diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6f831b5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,33 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{java,kt}] +indent_style = space +indent_size = 4 + +[*.{js,jsx,ts,tsx,json,yml,yaml,css,scss,html}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[*.{sh,bash}] +indent_style = space +indent_size = 4 + +[Makefile] +indent_style = tab + +[*.{gradle,groovy}] +indent_style = space +indent_size = 4 + +[*.xml] +indent_style = space +indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2125666 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto \ No newline at end of file diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..0c8f923 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,49 @@ +name: Build and Deploy Batch + +on: + push: + branches: + - main + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + container: + image: maven:3.9-eclipse-temurin-17 + steps: + - name: Checkout + run: | + git clone --depth=1 --branch=${GITHUB_REF_NAME} \ + http://gitea:3000/${GITHUB_REPOSITORY}.git . + + - name: Configure Maven settings + run: | + mkdir -p ~/.m2 + cat > ~/.m2/settings.xml << 'SETTINGS' + + + + nexus + * + https://nexus.gc-si.dev/repository/maven-public/ + + + + + nexus + ${{ secrets.NEXUS_USERNAME }} + ${{ secrets.NEXUS_PASSWORD }} + + + + SETTINGS + + - name: Build + run: mvn clean package -DskipTests -B + + - name: Deploy + run: | + cp target/snp-batch-validation-*.jar /deploy/snp-batch/app.jar + date '+%Y-%m-%d %H:%M:%S' > /deploy/snp-batch/.deploy-trigger + echo "Deployed at $(cat /deploy/snp-batch/.deploy-trigger)" + ls -la /deploy/snp-batch/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..feba811 --- /dev/null +++ b/.gitignore @@ -0,0 +1,108 @@ +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs +hs_err_pid* +replay_pid* + +# Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar +.mvn/wrapper/maven-wrapper.properties +mvnw +mvnw.cmd + +# Gradle +.gradle/ +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +# IntelliJ IDEA +.idea/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +# Eclipse +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +# VS Code +.vscode/ + +# NetBeans +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +# Mac +.DS_Store + +# Windows +Thumbs.db +ehthumbs.db +Desktop.ini + +# Application specific +application-local.yml +*.env +.env.* + +# Database +*.db +*.sqlite +*.sqlite3 + +# Logs +logs/ +*.log.* + +# Frontend (Vite + React) +frontend/node_modules/ +frontend/node/ +src/main/resources/static/ + +# Claude Code (개인 파일만 무시, 팀 파일은 추적) +.claude/settings.local.json +.claude/scripts/ diff --git a/.mvn/settings.xml b/.mvn/settings.xml new file mode 100644 index 0000000..e58dae0 --- /dev/null +++ b/.mvn/settings.xml @@ -0,0 +1,22 @@ + + + + + nexus + admin + Gcsc!8932 + + + + + + nexus + GC Nexus Repository + https://nexus.gc-si.dev/repository/maven-public/ + * + + + diff --git a/.sdkmanrc b/.sdkmanrc new file mode 100644 index 0000000..a4417cd --- /dev/null +++ b/.sdkmanrc @@ -0,0 +1 @@ +java=17.0.18-amzn diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ca66d12 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,99 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## 프로젝트 개요 + +S&P Maritime API에서 선박/항만/사건 데이터를 수집하여 PostgreSQL에 저장하는 배치 시스템. React 기반 관리 UI 포함. + +## 빌드 & 실행 + +```bash +# Java 버전 설정 +sdk use java 17.0.18-amzn + +# 프론트엔드 빌드 (src/main/resources/static/으로 출력) +cd frontend && npm install && npm run build && cd .. + +# 백엔드 빌드 (프론트 빌드 후 실행) +mvn clean package -DskipTests -Dskip.npm -Dskip.installnodenpm + +# 로컬 실행 +mvn spring-boot:run + +# 테스트 +mvn test + +# 프론트엔드 개발 서버 (localhost:5173, API는 8041로 프록시) +cd frontend && npm run dev +``` + +## 서버 설정 + +- 포트: 8041, Context Path: `/snp-collector` +- Swagger UI: `http://localhost:8041/snp-collector/swagger-ui/index.html` +- 프론트엔드 vite base: `/snp-collector/` (context-path와 일치해야 함) +- 프론트엔드 빌드 출력: `frontend/` → `src/main/resources/static/` (emptyOutDir: true) + +## 배치 Job 아키텍처 + +### 베이스 클래스 계층 (`common/batch/`) + +``` +BaseJobConfig # 단일 Step chunk-oriented Job + └── BaseMultiStepJobConfig # 다중 Step Job (createJobFlow() 오버라이드) + └── BasePartitionedJobConfig # 파티셔닝 병렬 처리 Job +``` + +- **BaseApiReader**: WebClient 기반 Maritime API 호출. GET/POST 지원. BatchApiLog 자동 기록. +- **BaseProcessor**: DTO→Entity 변환. `processItem()` 구현. +- **BaseWriter**: `writeItems()` 구현. BaseJdbcRepository의 saveAll() 사용 (기존 ID 조회 → insert/update 분리 → PreparedStatement 배치). +- **BaseEntity**: 공통 감사 필드 (createdAt, updatedAt, jobExecutionId). + +### 새 Job 추가 패턴 + +각 Job은 `jobs/batch/{도메인}/` 하위에 config, dto, entity, reader, processor, writer, repository 구성: +1. `*JobConfig` extends BaseJobConfig (또는 BaseMultiStepJobConfig/BasePartitionedJobConfig) +2. `*Reader` extends BaseApiReader — `fetchDataFromApi()` 또는 `fetchNextBatch()` 구현 +3. `*Processor` extends BaseProcessor — `processItem()` 구현 +4. `*Writer` extends BaseWriter — `writeItems()` 구현 +5. `*Repository` extends BaseJdbcRepository — SQL insert/update 구현 + +### Job 실행 흐름 + +1. **스케줄**: Quartz JDBC Store → `QuartzBatchJob` → `QuartzJobService` → Spring Batch Job 실행 +2. **수동 실행**: `BatchController.POST /api/batch/jobs/{jobName}/execute` +3. **재수집**: 실패 레코드 자동 재시도 (`AutoRetryJobExecutionListener`, 최대 3회) +4. **스케줄 초기화**: `SchedulerInitializer`가 앱 시작 시 DB의 `JobScheduleEntity`를 Quartz에 등록 + +## 주요 API 경로 (/api/batch) + +| 메서드 | 경로 | 설명 | +|--------|------|------| +| POST | /jobs/{jobName}/execute | 배치 작업 실행 | +| GET | /jobs | 작업 목록 | +| GET | /jobs/{jobName}/executions | 실행 이력 | +| GET | /executions/{id}/detail | 실행 상세 (Step 포함) | +| POST | /executions/{id}/stop | 실행 중지 | +| GET/POST | /schedules | 스케줄 관리 (CRUD) | +| GET | /dashboard | 대시보드 | +| GET | /timeline | 타임라인 | + +## 프론트엔드 + +- React 19 + TypeScript + Vite + Tailwind CSS +- 라우팅: React Router (basename `/snp-collector`) +- 페이지: Dashboard, Jobs, Executions, ExecutionDetail, Recollects, RecollectDetail, Schedules, Timeline +- API 클라이언트: `frontend/src/api/batchApi.ts` +- 전역 상태: ToastContext, ThemeContext + +## Lint/Format + +- 백엔드: 별도 lint 도구 미설정 (checkstyle, spotless 없음). IDE 기본 포매터 사용. +- 프론트엔드: `cd frontend && npm run lint` (ESLint) + +## 배포 + +- Gitea Actions (`.gitea/workflows/deploy.yml`) +- Maven Docker 이미지 (maven:3.9-eclipse-temurin-17) +- 빌드 산출물: `/deploy/snp-batch/app.jar` diff --git a/README.md b/README.md index 49fa9ea..df26211 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,91 @@ -# snp-collector +# SNP Collector (snp-collector) -S&P Collector - 해양 데이터 수집 배치 시스템 \ No newline at end of file +S&P Maritime API에서 선박/항만/사건 데이터를 수집하여 PostgreSQL에 저장하는 해양 데이터 배치 시스템. React 기반 관리 UI 포함. + +## 기술 스택 + +- Java 17, Spring Boot 3.2.1, Spring Batch 5.1.0 +- PostgreSQL, Quartz Scheduler, Caffeine Cache +- React 19 + Vite + Tailwind CSS 4 (관리 UI) +- frontend-maven-plugin (프론트엔드 빌드 통합) + +## 사전 요구사항 + +| 항목 | 버전 | 비고 | +|------|------|------| +| JDK | 17 | `.sdkmanrc` 참조 (`sdk env`) | +| Maven | 3.9+ | | +| Node.js | 20+ | 프론트엔드 빌드용 | +| npm | 10+ | Node.js에 포함 | + +## 빌드 + +> **주의**: frontend-maven-plugin의 Node 호환성 문제로, 프론트엔드와 백엔드를 분리하여 빌드합니다. + +### 터미널 + +```bash +# 1. 프론트엔드 빌드 +cd frontend && npm install && npm run build && cd .. + +# 2. Maven 패키징 (프론트엔드 빌드 스킵) +mvn clean package -DskipTests -Dskip.npm -Dskip.installnodenpm +``` + +빌드 결과: `target/snp-collector-1.0.0.jar` + +### IntelliJ IDEA + +1. **프론트엔드 빌드**: Terminal 탭에서 `cd frontend && npm run build` +2. **Maven 패키징**: Maven 패널 → Lifecycle → `package` + - VM Options: `-DskipTests -Dskip.npm -Dskip.installnodenpm` + - 또는 Run Configuration → Maven → Command line에 `clean package -DskipTests -Dskip.npm -Dskip.installnodenpm` + +## 로컬 실행 + +### 터미널 + +```bash +mvn spring-boot:run -Dspring-boot.run.profiles=local +``` + +### IntelliJ IDEA + +Run Configuration → Spring Boot: +- Main class: `com.snp.batch.SnpCollectorApplication` +- Active profiles: `local` + +## 서버 배포 + +```bash +# 1. 빌드 (위 빌드 절차 수행) + +# 2. JAR 전송 +scp target/snp-collector-1.0.0.jar {서버}:{경로}/ + +# 3. 실행 +java -jar snp-collector-1.0.0.jar --spring.profiles.active=dev +``` + +## 접속 정보 + +| 항목 | URL | +|------|-----| +| 관리 UI | `http://localhost:8041/snp-collector/` | +| Swagger | `http://localhost:8041/snp-collector/swagger-ui/index.html` | + +## 프로파일 + +| 프로파일 | 용도 | DB | +|----------|------|----| +| `local` | 로컬 개발 | 개발 DB | +| `dev` | 개발 서버 | 개발 DB | +| `prod` | 운영 서버 | 운영 DB | + +## Maven 빌드 플래그 요약 + +| 플래그 | 용도 | +|--------|------| +| `-DskipTests` | 테스트 스킵 | +| `-Dskip.npm` | npm install/build 스킵 | +| `-Dskip.installnodenpm` | Node/npm 자동 설치 스킵 | diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d2e7761 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e59a487 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,17 @@ + + + + + + + + + + + S&P Collector + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..46883a7 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3935 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router-dom": "^7.13.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@tailwindcss/vite": "^4.1.18", + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "tailwindcss": "^4.1.18", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0", + "vite": "^7.3.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", + "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "tailwindcss": "4.1.18" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", + "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", + "integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/type-utils": "8.56.0", + "@typescript-eslint/utils": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.56.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz", + "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz", + "integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.56.0", + "@typescript-eslint/types": "^8.56.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz", + "integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz", + "integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz", + "integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0", + "@typescript-eslint/utils": "8.56.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz", + "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz", + "integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.56.0", + "@typescript-eslint/tsconfig-utils": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz", + "integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz", + "integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz", + "integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", + "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001770", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", + "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz", + "integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz", + "integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==", + "license": "MIT", + "dependencies": { + "react-router": "7.13.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.0.tgz", + "integrity": "sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.56.0", + "@typescript-eslint/parser": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0", + "@typescript-eslint/utils": "8.56.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..7204e4d --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,33 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router-dom": "^7.13.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@tailwindcss/vite": "^4.1.18", + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "tailwindcss": "^4.1.18", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0", + "vite": "^7.3.1" + } +} diff --git a/frontend/public/android-chrome-192x192.png b/frontend/public/android-chrome-192x192.png new file mode 100644 index 0000000..8755e9a Binary files /dev/null and b/frontend/public/android-chrome-192x192.png differ diff --git a/frontend/public/android-chrome-512x512.png b/frontend/public/android-chrome-512x512.png new file mode 100644 index 0000000..de622e9 Binary files /dev/null and b/frontend/public/android-chrome-512x512.png differ diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png new file mode 100644 index 0000000..834a3a8 Binary files /dev/null and b/frontend/public/apple-touch-icon.png differ diff --git a/frontend/public/favicon-16x16.png b/frontend/public/favicon-16x16.png new file mode 100644 index 0000000..8299030 Binary files /dev/null and b/frontend/public/favicon-16x16.png differ diff --git a/frontend/public/favicon-32x32.png b/frontend/public/favicon-32x32.png new file mode 100644 index 0000000..20eedf0 Binary files /dev/null and b/frontend/public/favicon-32x32.png differ diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..ef7a1d1 Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/site.webmanifest b/frontend/public/site.webmanifest new file mode 100644 index 0000000..45dc8a2 --- /dev/null +++ b/frontend/public/site.webmanifest @@ -0,0 +1 @@ +{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..c086d17 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,56 @@ +import { lazy, Suspense } from 'react'; +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { ToastProvider, useToastContext } from './contexts/ToastContext'; +import { ThemeProvider } from './contexts/ThemeContext'; +import Navbar from './components/Navbar'; +import ToastContainer from './components/Toast'; +import LoadingSpinner from './components/LoadingSpinner'; + +const Dashboard = lazy(() => import('./pages/Dashboard')); +const Jobs = lazy(() => import('./pages/Jobs')); +const Executions = lazy(() => import('./pages/Executions')); +const ExecutionDetail = lazy(() => import('./pages/ExecutionDetail')); +const Recollects = lazy(() => import('./pages/Recollects')); +const RecollectDetail = lazy(() => import('./pages/RecollectDetail')); +const Schedules = lazy(() => import('./pages/Schedules')); +const Timeline = lazy(() => import('./pages/Timeline')); + +function AppLayout() { + const { toasts, removeToast } = useToastContext(); + + return ( +
+
+ +
+
+ }> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + +
+ +
+ ); +} + +export default function App() { + return ( + + + + + + + + ); +} diff --git a/frontend/src/api/batchApi.ts b/frontend/src/api/batchApi.ts new file mode 100644 index 0000000..e2017ed --- /dev/null +++ b/frontend/src/api/batchApi.ts @@ -0,0 +1,533 @@ +const BASE = import.meta.env.DEV ? '/snp-collector/api/batch' : '/snp-collector/api/batch'; + +async function fetchJson(url: string): Promise { + const res = await fetch(url); + if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`); + return res.json(); +} + +async function postJson(url: string, body?: unknown): Promise { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`); + return res.json(); +} + +// ── Dashboard ──────────────────────────────────────────────── + +export interface DashboardStats { + totalSchedules: number; + activeSchedules: number; + inactiveSchedules: number; + totalJobs: number; +} + +export interface RunningJob { + jobName: string; + executionId: number; + status: string; + startTime: string; +} + +export interface RecentExecution { + executionId: number; + jobName: string; + status: string; + startTime: string; + endTime: string | null; +} + +export interface RecentFailure { + executionId: number; + jobName: string; + status: string; + startTime: string; + endTime: string | null; + exitMessage: string | null; +} + +export interface FailureStats { + last24h: number; + last7d: number; +} + +export interface DashboardResponse { + stats: DashboardStats; + runningJobs: RunningJob[]; + recentExecutions: RecentExecution[]; + recentFailures: RecentFailure[]; + staleExecutionCount: number; + failureStats: FailureStats; +} + +// ── Job Execution ──────────────────────────────────────────── + +export interface JobExecutionDto { + executionId: number; + jobName: string; + status: string; + startTime: string; + endTime: string | null; + exitCode: string | null; + exitMessage: string | null; + failedRecordCount: number | null; +} + +export interface ApiCallInfo { + apiUrl: string; + method: string; + parameters: Record | null; + totalCalls: number; + completedCalls: number; + lastCallTime: string; +} + +export interface StepExecutionDto { + stepExecutionId: number; + stepName: string; + status: string; + startTime: string; + endTime: string | null; + readCount: number; + writeCount: number; + commitCount: number; + rollbackCount: number; + readSkipCount: number; + processSkipCount: number; + writeSkipCount: number; + filterCount: number; + exitCode: string; + exitMessage: string | null; + duration: number | null; + apiCallInfo: ApiCallInfo | null; + apiLogSummary: StepApiLogSummary | null; + failedRecords?: FailedRecordDto[] | null; +} + +export interface ApiLogEntryDto { + logId: number; + requestUri: string; + httpMethod: string; + statusCode: number | null; + responseTimeMs: number | null; + responseCount: number | null; + errorMessage: string | null; + createdAt: string; +} + +export interface StepApiLogSummary { + totalCalls: number; + successCount: number; + errorCount: number; + avgResponseMs: number; + maxResponseMs: number; + minResponseMs: number; + totalResponseMs: number; + totalRecordCount: number; +} + +export interface ApiLogPageResponse { + content: ApiLogEntryDto[]; + page: number; + size: number; + totalElements: number; + totalPages: number; +} + +export type ApiLogStatus = 'ALL' | 'SUCCESS' | 'ERROR'; + +export interface FailedRecordDto { + id: number; + jobName: string; + recordKey: string; + errorMessage: string | null; + retryCount: number; + status: string; + createdAt: string; +} + +export interface JobExecutionDetailDto { + executionId: number; + jobName: string; + status: string; + startTime: string; + endTime: string | null; + exitCode: string; + exitMessage: string | null; + jobParameters: Record; + jobInstanceId: number; + duration: number | null; + readCount: number; + writeCount: number; + skipCount: number; + filterCount: number; + stepExecutions: StepExecutionDto[]; +} + +// ── Schedule ───────────────────────────────────────────────── + +export interface ScheduleResponse { + id: number; + jobName: string; + cronExpression: string; + description: string | null; + active: boolean; + nextFireTime: string | null; + previousFireTime: string | null; + triggerState: string | null; + createdAt: string; + updatedAt: string; +} + +export interface ScheduleRequest { + jobName: string; + cronExpression: string; + description?: string; + active?: boolean; +} + +// ── Timeline ───────────────────────────────────────────────── + +export interface PeriodInfo { + key: string; + label: string; +} + +export interface ExecutionInfo { + executionId: number | null; + status: string; + startTime: string | null; + endTime: string | null; +} + +export interface ScheduleTimeline { + jobName: string; + executions: Record; +} + +export interface TimelineResponse { + periodLabel: string; + periods: PeriodInfo[]; + schedules: ScheduleTimeline[]; +} + +// ── F4: Execution Search ───────────────────────────────────── + +export interface ExecutionSearchResponse { + executions: JobExecutionDto[]; + totalCount: number; + page: number; + size: number; + totalPages: number; +} + +// ── F7: Job Detail ─────────────────────────────────────────── + +export interface LastExecution { + executionId: number; + status: string; + startTime: string; + endTime: string | null; +} + +export interface JobDetailDto { + jobName: string; + displayName: string | null; + lastExecution: LastExecution | null; + scheduleCron: string | null; +} + +// ── F8: Statistics ─────────────────────────────────────────── + +export interface DailyStat { + date: string; + successCount: number; + failedCount: number; + otherCount: number; + avgDurationMs: number; +} + +export interface ExecutionStatisticsDto { + dailyStats: DailyStat[]; + totalExecutions: number; + totalSuccess: number; + totalFailed: number; + avgDurationMs: number; +} + +// ── Recollection History ───────────────────────────────────── + +export interface RecollectionHistoryDto { + historyId: number; + apiKey: string; + apiKeyName: string | null; + jobName: string; + jobExecutionId: number | null; + rangeFromDate: string; + rangeToDate: string; + executionStatus: string; + executionStartTime: string | null; + executionEndTime: string | null; + durationMs: number | null; + readCount: number | null; + writeCount: number | null; + skipCount: number | null; + apiCallCount: number | null; + executor: string | null; + recollectionReason: string | null; + failureReason: string | null; + hasOverlap: boolean | null; + createdAt: string; +} + +export interface RecollectionSearchResponse { + content: RecollectionHistoryDto[]; + totalElements: number; + number: number; + size: number; + totalPages: number; + failedRecordCounts: Record; +} + +export interface RecollectionStatsResponse { + totalCount: number; + completedCount: number; + failedCount: number; + runningCount: number; + overlapCount: number; + recentHistories: RecollectionHistoryDto[]; +} + +export interface ApiStatsDto { + callCount: number; + totalMs: number; + avgMs: number; + maxMs: number; + minMs: number; +} + +export interface RecollectionDetailResponse { + history: RecollectionHistoryDto; + overlappingHistories: RecollectionHistoryDto[]; + apiStats: ApiStatsDto | null; + collectionPeriod: CollectionPeriodDto | null; + stepExecutions: StepExecutionDto[]; +} + +export interface CollectionPeriodDto { + apiKey: string; + apiKeyName: string | null; + jobName: string | null; + orderSeq: number | null; + rangeFromDate: string | null; + rangeToDate: string | null; +} + +// ── Last Collection Status ─────────────────────────────────── + +export interface LastCollectionStatusDto { + apiKey: string; + apiDesc: string | null; + lastSuccessDate: string | null; + updatedAt: string | null; + elapsedMinutes: number; +} + +// ── Job Display Name ────────────────────────────────────────── + +export interface JobDisplayName { + id: number; + jobName: string; + displayName: string; + apiKey: string | null; +} + +// ── API Functions ──────────────────────────────────────────── + +export const batchApi = { + getDashboard: () => + fetchJson(`${BASE}/dashboard`), + + getJobs: () => + fetchJson(`${BASE}/jobs`), + + getJobsDetail: () => + fetchJson(`${BASE}/jobs/detail`), + + executeJob: (jobName: string, params?: Record) => { + const qs = params ? '?' + new URLSearchParams(params).toString() : ''; + return postJson<{ success: boolean; message: string; executionId?: number }>( + `${BASE}/jobs/${jobName}/execute${qs}`); + }, + + retryFailedRecords: (jobName: string, failedCount: number, jobExecutionId: number) => { + const qs = new URLSearchParams({ + sourceJobExecutionId: String(jobExecutionId), + executionMode: 'RECOLLECT', + executor: 'MANUAL_RETRY', + reason: `실패 건 수동 재수집 (${failedCount}건)`, + }); + return postJson<{ success: boolean; message: string; executionId?: number }>( + `${BASE}/jobs/${jobName}/execute?${qs.toString()}`); + }, + + getJobExecutions: (jobName: string) => + fetchJson(`${BASE}/jobs/${jobName}/executions`), + + getRecentExecutions: (limit = 50) => + fetchJson(`${BASE}/executions/recent?limit=${limit}`), + + getExecutionDetail: (id: number) => + fetchJson(`${BASE}/executions/${id}/detail`), + + stopExecution: (id: number) => + postJson<{ success: boolean; message: string }>(`${BASE}/executions/${id}/stop`), + + // F1: Abandon + getStaleExecutions: (thresholdMinutes = 60) => + fetchJson(`${BASE}/executions/stale?thresholdMinutes=${thresholdMinutes}`), + + abandonExecution: (id: number) => + postJson<{ success: boolean; message: string }>(`${BASE}/executions/${id}/abandon`), + + abandonAllStale: (thresholdMinutes = 60) => + postJson<{ success: boolean; message: string; abandonedCount?: number }>( + `${BASE}/executions/stale/abandon-all?thresholdMinutes=${thresholdMinutes}`), + + // F4: Search + searchExecutions: (params: { + jobNames?: string[]; + status?: string; + startDate?: string; + endDate?: string; + page?: number; + size?: number; + }) => { + const qs = new URLSearchParams(); + if (params.jobNames && params.jobNames.length > 0) qs.set('jobNames', params.jobNames.join(',')); + if (params.status) qs.set('status', params.status); + if (params.startDate) qs.set('startDate', params.startDate); + if (params.endDate) qs.set('endDate', params.endDate); + qs.set('page', String(params.page ?? 0)); + qs.set('size', String(params.size ?? 50)); + return fetchJson(`${BASE}/executions/search?${qs.toString()}`); + }, + + // F8: Statistics + getStatistics: (days = 30) => + fetchJson(`${BASE}/statistics?days=${days}`), + + getJobStatistics: (jobName: string, days = 30) => + fetchJson(`${BASE}/statistics/${jobName}?days=${days}`), + + // Schedule + getSchedules: () => + fetchJson<{ schedules: ScheduleResponse[]; count: number }>(`${BASE}/schedules`), + + getSchedule: (jobName: string) => + fetchJson(`${BASE}/schedules/${jobName}`), + + createSchedule: (data: ScheduleRequest) => + postJson<{ success: boolean; message: string; data?: ScheduleResponse }>(`${BASE}/schedules`, data), + + updateSchedule: (jobName: string, data: { cronExpression: string; description?: string }) => + postJson<{ success: boolean; message: string; data?: ScheduleResponse }>( + `${BASE}/schedules/${jobName}/update`, data), + + deleteSchedule: (jobName: string) => + postJson<{ success: boolean; message: string }>(`${BASE}/schedules/${jobName}/delete`), + + toggleSchedule: (jobName: string, active: boolean) => + postJson<{ success: boolean; message: string; data?: ScheduleResponse }>( + `${BASE}/schedules/${jobName}/toggle`, { active }), + + // Timeline + getTimeline: (view: string, date: string) => + fetchJson(`${BASE}/timeline?view=${view}&date=${date}`), + + getPeriodExecutions: (jobName: string, view: string, periodKey: string) => + fetchJson( + `${BASE}/timeline/period-executions?jobName=${jobName}&view=${view}&periodKey=${periodKey}`), + + // Recollection + searchRecollections: (params: { + apiKey?: string; + jobName?: string; + status?: string; + fromDate?: string; + toDate?: string; + page?: number; + size?: number; + }) => { + const qs = new URLSearchParams(); + if (params.apiKey) qs.set('apiKey', params.apiKey); + if (params.jobName) qs.set('jobName', params.jobName); + if (params.status) qs.set('status', params.status); + if (params.fromDate) qs.set('fromDate', params.fromDate); + if (params.toDate) qs.set('toDate', params.toDate); + qs.set('page', String(params.page ?? 0)); + qs.set('size', String(params.size ?? 20)); + return fetchJson(`${BASE}/recollection-histories?${qs.toString()}`); + }, + + getStepApiLogs: (stepExecutionId: number, params?: { + page?: number; size?: number; status?: ApiLogStatus; + }) => { + const qs = new URLSearchParams(); + qs.set('page', String(params?.page ?? 0)); + qs.set('size', String(params?.size ?? 50)); + if (params?.status && params.status !== 'ALL') qs.set('status', params.status); + return fetchJson( + `${BASE}/steps/${stepExecutionId}/api-logs?${qs.toString()}`); + }, + + getRecollectionDetail: (historyId: number) => + fetchJson(`${BASE}/recollection-histories/${historyId}`), + + getRecollectionStats: () => + fetchJson(`${BASE}/recollection-histories/stats`), + + getCollectionPeriods: () => + fetchJson(`${BASE}/collection-periods`), + + resetCollectionPeriod: (apiKey: string) => + postJson<{ success: boolean; message: string }>(`${BASE}/collection-periods/${apiKey}/reset`), + + updateCollectionPeriod: (apiKey: string, body: { rangeFromDate: string; rangeToDate: string }) => + postJson<{ success: boolean; message: string }>(`${BASE}/collection-periods/${apiKey}/update`, body), + + // Last Collection Status + getLastCollectionStatuses: () => + fetchJson(`${BASE}/last-collections`), + + // Display Names + getDisplayNames: () => + fetchJson(`${BASE}/display-names`), + + resolveFailedRecords: (ids: number[]) => + postJson<{ success: boolean; message: string; resolvedCount?: number }>( + `${BASE}/failed-records/resolve`, { ids }), + + resetRetryCount: (ids: number[]) => + postJson<{ success: boolean; message: string; resetCount?: number }>( + `${BASE}/failed-records/reset-retry`, { ids }), + + exportRecollectionHistories: (params: { + apiKey?: string; + jobName?: string; + status?: string; + fromDate?: string; + toDate?: string; + }) => { + const qs = new URLSearchParams(); + if (params.apiKey) qs.set('apiKey', params.apiKey); + if (params.jobName) qs.set('jobName', params.jobName); + if (params.status) qs.set('status', params.status); + if (params.fromDate) qs.set('fromDate', params.fromDate); + if (params.toDate) qs.set('toDate', params.toDate); + window.open(`${BASE}/recollection-histories/export?${qs.toString()}`); + }, +}; diff --git a/frontend/src/components/ApiLogSection.tsx b/frontend/src/components/ApiLogSection.tsx new file mode 100644 index 0000000..7f89b6d --- /dev/null +++ b/frontend/src/components/ApiLogSection.tsx @@ -0,0 +1,170 @@ +import { useState, useCallback, useEffect } from 'react'; +import { batchApi, type ApiLogPageResponse, type ApiLogStatus } from '../api/batchApi'; +import { formatDateTime } from '../utils/formatters'; +import Pagination from './Pagination'; +import CopyButton from './CopyButton'; + +interface ApiLogSectionProps { + stepExecutionId: number; + summary: { totalCalls: number; successCount: number; errorCount: number }; +} + +export default function ApiLogSection({ stepExecutionId, summary }: ApiLogSectionProps) { + const [open, setOpen] = useState(false); + const [status, setStatus] = useState('ALL'); + const [page, setPage] = useState(0); + const [logData, setLogData] = useState(null); + const [loading, setLoading] = useState(false); + + const fetchLogs = useCallback(async (p: number, s: ApiLogStatus) => { + setLoading(true); + try { + const data = await batchApi.getStepApiLogs(stepExecutionId, { page: p, size: 10, status: s }); + setLogData(data); + } catch { + setLogData(null); + } finally { + setLoading(false); + } + }, [stepExecutionId]); + + useEffect(() => { + if (open) { + fetchLogs(page, status); + } + }, [open, page, status, fetchLogs]); + + const handleStatusChange = (s: ApiLogStatus) => { + setStatus(s); + setPage(0); + }; + + const filters: { key: ApiLogStatus; label: string; count: number }[] = [ + { key: 'ALL', label: '전체', count: summary.totalCalls }, + { key: 'SUCCESS', label: '성공', count: summary.successCount }, + { key: 'ERROR', label: '에러', count: summary.errorCount }, + ]; + + return ( +
+ + + {open && ( +
+ {/* 상태 필터 탭 */} +
+ {filters.map(({ key, label, count }) => ( + + ))} +
+ + {loading ? ( +
+
+ 로딩중... +
+ ) : logData && logData.content.length > 0 ? ( + <> +
+ + + + + + + + + + + + + + + {logData.content.map((log, idx) => { + const isError = (log.statusCode != null && log.statusCode >= 400) || log.errorMessage; + return ( + + + + + + + + + + + ); + })} + +
#URIMethod상태응답(ms)건수시간에러
{page * 10 + idx + 1} +
+ + {log.requestUri} + + +
+
{log.httpMethod} + + {log.statusCode ?? '-'} + + + {log.responseTimeMs?.toLocaleString() ?? '-'} + + {log.responseCount?.toLocaleString() ?? '-'} + + {formatDateTime(log.createdAt)} + + {log.errorMessage || '-'} +
+
+ + {/* 페이지네이션 */} + + + ) : ( +

조회된 로그가 없습니다.

+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/BarChart.tsx b/frontend/src/components/BarChart.tsx new file mode 100644 index 0000000..2c25aaa --- /dev/null +++ b/frontend/src/components/BarChart.tsx @@ -0,0 +1,74 @@ +interface BarValue { + color: string; + value: number; +} + +interface BarData { + label: string; + values: BarValue[]; +} + +interface Props { + data: BarData[]; + height?: number; +} + +export default function BarChart({ data, height = 200 }: Props) { + const maxTotal = Math.max(...data.map((d) => d.values.reduce((sum, v) => sum + v.value, 0)), 1); + + return ( +
+
+ {data.map((bar, i) => { + const total = bar.values.reduce((sum, v) => sum + v.value, 0); + const ratio = total / maxTotal; + + return ( +
+
`${v.color}: ${v.value}`).join(', ')} + > + {bar.values + .filter((v) => v.value > 0) + .map((v, j) => { + const segmentRatio = total > 0 ? (v.value / total) * 100 : 0; + return ( +
+ ); + })} +
+
+ ); + })} +
+
+ {data.map((bar, i) => ( +
+

+ {bar.label} +

+
+ ))} +
+
+ ); +} + +function colorToClass(color: string): string { + const map: Record = { + green: 'bg-green-500', + red: 'bg-red-500', + gray: 'bg-gray-400', + blue: 'bg-blue-500', + yellow: 'bg-yellow-500', + orange: 'bg-orange-500', + indigo: 'bg-indigo-500', + }; + return map[color] ?? color; +} diff --git a/frontend/src/components/ConfirmModal.tsx b/frontend/src/components/ConfirmModal.tsx new file mode 100644 index 0000000..8ae36f5 --- /dev/null +++ b/frontend/src/components/ConfirmModal.tsx @@ -0,0 +1,49 @@ +interface Props { + open: boolean; + title?: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + confirmColor?: string; + onConfirm: () => void; + onCancel: () => void; +} + +export default function ConfirmModal({ + open, + title = '확인', + message, + confirmLabel = '확인', + cancelLabel = '취소', + confirmColor = 'bg-wing-accent hover:bg-wing-accent/80', + onConfirm, + onCancel, +}: Props) { + if (!open) return null; + + return ( +
+
e.stopPropagation()} + > +

{title}

+

{message}

+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/CopyButton.tsx b/frontend/src/components/CopyButton.tsx new file mode 100644 index 0000000..617bf3e --- /dev/null +++ b/frontend/src/components/CopyButton.tsx @@ -0,0 +1,48 @@ +import { useState } from 'react'; + +interface CopyButtonProps { + text: string; +} + +export default function CopyButton({ text }: CopyButtonProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation(); + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } + }; + + return ( + + ); +} diff --git a/frontend/src/components/DetailStatCard.tsx b/frontend/src/components/DetailStatCard.tsx new file mode 100644 index 0000000..13d0b11 --- /dev/null +++ b/frontend/src/components/DetailStatCard.tsx @@ -0,0 +1,22 @@ +interface DetailStatCardProps { + label: string; + value: number; + gradient: string; + icon: string; +} + +export default function DetailStatCard({ label, value, gradient, icon }: DetailStatCardProps) { + return ( +
+
+
+

{label}

+

+ {value.toLocaleString()} +

+
+ {icon} +
+
+ ); +} diff --git a/frontend/src/components/EmptyState.tsx b/frontend/src/components/EmptyState.tsx new file mode 100644 index 0000000..0cf5f0a --- /dev/null +++ b/frontend/src/components/EmptyState.tsx @@ -0,0 +1,15 @@ +interface Props { + icon?: string; + message: string; + sub?: string; +} + +export default function EmptyState({ icon = '📭', message, sub }: Props) { + return ( +
+ {icon} +

{message}

+ {sub &&

{sub}

} +
+ ); +} diff --git a/frontend/src/components/GuideModal.tsx b/frontend/src/components/GuideModal.tsx new file mode 100644 index 0000000..0c4ee2e --- /dev/null +++ b/frontend/src/components/GuideModal.tsx @@ -0,0 +1,92 @@ +import { useState } from 'react'; + +interface GuideSection { + title: string; + content: string; +} + +interface Props { + open: boolean; + pageTitle: string; + sections: GuideSection[]; + onClose: () => void; +} + +export default function GuideModal({ open, pageTitle, sections, onClose }: Props) { + if (!open) return null; + + return ( +
+
e.stopPropagation()} + > +
+

{pageTitle} 사용 가이드

+ +
+ +
+ {sections.map((section, i) => ( + + ))} +
+ +
+ +
+
+
+ ); +} + +function GuideAccordion({ title, content, defaultOpen }: { title: string; content: string; defaultOpen: boolean }) { + const [isOpen, setIsOpen] = useState(defaultOpen); + + return ( +
+ + {isOpen && ( +
+ {content} +
+ )} +
+ ); +} + +export function HelpButton({ onClick }: { onClick: () => void }) { + return ( + + ); +} diff --git a/frontend/src/components/InfoItem.tsx b/frontend/src/components/InfoItem.tsx new file mode 100644 index 0000000..d8b400e --- /dev/null +++ b/frontend/src/components/InfoItem.tsx @@ -0,0 +1,15 @@ +interface InfoItemProps { + label: string; + value: string; +} + +export default function InfoItem({ label, value }: InfoItemProps) { + return ( +
+
+ {label} +
+
{value || '-'}
+
+ ); +} diff --git a/frontend/src/components/InfoModal.tsx b/frontend/src/components/InfoModal.tsx new file mode 100644 index 0000000..cedb5a9 --- /dev/null +++ b/frontend/src/components/InfoModal.tsx @@ -0,0 +1,35 @@ +interface Props { + open: boolean; + title?: string; + children: React.ReactNode; + onClose: () => void; +} + +export default function InfoModal({ + open, + title = '정보', + children, + onClose, +}: Props) { + if (!open) return null; + + return ( +
+
e.stopPropagation()} + > +

{title}

+
{children}
+
+ +
+
+
+ ); +} diff --git a/frontend/src/components/LoadingSpinner.tsx b/frontend/src/components/LoadingSpinner.tsx new file mode 100644 index 0000000..738e225 --- /dev/null +++ b/frontend/src/components/LoadingSpinner.tsx @@ -0,0 +1,7 @@ +export default function LoadingSpinner({ className = '' }: { className?: string }) { + return ( +
+
+
+ ); +} diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx new file mode 100644 index 0000000..c0ca23e --- /dev/null +++ b/frontend/src/components/Navbar.tsx @@ -0,0 +1,116 @@ +import { useLocation, useNavigate } from 'react-router-dom'; +import { useThemeContext } from '../contexts/ThemeContext'; + +interface MenuItem { + id: string; + label: string; + path: string; +} + +interface MenuSection { + id: string; + label: string; + shortLabel: string; + icon: React.ReactNode; + defaultPath: string; + children: MenuItem[]; +} + +const MENU_STRUCTURE: MenuSection[] = [ + { + id: 'collector', + label: 'S&P Collector', + shortLabel: 'Collector', + icon: ( + + + + ), + defaultPath: '/dashboard', + children: [ + { id: 'dashboard', label: '대시보드', path: '/dashboard' }, + { id: 'executions', label: '실행 이력', path: '/executions' }, + { id: 'recollects', label: '재수집 이력', path: '/recollects' }, + { id: 'jobs', label: '작업 관리', path: '/jobs' }, + { id: 'schedules', label: '스케줄', path: '/schedules' }, + { id: 'timeline', label: '타임라인', path: '/schedule-timeline' }, + ], + }, +]; + +function getCurrentSection(pathname: string): MenuSection | null { + for (const section of MENU_STRUCTURE) { + if (section.children.some((c) => pathname === c.path || pathname.startsWith(c.path + '/'))) { + return section; + } + } + return null; +} + +export default function Navbar() { + const location = useLocation(); + const navigate = useNavigate(); + const { theme, toggle } = useThemeContext(); + const currentSection = getCurrentSection(location.pathname); + + // 메인 화면에서는 숨김 + if (!currentSection) return null; + + const isActivePath = (path: string) => { + return location.pathname === path || location.pathname.startsWith(path + '/'); + }; + + return ( +
+ {/* 1단: 섹션 탭 */} +
+
+ {MENU_STRUCTURE.map((section) => ( + + ))} +
+ +
+ + {/* 2단: 서브 탭 */} +
+
+ {currentSection?.children.map((child) => ( + + ))} +
+
+
+ ); +} diff --git a/frontend/src/components/Pagination.tsx b/frontend/src/components/Pagination.tsx new file mode 100644 index 0000000..b735e4f --- /dev/null +++ b/frontend/src/components/Pagination.tsx @@ -0,0 +1,145 @@ +interface PaginationProps { + page: number; + totalPages: number; + totalElements: number; + pageSize: number; + onPageChange: (page: number) => void; +} + +/** + * 표시할 페이지 번호 목록 생성 (Truncated Page Number) + * - 총 7슬롯 이하면 전부 표시 + * - 7슬롯 초과면 현재 페이지 기준 양쪽 1개 + 처음/끝 + ellipsis + */ +function getPageNumbers(current: number, total: number): (number | 'ellipsis')[] { + if (total <= 7) { + return Array.from({ length: total }, (_, i) => i); + } + + const pages: (number | 'ellipsis')[] = []; + const SIBLING = 1; + + const leftSibling = Math.max(current - SIBLING, 0); + const rightSibling = Math.min(current + SIBLING, total - 1); + + const showLeftEllipsis = leftSibling > 1; + const showRightEllipsis = rightSibling < total - 2; + + pages.push(0); + + if (showLeftEllipsis) { + pages.push('ellipsis'); + } else { + for (let i = 1; i < leftSibling; i++) { + pages.push(i); + } + } + + for (let i = leftSibling; i <= rightSibling; i++) { + if (i !== 0 && i !== total - 1) { + pages.push(i); + } + } + + if (showRightEllipsis) { + pages.push('ellipsis'); + } else { + for (let i = rightSibling + 1; i < total - 1; i++) { + pages.push(i); + } + } + + if (total > 1) { + pages.push(total - 1); + } + + return pages; +} + +export default function Pagination({ + page, + totalPages, + totalElements, + pageSize, + onPageChange, +}: PaginationProps) { + if (totalPages <= 1) return null; + + const start = page * pageSize + 1; + const end = Math.min((page + 1) * pageSize, totalElements); + const pages = getPageNumbers(page, totalPages); + + const btnBase = + 'inline-flex items-center justify-center w-7 h-7 text-xs rounded transition-colors'; + const btnEnabled = 'hover:bg-wing-hover text-wing-muted'; + const btnDisabled = 'opacity-30 cursor-not-allowed text-wing-muted'; + + return ( +
+ + {totalElements.toLocaleString()}건 중 {start.toLocaleString()}~ + {end.toLocaleString()} + +
+ {/* First */} + + {/* Prev */} + + + {/* Page Numbers */} + {pages.map((p, idx) => + p === 'ellipsis' ? ( + + … + + ) : ( + + ), + )} + + {/* Next */} + + {/* Last */} + +
+
+ ); +} diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx new file mode 100644 index 0000000..686660e --- /dev/null +++ b/frontend/src/components/StatusBadge.tsx @@ -0,0 +1,40 @@ +const STATUS_CONFIG: Record = { + COMPLETED: { bg: 'bg-emerald-100 text-emerald-700', text: '완료', label: '✓' }, + FAILED: { bg: 'bg-red-100 text-red-700', text: '실패', label: '✕' }, + STARTED: { bg: 'bg-blue-100 text-blue-700', text: '실행중', label: '↻' }, + STARTING: { bg: 'bg-cyan-100 text-cyan-700', text: '시작중', label: '⏳' }, + STOPPED: { bg: 'bg-amber-100 text-amber-700', text: '중지됨', label: '⏸' }, + STOPPING: { bg: 'bg-orange-100 text-orange-700', text: '중지중', label: '⏸' }, + ABANDONED: { bg: 'bg-gray-100 text-gray-700', text: '포기됨', label: '—' }, + SCHEDULED: { bg: 'bg-violet-100 text-violet-700', text: '예정', label: '🕐' }, + UNKNOWN: { bg: 'bg-gray-100 text-gray-500', text: '알수없음', label: '?' }, +}; + +interface Props { + status: string; + className?: string; +} + +export default function StatusBadge({ status, className = '' }: Props) { + const config = STATUS_CONFIG[status] || STATUS_CONFIG.UNKNOWN; + return ( + + {config.label} + {config.text} + + ); +} + +// eslint-disable-next-line react-refresh/only-export-components +export function getStatusColor(status: string): string { + switch (status) { + case 'COMPLETED': return '#10b981'; + case 'FAILED': return '#ef4444'; + case 'STARTED': return '#3b82f6'; + case 'STARTING': return '#06b6d4'; + case 'STOPPED': return '#f59e0b'; + case 'STOPPING': return '#f97316'; + case 'SCHEDULED': return '#8b5cf6'; + default: return '#6b7280'; + } +} diff --git a/frontend/src/components/Toast.tsx b/frontend/src/components/Toast.tsx new file mode 100644 index 0000000..90e5750 --- /dev/null +++ b/frontend/src/components/Toast.tsx @@ -0,0 +1,37 @@ +import type { Toast as ToastType } from '../hooks/useToast'; + +const TYPE_STYLES: Record = { + success: 'bg-emerald-500', + error: 'bg-red-500', + warning: 'bg-amber-500', + info: 'bg-blue-500', +}; + +interface Props { + toasts: ToastType[]; + onRemove: (id: number) => void; +} + +export default function ToastContainer({ toasts, onRemove }: Props) { + if (toasts.length === 0) return null; + + return ( +
+ {toasts.map((toast) => ( +
+ {toast.message} + +
+ ))} +
+ ); +} diff --git a/frontend/src/contexts/ThemeContext.tsx b/frontend/src/contexts/ThemeContext.tsx new file mode 100644 index 0000000..0db5e08 --- /dev/null +++ b/frontend/src/contexts/ThemeContext.tsx @@ -0,0 +1,26 @@ +import { createContext, useContext, type ReactNode } from 'react'; +import { useTheme } from '../hooks/useTheme'; + +interface ThemeContextValue { + theme: 'dark' | 'light'; + toggle: () => void; +} + +const ThemeContext = createContext({ + theme: 'dark', + toggle: () => {}, +}); + +export function ThemeProvider({ children }: { children: ReactNode }) { + const value = useTheme(); + return ( + + {children} + + ); +} + +// eslint-disable-next-line react-refresh/only-export-components +export function useThemeContext() { + return useContext(ThemeContext); +} diff --git a/frontend/src/contexts/ToastContext.tsx b/frontend/src/contexts/ToastContext.tsx new file mode 100644 index 0000000..31cae8e --- /dev/null +++ b/frontend/src/contexts/ToastContext.tsx @@ -0,0 +1,29 @@ +import { createContext, useContext, type ReactNode } from 'react'; +import { useToast, type Toast } from '../hooks/useToast'; + +interface ToastContextValue { + toasts: Toast[]; + showToast: (message: string, type?: Toast['type']) => void; + removeToast: (id: number) => void; +} + +const ToastContext = createContext(null); + +export function ToastProvider({ children }: { children: ReactNode }) { + const { toasts, showToast, removeToast } = useToast(); + + return ( + + {children} + + ); +} + +// eslint-disable-next-line react-refresh/only-export-components +export function useToastContext(): ToastContextValue { + const ctx = useContext(ToastContext); + if (!ctx) { + throw new Error('useToastContext must be used within a ToastProvider'); + } + return ctx; +} diff --git a/frontend/src/hooks/usePoller.ts b/frontend/src/hooks/usePoller.ts new file mode 100644 index 0000000..04ac6c1 --- /dev/null +++ b/frontend/src/hooks/usePoller.ts @@ -0,0 +1,53 @@ +import { useEffect, useRef } from 'react'; + +/** + * 주기적 폴링 훅 + * - 마운트 시 즉시 1회 실행 후 intervalMs 주기로 반복 + * - 탭 비활성(document.hidden) 시 자동 중단, 활성화 시 즉시 재개 + * - deps 변경 시 타이머 재설정 + */ +export function usePoller( + fn: () => Promise | void, + intervalMs: number, + deps: unknown[] = [], +) { + const fnRef = useRef(fn); + fnRef.current = fn; + + useEffect(() => { + let timer: ReturnType | null = null; + + const run = () => { + fnRef.current(); + }; + + const start = () => { + run(); + timer = setInterval(run, intervalMs); + }; + + const stop = () => { + if (timer) { + clearInterval(timer); + timer = null; + } + }; + + const handleVisibility = () => { + if (document.hidden) { + stop(); + } else { + start(); + } + }; + + start(); + document.addEventListener('visibilitychange', handleVisibility); + + return () => { + stop(); + document.removeEventListener('visibilitychange', handleVisibility); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [intervalMs, ...deps]); +} diff --git a/frontend/src/hooks/useTheme.ts b/frontend/src/hooks/useTheme.ts new file mode 100644 index 0000000..a62aa00 --- /dev/null +++ b/frontend/src/hooks/useTheme.ts @@ -0,0 +1,27 @@ +import { useState, useEffect, useCallback } from 'react'; + +type Theme = 'dark' | 'light'; + +const STORAGE_KEY = 'snp-batch-theme'; + +function getInitialTheme(): Theme { + if (typeof window === 'undefined') return 'dark'; + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === 'light' || stored === 'dark') return stored; + return 'dark'; +} + +export function useTheme() { + const [theme, setTheme] = useState(getInitialTheme); + + useEffect(() => { + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem(STORAGE_KEY, theme); + }, [theme]); + + const toggle = useCallback(() => { + setTheme((prev) => (prev === 'dark' ? 'light' : 'dark')); + }, []); + + return { theme, toggle } as const; +} diff --git a/frontend/src/hooks/useToast.ts b/frontend/src/hooks/useToast.ts new file mode 100644 index 0000000..04ce8f8 --- /dev/null +++ b/frontend/src/hooks/useToast.ts @@ -0,0 +1,27 @@ +import { useState, useCallback } from 'react'; + +export interface Toast { + id: number; + message: string; + type: 'success' | 'error' | 'warning' | 'info'; +} + +let nextId = 0; + +export function useToast() { + const [toasts, setToasts] = useState([]); + + const showToast = useCallback((message: string, type: Toast['type'] = 'info') => { + const id = nextId++; + setToasts((prev) => [...prev, { id, message, type }]); + setTimeout(() => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, 5000); + }, []); + + const removeToast = useCallback((id: number) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, []); + + return { toasts, showToast, removeToast }; +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..7373563 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,3 @@ +@import "tailwindcss"; +@import "./theme/tokens.css"; +@import "./theme/base.css"; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..0f5b5b1 --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,495 @@ +import { useState, useCallback, useEffect, useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { + batchApi, + type DashboardResponse, + type DashboardStats, + type ExecutionStatisticsDto, + type RecollectionStatsResponse, + type JobDisplayName, +} from '../api/batchApi'; +import { usePoller } from '../hooks/usePoller'; +import { useToastContext } from '../contexts/ToastContext'; +import StatusBadge from '../components/StatusBadge'; +import EmptyState from '../components/EmptyState'; +import LoadingSpinner from '../components/LoadingSpinner'; +import BarChart from '../components/BarChart'; +import { formatDateTime, calculateDuration } from '../utils/formatters'; +import GuideModal, { HelpButton } from '../components/GuideModal'; + +const POLLING_INTERVAL = 5000; + +const DASHBOARD_GUIDE = [ + { + title: '통계 카드', + content: '화면 상단에 전체 스케줄, 활성/비활성 스케줄 수, 전체 작업 수, 최근 24시간 실패 건수를 한눈에 보여줍니다.', + }, + { + title: '실행 중인 작업', + content: '현재 실행 중인 배치 작업 목록을 실시간으로 보여줍니다.\n5초마다 자동으로 갱신됩니다.\n오래 실행 중인 작업이 있으면 상단에 경고 배너가 표시되며, "전체 강제 종료" 버튼으로 일괄 중지할 수 있습니다.', + }, + { + title: '최근 실행 이력', + content: '최근 완료된 배치 작업 5건을 보여줍니다.\n각 행의 작업명, 상태, 시작 시간, 소요 시간을 확인할 수 있습니다.\n"전체 보기"를 클릭하면 실행 이력 화면으로 이동합니다.', + }, + { + title: '최근 실패 이력', + content: '최근 24시간 내 실패한 작업이 있을 때만 표시됩니다.\n실패 원인을 빠르게 파악할 수 있도록 종료 코드와 메시지를 함께 보여줍니다.', + }, + { + title: '재수집 현황', + content: '마지막 수집 완료일시를 API별로 보여줍니다.\n최근 5건의 재수집 이력도 함께 확인할 수 있습니다.', + }, + { + title: '실행 통계 차트', + content: '최근 30일간의 배치 실행 통계를 바 차트로 보여줍니다.\n초록색은 성공, 빨간색은 실패, 회색은 기타 상태를 나타냅니다.', + }, +]; + +interface StatCardProps { + label: string; + value: number; + gradient: string; + to?: string; +} + +function StatCard({ label, value, gradient, to }: StatCardProps) { + const content = ( +
+

{value}

+

{label}

+
+ ); + + if (to) { + return {content}; + } + return content; +} + +export default function Dashboard() { + const { showToast } = useToastContext(); + const [dashboard, setDashboard] = useState(null); + const [loading, setLoading] = useState(true); + const [guideOpen, setGuideOpen] = useState(false); + + const [abandoning, setAbandoning] = useState(false); + const [statistics, setStatistics] = useState(null); + const [recollectionStats, setRecollectionStats] = useState(null); + const [displayNames, setDisplayNames] = useState([]); + + const loadStatistics = useCallback(async () => { + try { + const data = await batchApi.getStatistics(30); + setStatistics(data); + } catch { + /* 통계 로드 실패는 무시 */ + } + }, []); + + useEffect(() => { + loadStatistics(); + }, [loadStatistics]); + + const loadRecollectionStats = useCallback(async () => { + try { + const data = await batchApi.getRecollectionStats(); + setRecollectionStats(data); + } catch { + /* 통계 로드 실패는 무시 */ + } + }, []); + + useEffect(() => { + loadRecollectionStats(); + }, [loadRecollectionStats]); + + useEffect(() => { + batchApi.getDisplayNames().then(setDisplayNames).catch(() => {}); + }, []); + + const displayNameMap = useMemo>(() => { + const map: Record = {}; + for (const dn of displayNames) { + if (dn.apiKey) map[dn.apiKey] = dn.displayName; + map[dn.jobName] = dn.displayName; + } + return map; + }, [displayNames]); + + const loadDashboard = useCallback(async () => { + try { + const data = await batchApi.getDashboard(); + setDashboard(data); + } catch (err) { + console.error('Dashboard load failed:', err); + } finally { + setLoading(false); + } + }, []); + + usePoller(loadDashboard, POLLING_INTERVAL); + + const handleAbandonAllStale = async () => { + setAbandoning(true); + try { + const result = await batchApi.abandonAllStale(); + showToast( + result.message || `${result.abandonedCount ?? 0}건 강제 종료 완료`, + 'success', + ); + await loadDashboard(); + } catch (err) { + showToast( + `강제 종료 실패: ${err instanceof Error ? err.message : '알 수 없는 오류'}`, + 'error', + ); + } finally { + setAbandoning(false); + } + }; + + if (loading) return ; + + const stats: DashboardStats = dashboard?.stats ?? { + totalSchedules: 0, + activeSchedules: 0, + inactiveSchedules: 0, + totalJobs: 0, + }; + + const runningJobs = dashboard?.runningJobs ?? []; + const recentExecutions = dashboard?.recentExecutions ?? []; + const recentFailures = dashboard?.recentFailures ?? []; + const staleExecutionCount = dashboard?.staleExecutionCount ?? 0; + const failureStats = dashboard?.failureStats ?? { last24h: 0, last7d: 0 }; + + return ( +
+ {/* Header */} +
+
+

대시보드

+ setGuideOpen(true)} /> +
+
+ + {/* F1: Stale Execution Warning Banner */} + {staleExecutionCount > 0 && ( +
+ + {staleExecutionCount}건의 오래된 실행 중 작업이 있습니다 + + +
+ )} + + {/* Stats Cards */} +
+ + + + + +
+ + {/* Running Jobs */} +
+

+ 실행 중인 작업 + {runningJobs.length > 0 && ( + + ({runningJobs.length}건) + + )} +

+ {runningJobs.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + {runningJobs.map((job) => ( + + + + + + + ))} + +
작업명실행 ID시작 시간상태
{displayNameMap[job.jobName] || job.jobName}#{job.executionId}{formatDateTime(job.startTime)} + +
+
+ )} +
+ + {/* Recent Executions */} +
+
+

최근 실행 이력

+ + 전체 보기 → + +
+ {recentExecutions.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + {recentExecutions.slice(0, 5).map((exec) => ( + + + + + + + + + ))} + +
실행 ID작업명시작 시간종료 시간소요 시간상태
+ + #{exec.executionId} + + {displayNameMap[exec.jobName] || exec.jobName}{formatDateTime(exec.startTime)}{formatDateTime(exec.endTime)} + {calculateDuration(exec.startTime, exec.endTime)} + + +
+
+ )} +
+ + {/* F6: Recent Failures */} + {recentFailures.length > 0 && ( +
+

+ 최근 실패 이력 + + ({recentFailures.length}건) + +

+
+ + + + + + + + + + + {recentFailures.map((fail) => ( + + + + + + + ))} + +
실행 ID작업명시작 시간오류 메시지
+ + #{fail.executionId} + + {displayNameMap[fail.jobName] || fail.jobName}{formatDateTime(fail.startTime)} + {fail.exitMessage + ? fail.exitMessage.length > 50 + ? `${fail.exitMessage.slice(0, 50)}...` + : fail.exitMessage + : '-'} +
+
+
+ )} + + {/* 재수집 현황 */} + {recollectionStats && recollectionStats.totalCount > 0 && ( +
+
+

재수집 현황

+ + 전체 보기 → + +
+
+
+

{recollectionStats.totalCount}

+

전체

+
+
+

{recollectionStats.completedCount}

+

완료

+
+
+

{recollectionStats.failedCount}

+

실패

+
+
+

{recollectionStats.runningCount}

+

실행 중

+
+
+

{recollectionStats.overlapCount}

+

중복

+
+
+ {/* 최근 재수집 이력 (최대 5건) */} + {recollectionStats.recentHistories.length > 0 && ( +
+ + + + + + + + + + + + {recollectionStats.recentHistories.slice(0, 5).map((h) => ( + + + + + + + + ))} + +
이력 ID작업명실행자시작 시간상태
+ + #{h.historyId} + + {displayNameMap[h.apiKey] || h.apiKeyName || h.jobName}{h.executor || '-'}{formatDateTime(h.executionStartTime)} + +
+
+ )} +
+ )} + + {/* F8: Execution Statistics Chart */} + {statistics && statistics.dailyStats.length > 0 && ( +
+
+

+ 실행 통계 (최근 30일) +

+
+ + 전체 {statistics.totalExecutions} + + + 성공 {statistics.totalSuccess} + + + 실패 {statistics.totalFailed} + +
+
+ ({ + label: d.date.slice(5), + values: [ + { color: 'green', value: d.successCount }, + { color: 'red', value: d.failedCount }, + { color: 'gray', value: d.otherCount }, + ], + }))} + height={180} + /> +
+ + 성공 + + + 실패 + + + 기타 + +
+
+ )} + + setGuideOpen(false)} + /> +
+ ); +} diff --git a/frontend/src/pages/ExecutionDetail.tsx b/frontend/src/pages/ExecutionDetail.tsx new file mode 100644 index 0000000..68c2b5c --- /dev/null +++ b/frontend/src/pages/ExecutionDetail.tsx @@ -0,0 +1,708 @@ +import { useState, useCallback } from 'react'; +import { useParams, useSearchParams, useNavigate } from 'react-router-dom'; +import { batchApi, type JobExecutionDetailDto, type StepExecutionDto, type FailedRecordDto } from '../api/batchApi'; +import { formatDateTime, formatDuration, calculateDuration } from '../utils/formatters'; +import { usePoller } from '../hooks/usePoller'; +import StatusBadge from '../components/StatusBadge'; +import EmptyState from '../components/EmptyState'; +import LoadingSpinner from '../components/LoadingSpinner'; +import Pagination from '../components/Pagination'; +import DetailStatCard from '../components/DetailStatCard'; +import ApiLogSection from '../components/ApiLogSection'; +import InfoItem from '../components/InfoItem'; +import GuideModal, { HelpButton } from '../components/GuideModal'; + +const POLLING_INTERVAL_MS = 5000; + +const EXECUTION_DETAIL_GUIDE = [ + { + title: '실행 기본 정보', + content: '실행의 시작/종료 시간, 소요 시간, 종료 코드, 에러 메시지 등 기본 정보를 보여줍니다.\n실행 중인 경우 5초마다 자동으로 갱신됩니다.', + }, + { + title: '처리 통계', + content: '4개의 통계 카드로 전체 처리 현황을 요약합니다.\n• 읽기(Read): 외부 API에서 조회한 건수\n• 쓰기(Write): DB에 저장된 건수\n• 건너뜀(Skip): 처리하지 않은 건수\n• 필터(Filter): 조건에 의해 제외된 건수', + }, + { + title: 'Step 실행 정보', + content: '배치 작업은 하나 이상의 Step으로 구성됩니다.\n각 Step의 상태, 처리 건수, 커밋/롤백 횟수를 확인할 수 있습니다.\nAPI 호출 정보에서는 총 호출 수, 성공/에러 수, 평균 응답 시간을 보여줍니다.', + }, + { + title: 'API 호출 로그', + content: '각 Step에서 호출한 외부 API의 상세 로그를 확인할 수 있습니다.\n요청 URL, 응답 코드, 응답 시간 등을 페이지 단위로 조회합니다.', + }, + { + title: '실패 건 관리', + content: '처리 중 실패한 레코드가 있으면 목록으로 표시됩니다.\n• 실패 건 재수집: 실패한 데이터를 다시 수집합니다\n• 일괄 RESOLVED: 모든 실패 건을 해결됨으로 처리합니다\n• 재시도 초기화: 재시도 횟수를 초기화하여 자동 재수집 대상에 포함시킵니다', + }, +]; + +interface StepCardProps { + step: StepExecutionDto; + jobName: string; + jobExecutionId: number; +} + +function StepCard({ step, jobName, jobExecutionId }: StepCardProps) { + const stats = [ + { label: '읽기', value: step.readCount }, + { label: '쓰기', value: step.writeCount }, + { label: '커밋', value: step.commitCount }, + { label: '롤백', value: step.rollbackCount }, + { label: '읽기 건너뜀', value: step.readSkipCount }, + { label: '처리 건너뜀', value: step.processSkipCount }, + { label: '쓰기 건너뜀', value: step.writeSkipCount }, + { label: '필터', value: step.filterCount }, + ]; + + return ( +
+
+
+

+ {step.stepName} +

+ +
+ + {step.duration != null + ? formatDuration(step.duration) + : calculateDuration(step.startTime, step.endTime)} + +
+ +
+
+ 시작: {formatDateTime(step.startTime)} +
+
+ 종료: {formatDateTime(step.endTime)} +
+
+ +
+ {stats.map(({ label, value }) => ( +
+

+ {value.toLocaleString()} +

+

{label}

+
+ ))} +
+ + {/* API 호출 정보: apiLogSummary가 있으면 개별 로그 리스트, 없으면 기존 apiCallInfo 요약 */} + {step.apiLogSummary ? ( +
+

API 호출 정보

+
+
+

{step.apiLogSummary.totalCalls.toLocaleString()}

+

총 호출

+
+
+

{step.apiLogSummary.successCount.toLocaleString()}

+

성공

+
+
+

0 ? 'text-red-500' : 'text-wing-text'}`}> + {step.apiLogSummary.errorCount.toLocaleString()} +

+

에러

+
+
+

{Math.round(step.apiLogSummary.avgResponseMs).toLocaleString()}

+

평균(ms)

+
+
+

{step.apiLogSummary.maxResponseMs.toLocaleString()}

+

최대(ms)

+
+
+

{step.apiLogSummary.minResponseMs.toLocaleString()}

+

최소(ms)

+
+
+ + {step.apiLogSummary.totalCalls > 0 && ( + + )} +
+ ) : step.apiCallInfo && ( +
+

API 호출 정보

+
+
+ URL:{' '} + {step.apiCallInfo.apiUrl} +
+
+ Method:{' '} + {step.apiCallInfo.method} +
+
+ 호출:{' '} + {step.apiCallInfo.completedCalls} / {step.apiCallInfo.totalCalls} +
+ {step.apiCallInfo.lastCallTime && ( +
+ 최종:{' '} + {step.apiCallInfo.lastCallTime} +
+ )} +
+
+ )} + + {/* 호출 실패 데이터 토글 */} + {step.failedRecords && step.failedRecords.length > 0 && ( + + )} + + {step.exitMessage && ( +
+

Exit Message

+

+ {step.exitMessage} +

+
+ )} +
+ ); +} + +export default function ExecutionDetail() { + const { id: paramId } = useParams<{ id: string }>(); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + + const executionId = paramId + ? Number(paramId) + : Number(searchParams.get('id')); + + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [guideOpen, setGuideOpen] = useState(false); + + const isRunning = detail + ? detail.status === 'STARTED' || detail.status === 'STARTING' + : false; + + const loadDetail = useCallback(async () => { + if (!executionId || isNaN(executionId)) { + setError('유효하지 않은 실행 ID입니다.'); + setLoading(false); + return; + } + try { + const data = await batchApi.getExecutionDetail(executionId); + setDetail(data); + setError(null); + } catch (err) { + setError( + err instanceof Error + ? err.message + : '실행 상세 정보를 불러오지 못했습니다.', + ); + } finally { + setLoading(false); + } + }, [executionId]); + + /* 실행중인 경우 5초 폴링, 완료 후에는 1회 로드로 충분하지만 폴링 유지 */ + usePoller(loadDetail, isRunning ? POLLING_INTERVAL_MS : 30_000, [ + executionId, + ]); + + if (loading) return ; + + if (error || !detail) { + return ( +
+ + +
+ ); + } + + const jobParams = Object.entries(detail.jobParameters); + + return ( +
+ {/* 상단 내비게이션 */} + + + {/* Job 기본 정보 */} +
+
+
+
+

+ 실행 #{detail.executionId} +

+ setGuideOpen(true)} /> +
+

+ {detail.jobName} +

+
+ +
+ +
+ + + + + {detail.exitMessage && ( +
+ +
+ )} +
+
+ + {/* 실행 통계 카드 4개 */} +
+ + + + +
+ + {/* Job Parameters */} + {jobParams.length > 0 && ( +
+

+ Job Parameters +

+
+ + + + + + + + + {jobParams.map(([key, value]) => ( + + + + + ))} + +
KeyValue
+ {key} + + {value} +
+
+
+ )} + + {/* Step 실행 정보 */} +
+

+ Step 실행 정보 + + ({detail.stepExecutions.length}개) + +

+ {detail.stepExecutions.length === 0 ? ( + + ) : ( +
+ {detail.stepExecutions.map((step) => ( + + ))} +
+ )} +
+ + setGuideOpen(false)} + pageTitle="실행 상세" + sections={EXECUTION_DETAIL_GUIDE} + /> +
+ ); +} + +const FAILED_PAGE_SIZE = 10; + +function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: FailedRecordDto[]; jobName: string; jobExecutionId: number }) { + const [open, setOpen] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); + const [showResolveConfirm, setShowResolveConfirm] = useState(false); + const [retrying, setRetrying] = useState(false); + const [resolving, setResolving] = useState(false); + const [showResetConfirm, setShowResetConfirm] = useState(false); + const [resetting, setResetting] = useState(false); + const [page, setPage] = useState(0); + const navigate = useNavigate(); + + const failedRecords = records.filter((r) => r.status === 'FAILED'); + const totalPages = Math.ceil(records.length / FAILED_PAGE_SIZE); + const pagedRecords = records.slice(page * FAILED_PAGE_SIZE, (page + 1) * FAILED_PAGE_SIZE); + + const statusColor = (status: string) => { + switch (status) { + case 'RESOLVED': return 'text-emerald-600 bg-emerald-50'; + case 'RETRY_PENDING': return 'text-amber-600 bg-amber-50'; + default: return 'text-red-600 bg-red-50'; + } + }; + + const MAX_RETRY_COUNT = 3; + + const retryStatusLabel = (record: FailedRecordDto) => { + if (record.status !== 'FAILED') return null; + if (record.retryCount >= MAX_RETRY_COUNT) return { label: '재시도 초과', color: 'text-red-600 bg-red-100' }; + if (record.retryCount > 0) return { label: `재시도 ${record.retryCount}/${MAX_RETRY_COUNT}`, color: 'text-amber-600 bg-amber-100' }; + return { label: '대기', color: 'text-blue-600 bg-blue-100' }; + }; + + const exceededRecords = failedRecords.filter((r) => r.retryCount >= MAX_RETRY_COUNT); + + const handleRetry = async () => { + setRetrying(true); + try { + const result = await batchApi.retryFailedRecords(jobName, failedRecords.length, jobExecutionId); + if (result.success) { + setShowConfirm(false); + if (result.executionId) { + navigate(`/executions/${result.executionId}`); + } else { + alert(result.message || '재수집이 요청되었습니다.'); + } + } else { + alert(result.message || '재수집 실행에 실패했습니다.'); + } + } catch { + alert('재수집 실행에 실패했습니다.'); + } finally { + setRetrying(false); + } + }; + + const handleResolve = async () => { + setResolving(true); + try { + const ids = failedRecords.map((r) => r.id); + await batchApi.resolveFailedRecords(ids); + setShowResolveConfirm(false); + navigate(0); + } catch { + alert('일괄 RESOLVED 처리에 실패했습니다.'); + } finally { + setResolving(false); + } + }; + + const handleResetRetry = async () => { + setResetting(true); + try { + const ids = exceededRecords.map((r) => r.id); + await batchApi.resetRetryCount(ids); + setShowResetConfirm(false); + navigate(0); + } catch { + alert('재시도 초기화에 실패했습니다.'); + } finally { + setResetting(false); + } + }; + + return ( +
+
+ + + {failedRecords.length > 0 && ( +
+ {exceededRecords.length > 0 && ( + + )} + + +
+ )} +
+ + {open && ( +
+
+ + + + + + + + + + + + {pagedRecords.map((record) => ( + + + + + + + + ))} + +
Record Key에러 메시지재시도상태생성 시간
+ {record.recordKey} + + {record.errorMessage || '-'} + + {(() => { + const info = retryStatusLabel(record); + return info ? ( + + {info.label} + + ) : ( + - + ); + })()} + + + {record.status} + + + {formatDateTime(record.createdAt)} +
+
+ +
+ )} + + {/* 재수집 확인 다이얼로그 */} + {showConfirm && ( +
+
+

+ 실패 건 재수집 확인 +

+

+ 다음 {failedRecords.length}건의 IMO에 대해 재수집을 실행합니다. +

+
+
+ {failedRecords.map((r) => ( + + {r.recordKey} + + ))} +
+
+
+ + +
+
+
+ )} + + {/* 일괄 RESOLVED 확인 다이얼로그 */} + {showResolveConfirm && ( +
+
+

+ 일괄 RESOLVED 확인 +

+

+ FAILED 상태의 {failedRecords.length}건을 RESOLVED로 변경합니다. + 이 작업은 되돌릴 수 없습니다. +

+
+ + +
+
+
+ )} + + {/* 재시도 초기화 확인 다이얼로그 */} + {showResetConfirm && ( +
+
+

+ 재시도 초기화 확인 +

+

+ 재시도 횟수를 초과한 {exceededRecords.length}건의 retryCount를 0으로 초기화합니다. + 초기화 후 다음 배치 실행 시 자동 재수집 대상에 포함됩니다. +

+
+ + +
+
+
+ )} +
+ ); +} + diff --git a/frontend/src/pages/Executions.tsx b/frontend/src/pages/Executions.tsx new file mode 100644 index 0000000..8c69792 --- /dev/null +++ b/frontend/src/pages/Executions.tsx @@ -0,0 +1,659 @@ +import { useState, useMemo, useCallback, useEffect } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { batchApi, type JobExecutionDto, type ExecutionSearchResponse, type JobDisplayName } from '../api/batchApi'; +import { formatDateTime, calculateDuration } from '../utils/formatters'; +import { usePoller } from '../hooks/usePoller'; +import { useToastContext } from '../contexts/ToastContext'; +import StatusBadge from '../components/StatusBadge'; +import ConfirmModal from '../components/ConfirmModal'; +import InfoModal from '../components/InfoModal'; +import EmptyState from '../components/EmptyState'; +import LoadingSpinner from '../components/LoadingSpinner'; +import GuideModal, { HelpButton } from '../components/GuideModal'; + +type StatusFilter = 'ALL' | 'COMPLETED' | 'FAILED' | 'STARTED' | 'STOPPED'; + +const STATUS_FILTERS: { value: StatusFilter; label: string }[] = [ + { value: 'ALL', label: '전체' }, + { value: 'COMPLETED', label: '완료' }, + { value: 'FAILED', label: '실패' }, + { value: 'STARTED', label: '실행중' }, + { value: 'STOPPED', label: '중지됨' }, +]; + +const POLLING_INTERVAL_MS = 5000; +const RECENT_LIMIT = 50; +const PAGE_SIZE = 50; + +const EXECUTIONS_GUIDE = [ + { + title: '작업 필터', + content: '상단의 드롭다운에서 조회할 작업을 선택할 수 있습니다.\n여러 작업을 동시에 선택할 수 있으며, 단축 버튼으로 빠르게 필터링할 수 있습니다.\n• 전체: 모든 작업 표시\n• AIS 제외: AIS 관련 작업을 제외하고 표시\n• AIS만: AIS 관련 작업만 표시', + }, + { + title: '상태 필터', + content: '완료 / 실패 / 실행중 / 중지됨 버튼으로 상태별 필터링이 가능합니다.\n"전체"를 선택하면 모든 상태의 실행 이력을 볼 수 있습니다.', + }, + { + title: '날짜 검색', + content: '시작일과 종료일을 지정하여 특정 기간의 실행 이력을 조회할 수 있습니다.\n"검색" 버튼을 클릭하면 조건에 맞는 결과가 표시됩니다.\n"초기화" 버튼으로 검색 조건을 제거하고 최신 이력으로 돌아갑니다.', + }, + { + title: '실행 중인 작업 제어', + content: '실행 중인 작업의 행에서 "중지" 또는 "강제 종료" 버튼을 사용할 수 있습니다.\n• 중지: 현재 Step 완료 후 안전하게 종료\n• 강제 종료: 즉시 중단 (데이터 정합성 주의)', + }, + { + title: '실패 로그 확인', + content: '상태가 "FAILED"인 행을 클릭하면 실패 상세 정보를 확인할 수 있습니다.\n종료 코드(Exit Code)와 에러 메시지로 실패 원인을 파악하세요.\n상태가 "COMPLETED"이지만 실패 건수가 있으면 경고 아이콘이 표시됩니다.', + }, +]; + +export default function Executions() { + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + + const jobFromQuery = searchParams.get('job') || ''; + + const [jobs, setJobs] = useState([]); + const [displayNames, setDisplayNames] = useState([]); + const [executions, setExecutions] = useState([]); + const [selectedJobs, setSelectedJobs] = useState(jobFromQuery ? [jobFromQuery] : []); + const [jobDropdownOpen, setJobDropdownOpen] = useState(false); + const [statusFilter, setStatusFilter] = useState('ALL'); + const [loading, setLoading] = useState(true); + const [stopTarget, setStopTarget] = useState(null); + + // F1: 강제 종료 + const [abandonTarget, setAbandonTarget] = useState(null); + + // F4: 날짜 범위 필터 + 페이지네이션 + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + const [page, setPage] = useState(0); + const [totalPages, setTotalPages] = useState(0); + const [totalCount, setTotalCount] = useState(0); + const [useSearch, setUseSearch] = useState(false); + + // F9: 실패 로그 뷰어 + const [failLogTarget, setFailLogTarget] = useState(null); + + const [guideOpen, setGuideOpen] = useState(false); + + const { showToast } = useToastContext(); + + useEffect(() => { + batchApi.getDisplayNames().then(setDisplayNames).catch(() => {}); + }, []); + + const displayNameMap = useMemo>(() => { + const map: Record = {}; + for (const dn of displayNames) { + map[dn.jobName] = dn.displayName; + } + return map; + }, [displayNames]); + + const loadJobs = useCallback(async () => { + try { + const data = await batchApi.getJobs(); + setJobs(data); + } catch { + /* Job 목록 로드 실패는 무시 */ + } + }, []); + + const loadSearchExecutions = useCallback(async (targetPage: number) => { + try { + setLoading(true); + const params: { + jobNames?: string[]; + status?: string; + startDate?: string; + endDate?: string; + page?: number; + size?: number; + } = { + page: targetPage, + size: PAGE_SIZE, + }; + if (selectedJobs.length > 0) params.jobNames = selectedJobs; + if (statusFilter !== 'ALL') params.status = statusFilter; + if (startDate) params.startDate = `${startDate}T00:00:00`; + if (endDate) params.endDate = `${endDate}T23:59:59`; + + const data: ExecutionSearchResponse = await batchApi.searchExecutions(params); + setExecutions(data.executions); + setTotalPages(data.totalPages); + setTotalCount(data.totalCount); + setPage(data.page); + } catch { + setExecutions([]); + setTotalPages(0); + setTotalCount(0); + } finally { + setLoading(false); + } + }, [selectedJobs, statusFilter, startDate, endDate]); + + const loadExecutions = useCallback(async () => { + // 검색 모드에서는 폴링하지 않음 (검색 버튼 클릭 시에만 1회 조회) + if (useSearch) return; + try { + let data: JobExecutionDto[]; + if (selectedJobs.length === 1) { + data = await batchApi.getJobExecutions(selectedJobs[0]); + } else if (selectedJobs.length > 1) { + // 복수 Job 선택 시 search API 사용 + const result = await batchApi.searchExecutions({ + jobNames: selectedJobs, size: RECENT_LIMIT, + }); + data = result.executions; + } else { + try { + data = await batchApi.getRecentExecutions(RECENT_LIMIT); + } catch { + data = []; + } + } + setExecutions(data); + } catch { + setExecutions([]); + } finally { + setLoading(false); + } + }, [selectedJobs, useSearch, page, loadSearchExecutions]); + + /* 마운트 시 Job 목록 1회 로드 */ + usePoller(loadJobs, 60_000, []); + + /* 실행 이력 5초 폴링 */ + usePoller(loadExecutions, POLLING_INTERVAL_MS, [selectedJobs, useSearch, page]); + + const filteredExecutions = useMemo(() => { + // 검색 모드에서는 서버 필터링 사용 + if (useSearch) return executions; + if (statusFilter === 'ALL') return executions; + return executions.filter((e) => e.status === statusFilter); + }, [executions, statusFilter, useSearch]); + + const toggleJob = (jobName: string) => { + setSelectedJobs((prev) => { + const next = prev.includes(jobName) + ? prev.filter((j) => j !== jobName) + : [...prev, jobName]; + if (next.length === 1) { + setSearchParams({ job: next[0] }); + } else { + setSearchParams({}); + } + return next; + }); + setLoading(true); + if (useSearch) { + setPage(0); + } + }; + + const clearSelectedJobs = () => { + setSelectedJobs([]); + setSearchParams({}); + setLoading(true); + if (useSearch) { + setPage(0); + } + }; + + const handleStop = async () => { + if (!stopTarget) return; + try { + const result = await batchApi.stopExecution(stopTarget.executionId); + showToast(result.message || '실행이 중지되었습니다.', 'success'); + } catch (err) { + showToast( + err instanceof Error ? err.message : '중지 요청에 실패했습니다.', + 'error', + ); + } finally { + setStopTarget(null); + } + }; + + // F1: 강제 종료 핸들러 + const handleAbandon = async () => { + if (!abandonTarget) return; + try { + const result = await batchApi.abandonExecution(abandonTarget.executionId); + showToast(result.message || '실행이 강제 종료되었습니다.', 'success'); + } catch (err) { + showToast( + err instanceof Error ? err.message : '강제 종료 요청에 실패했습니다.', + 'error', + ); + } finally { + setAbandonTarget(null); + } + }; + + // F4: 검색 핸들러 + const handleSearch = async () => { + setUseSearch(true); + setPage(0); + await loadSearchExecutions(0); + }; + + // F4: 초기화 핸들러 + const handleResetSearch = () => { + setUseSearch(false); + setStartDate(''); + setEndDate(''); + setPage(0); + setTotalPages(0); + setTotalCount(0); + setLoading(true); + }; + + // F4: 페이지 이동 핸들러 + const handlePageChange = (newPage: number) => { + if (newPage < 0 || newPage >= totalPages) return; + setPage(newPage); + loadSearchExecutions(newPage); + }; + + const isRunning = (status: string) => + status === 'STARTED' || status === 'STARTING'; + + return ( +
+ {/* 헤더 */} +
+
+

실행 이력

+ setGuideOpen(true)} /> +
+

+ 배치 작업 실행 이력을 조회하고 관리합니다. +

+
+ + {/* 필터 영역 */} +
+
+ {/* Job 멀티 선택 */} +
+
+ +
+ + {jobDropdownOpen && ( + <> +
setJobDropdownOpen(false)} /> +
+ {jobs.map((job) => ( + + ))} +
+ + )} +
+
+ +
+ {selectedJobs.length > 0 && ( + + )} +
+ {/* 선택된 Job 칩 */} + {selectedJobs.length > 0 && ( +
+ {selectedJobs.map((job) => ( + + {displayNameMap[job] || job} + + + ))} +
+ )} +
+ + {/* 상태 필터 버튼 그룹 */} +
+ {STATUS_FILTERS.map(({ value, label }) => ( + + ))} +
+
+ + {/* F4: 날짜 범위 필터 */} +
+ +
+ setStartDate(e.target.value)} + className="block rounded-lg border border-wing-border bg-wing-surface px-3 py-2 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent" + /> + ~ + setEndDate(e.target.value)} + className="block rounded-lg border border-wing-border bg-wing-surface px-3 py-2 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent" + /> +
+
+ + {useSearch && ( + + )} +
+
+
+ + {/* 실행 이력 테이블 */} +
+ {loading ? ( + + ) : filteredExecutions.length === 0 ? ( + 0 + ? '선택한 작업의 실행 이력이 없습니다.' + : undefined + } + /> + ) : ( +
+ + + + + + + + + + + + + + {filteredExecutions.map((exec) => ( + + + + + + + + + + ))} + +
실행 ID작업명상태시작시간종료시간소요시간 + 액션 +
+ #{exec.executionId} + + {displayNameMap[exec.jobName] || exec.jobName} + +
+ {/* F9: FAILED 상태 클릭 시 실패 로그 모달 */} + {exec.status === 'FAILED' ? ( + + ) : ( + + )} + {exec.status === 'COMPLETED' && exec.failedRecordCount != null && exec.failedRecordCount > 0 && ( + + + + + {exec.failedRecordCount} + + )} +
+
+ {formatDateTime(exec.startTime)} + + {formatDateTime(exec.endTime)} + + {calculateDuration( + exec.startTime, + exec.endTime, + )} + +
+ {isRunning(exec.status) && ( + <> + + + + )} + +
+
+
+ )} + + {/* 결과 건수 표시 + F4: 페이지네이션 */} + {!loading && filteredExecutions.length > 0 && ( +
+
+ {useSearch ? ( + <>총 {totalCount}건 + ) : ( + <> + 총 {filteredExecutions.length}건 + {statusFilter !== 'ALL' && ( + + (전체 {executions.length}건 중) + + )} + + )} +
+ {/* F4: 페이지네이션 UI */} + {useSearch && totalPages > 1 && ( +
+ + + {page + 1} / {totalPages} + + +
+ )} +
+ )} +
+ + {/* 중지 확인 모달 */} + setStopTarget(null)} + /> + + {/* F1: 강제 종료 확인 모달 */} + setAbandonTarget(null)} + /> + + setGuideOpen(false)} + pageTitle="실행 이력" + sections={EXECUTIONS_GUIDE} + /> + + {/* F9: 실패 로그 뷰어 모달 */} + setFailLogTarget(null)} + > + {failLogTarget && ( +
+
+

+ Exit Code +

+

+ {failLogTarget.exitCode || '-'} +

+
+
+

+ Exit Message +

+
+                                {failLogTarget.exitMessage || '메시지 없음'}
+                            
+
+
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/Jobs.tsx b/frontend/src/pages/Jobs.tsx new file mode 100644 index 0000000..d8d21e1 --- /dev/null +++ b/frontend/src/pages/Jobs.tsx @@ -0,0 +1,562 @@ +import { useState, useCallback, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { batchApi } from '../api/batchApi'; +import type { JobDetailDto } from '../api/batchApi'; +import { usePoller } from '../hooks/usePoller'; +import { useToastContext } from '../contexts/ToastContext'; +import StatusBadge from '../components/StatusBadge'; +import EmptyState from '../components/EmptyState'; +import LoadingSpinner from '../components/LoadingSpinner'; +import { formatDateTime, calculateDuration } from '../utils/formatters'; +import GuideModal, { HelpButton } from '../components/GuideModal'; + +const POLLING_INTERVAL = 30000; + +const JOBS_GUIDE = [ + { + title: '상태 필터', + content: '상단의 탭 버튼으로 작업 상태별 필터링이 가능합니다.\n전체 / 실행 중 / 성공 / 실패 / 미실행 중 선택하세요.\n각 탭 옆의 숫자는 해당 상태의 작업 수입니다.', + }, + { + title: '검색 및 정렬', + content: '검색창에 작업명을 입력하면 실시간으로 필터링됩니다.\n정렬 옵션: 작업명순, 최신 실행순(기본), 상태별(실패 우선)\n테이블/카드 뷰 전환 버튼으로 보기 방식을 변경할 수 있습니다.', + }, + { + title: '작업 실행', + content: '"실행" 버튼을 클릭하면 확인 팝업이 표시됩니다.\n확인 후 해당 배치 작업이 즉시 실행됩니다.\n실행 중인 작업은 좌측에 초록색 점이 표시됩니다.', + }, + { + title: '이력 보기', + content: '"이력 보기" 버튼을 클릭하면 해당 작업의 실행 이력 화면으로 이동합니다.\n과거 실행 결과, 소요 시간 등을 상세히 확인할 수 있습니다.', + }, +]; + +type StatusFilterKey = 'ALL' | 'STARTED' | 'COMPLETED' | 'FAILED' | 'NONE'; +type SortKey = 'name' | 'recent' | 'status'; +type ViewMode = 'card' | 'table'; + +interface StatusTabConfig { + key: StatusFilterKey; + label: string; +} + +const STATUS_TABS: StatusTabConfig[] = [ + { key: 'ALL', label: '전체' }, + { key: 'STARTED', label: '실행 중' }, + { key: 'COMPLETED', label: '성공' }, + { key: 'FAILED', label: '실패' }, + { key: 'NONE', label: '미실행' }, +]; + +const STATUS_ORDER: Record = { + FAILED: 0, + STARTED: 1, + COMPLETED: 2, +}; + +function getStatusOrder(job: JobDetailDto): number { + if (!job.lastExecution) return 3; + return STATUS_ORDER[job.lastExecution.status] ?? 4; +} + +function matchesStatusFilter(job: JobDetailDto, filter: StatusFilterKey): boolean { + if (filter === 'ALL') return true; + if (filter === 'NONE') return job.lastExecution === null; + return job.lastExecution?.status === filter; +} + +export default function Jobs() { + const navigate = useNavigate(); + const { showToast } = useToastContext(); + + const [jobs, setJobs] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(''); + const [statusFilter, setStatusFilter] = useState('ALL'); + const [sortKey, setSortKey] = useState('recent'); + const [viewMode, setViewMode] = useState('table'); + + const [guideOpen, setGuideOpen] = useState(false); + + // Execute modal (individual card) + const [executeModalOpen, setExecuteModalOpen] = useState(false); + const [targetJob, setTargetJob] = useState(''); + const [executing, setExecuting] = useState(false); + + + const loadJobs = useCallback(async () => { + try { + const data = await batchApi.getJobsDetail(); + setJobs(data); + } catch (err) { + console.error('Jobs load failed:', err); + } finally { + setLoading(false); + } + }, []); + + usePoller(loadJobs, POLLING_INTERVAL); + + /** displayName 우선, 없으면 jobName */ + const getJobLabel = useCallback((job: JobDetailDto) => job.displayName || job.jobName, []); + + const statusCounts = useMemo(() => { + const searchFiltered = searchTerm.trim() + ? jobs.filter((job) => { + const term = searchTerm.toLowerCase(); + return job.jobName.toLowerCase().includes(term) + || (job.displayName?.toLowerCase().includes(term) ?? false); + }) + : jobs; + + return STATUS_TABS.reduce>( + (acc, tab) => { + acc[tab.key] = searchFiltered.filter((job) => matchesStatusFilter(job, tab.key)).length; + return acc; + }, + { ALL: 0, STARTED: 0, COMPLETED: 0, FAILED: 0, NONE: 0 }, + ); + }, [jobs, searchTerm]); + + const filteredJobs = useMemo(() => { + let result = jobs; + + if (searchTerm.trim()) { + const term = searchTerm.toLowerCase(); + result = result.filter((job) => + job.jobName.toLowerCase().includes(term) + || (job.displayName?.toLowerCase().includes(term) ?? false), + ); + } + + result = result.filter((job) => matchesStatusFilter(job, statusFilter)); + + result = [...result].sort((a, b) => { + if (sortKey === 'name') { + return getJobLabel(a).localeCompare(getJobLabel(b)); + } + if (sortKey === 'recent') { + const aTime = a.lastExecution?.startTime ? new Date(a.lastExecution.startTime).getTime() : 0; + const bTime = b.lastExecution?.startTime ? new Date(b.lastExecution.startTime).getTime() : 0; + return bTime - aTime; + } + if (sortKey === 'status') { + return getStatusOrder(a) - getStatusOrder(b); + } + return 0; + }); + + return result; + }, [jobs, searchTerm, statusFilter, sortKey]); + + const handleExecuteClick = (jobName: string) => { + setTargetJob(jobName); + setExecuteModalOpen(true); + }; + + const handleConfirmExecute = async () => { + if (!targetJob) return; + setExecuting(true); + try { + const result = await batchApi.executeJob(targetJob); + showToast( + result.message || `${targetJob} 실행 요청 완료`, + 'success', + ); + setExecuteModalOpen(false); + } catch (err) { + showToast( + `실행 실패: ${err instanceof Error ? err.message : '알 수 없는 오류'}`, + 'error', + ); + } finally { + setExecuting(false); + } + }; + + const handleViewHistory = (jobName: string) => { + navigate(`/executions?job=${encodeURIComponent(jobName)}`); + }; + + if (loading) return ; + + return ( +
+ {/* Header */} +
+
+

배치 작업 목록

+ setGuideOpen(true)} /> +
+ + 총 {jobs.length}개 작업 + +
+ + {/* Status Filter Tabs */} +
+ {STATUS_TABS.map((tab) => ( + + ))} +
+ + {/* Search + Sort + View Toggle */} +
+
+ {/* Search */} +
+ + + + + + setSearchTerm(e.target.value)} + className="w-full pl-10 pr-4 py-2 border border-wing-border rounded-lg text-sm + focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none" + /> + {searchTerm && ( + + )} +
+ + {/* Sort dropdown */} + + + {/* View mode toggle */} +
+ + +
+
+ + {searchTerm && ( +

+ {filteredJobs.length}개 작업 검색됨 +

+ )} +
+ + {/* Job List */} + {filteredJobs.length === 0 ? ( +
+ +
+ ) : viewMode === 'card' ? ( + /* Card View */ +
+ {filteredJobs.map((job) => { + const isRunning = job.lastExecution?.status === 'STARTED'; + const duration = job.lastExecution + ? calculateDuration(job.lastExecution.startTime, job.lastExecution.endTime) + : null; + const showDuration = + job.lastExecution?.endTime != null && duration !== null && duration !== '-'; + + return ( +
+
+
+

+ {getJobLabel(job)} +

+
+
+ {isRunning && ( + + )} + {job.lastExecution && ( + + )} +
+
+ + {/* Job detail info */} +
+ {job.lastExecution ? ( + <> +

+ 마지막 실행: {formatDateTime(job.lastExecution.startTime)} +

+ {showDuration && ( +

+ 소요 시간: {duration} +

+ )} + {isRunning && !showDuration && ( +

+ 소요 시간: 실행 중... +

+ )} + + ) : ( +

실행 이력 없음

+ )} + +
+ {job.scheduleCron ? ( + + 자동 + + ) : ( + + 수동 + + )} + {job.scheduleCron && ( + + {job.scheduleCron} + + )} +
+
+ +
+ + +
+
+ ); + })} +
+ ) : ( + /* Table View */ +
+
+ + + + + + + + + + + + + {filteredJobs.map((job) => { + const isRunning = job.lastExecution?.status === 'STARTED'; + const duration = job.lastExecution + ? calculateDuration(job.lastExecution.startTime, job.lastExecution.endTime) + : '-'; + + return ( + + + + + + + + + ); + })} + +
+ 작업명 + + 상태 + + 마지막 실행 + + 소요시간 + + 스케줄 + + 액션 +
+
+ {isRunning && ( + + )} + {getJobLabel(job)} +
+
+ {job.lastExecution ? ( + + ) : ( + 미실행 + )} + + {job.lastExecution + ? formatDateTime(job.lastExecution.startTime) + : '-'} + + {job.lastExecution ? duration : '-'} + + {job.scheduleCron ? ( + + 자동 + + ) : ( + + 수동 + + )} + +
+ + +
+
+
+
+ )} + + setGuideOpen(false)} + /> + + {/* Execute Modal (custom with date params) */} + {executeModalOpen && ( +
setExecuteModalOpen(false)} + > +
e.stopPropagation()} + > +

작업 실행 확인

+

+ "{jobs.find((j) => j.jobName === targetJob)?.displayName || targetJob}" 작업을 실행하시겠습니까? +

+ +
+ + +
+
+
+ )} + +
+ ); +} diff --git a/frontend/src/pages/RecollectDetail.tsx b/frontend/src/pages/RecollectDetail.tsx new file mode 100644 index 0000000..a38da78 --- /dev/null +++ b/frontend/src/pages/RecollectDetail.tsx @@ -0,0 +1,775 @@ +import { useState, useCallback, useEffect, useMemo } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { + batchApi, + type RecollectionDetailResponse, + type StepExecutionDto, + type FailedRecordDto, + type JobDisplayName, +} from '../api/batchApi'; +import { formatDateTime, formatDuration, calculateDuration } from '../utils/formatters'; +import { usePoller } from '../hooks/usePoller'; +import StatusBadge from '../components/StatusBadge'; +import EmptyState from '../components/EmptyState'; +import LoadingSpinner from '../components/LoadingSpinner'; +import Pagination from '../components/Pagination'; +import DetailStatCard from '../components/DetailStatCard'; +import ApiLogSection from '../components/ApiLogSection'; +import InfoItem from '../components/InfoItem'; +import GuideModal, { HelpButton } from '../components/GuideModal'; + +const POLLING_INTERVAL_MS = 10_000; + +function StepCard({ step, jobName, jobExecutionId }: { step: StepExecutionDto; jobName: string; jobExecutionId: number }) { + const stats = [ + { label: '읽기', value: step.readCount }, + { label: '쓰기', value: step.writeCount }, + { label: '커밋', value: step.commitCount }, + { label: '롤백', value: step.rollbackCount }, + { label: '읽기 건너뜀', value: step.readSkipCount }, + { label: '처리 건너뜀', value: step.processSkipCount }, + { label: '쓰기 건너뜀', value: step.writeSkipCount }, + { label: '필터', value: step.filterCount }, + ]; + + const summary = step.apiLogSummary; + + return ( +
+
+
+

+ {step.stepName} +

+ +
+ + {step.duration != null + ? formatDuration(step.duration) + : calculateDuration(step.startTime, step.endTime)} + +
+ +
+
+ 시작: {formatDateTime(step.startTime)} +
+
+ 종료: {formatDateTime(step.endTime)} +
+
+ +
+ {stats.map(({ label, value }) => ( +
+

+ {value.toLocaleString()} +

+

{label}

+
+ ))} +
+ + {/* API 호출 로그 요약 (batch_api_log 기반) */} + {summary && ( +
+

API 호출 정보

+
+
+

{summary.totalCalls.toLocaleString()}

+

총 호출

+
+
+

{summary.successCount.toLocaleString()}

+

성공

+
+
+

0 ? 'text-red-500' : 'text-wing-text'}`}> + {summary.errorCount.toLocaleString()} +

+

에러

+
+
+

{Math.round(summary.avgResponseMs).toLocaleString()}

+

평균(ms)

+
+
+

{summary.maxResponseMs.toLocaleString()}

+

최대(ms)

+
+
+

{summary.minResponseMs.toLocaleString()}

+

최소(ms)

+
+
+ + {summary.totalCalls > 0 && ( + + )} +
+ )} + + {/* 호출 실패 데이터 토글 */} + {step.failedRecords && step.failedRecords.length > 0 && ( + + )} + + {step.exitMessage && ( +
+

Exit Message

+

+ {step.exitMessage} +

+
+ )} +
+ ); +} + +const RECOLLECT_DETAIL_GUIDE = [ + { + title: '재수집 기본 정보', + content: '재수집 실행자, 실행일시, 소요 시간, 재수집 사유 등 기본 정보를 보여줍니다.\n재수집 기간(시작~종료)도 함께 확인할 수 있습니다.', + }, + { + title: '처리 통계', + content: '재수집 처리 현황을 4개 카드로 요약합니다.\n• 읽기(Read): API에서 조회한 건수\n• 쓰기(Write): DB에 저장된 건수\n• 건너뜀(Skip): 변경 없어 건너뛴 건수\n• API 호출: 외부 API 총 호출 수', + }, + { + title: '기간 중복 이력', + content: '동일 기간에 수행된 다른 수집/재수집 이력이 있으면 표시됩니다.\n중복 수집 여부를 확인하여 데이터 정합성을 검증할 수 있습니다.', + }, + { + title: 'Step 실행 정보', + content: '배치 작업은 하나 이상의 Step으로 구성됩니다.\n각 Step의 상태, 처리 건수, 커밋/롤백 횟수를 확인할 수 있습니다.\nAPI 호출 정보에서는 총 호출 수, 성공/에러 수, 평균 응답 시간을 보여줍니다.', + }, + { + title: 'API 호출 로그', + content: '각 Step에서 호출한 외부 API의 상세 로그를 확인할 수 있습니다.\n요청 URL, 응답 코드, 응답 시간 등을 페이지 단위로 조회합니다.', + }, + { + title: '실패 건 관리', + content: '처리 중 실패한 레코드가 있으면 목록으로 표시됩니다.\n• 실패 건 재수집: 실패한 데이터를 다시 수집합니다\n• 일괄 RESOLVED: 모든 실패 건을 해결됨으로 처리합니다\n• 재시도 초기화: 재시도 횟수를 초기화하여 자동 재수집 대상에 포함시킵니다', + }, +]; + +export default function RecollectDetail() { + const { id: paramId } = useParams<{ id: string }>(); + const navigate = useNavigate(); + + const historyId = paramId ? Number(paramId) : NaN; + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [displayNames, setDisplayNames] = useState([]); + const [guideOpen, setGuideOpen] = useState(false); + + useEffect(() => { + batchApi.getDisplayNames().then(setDisplayNames).catch(() => {}); + }, []); + + const displayNameMap = useMemo>(() => { + const map: Record = {}; + for (const dn of displayNames) { + if (dn.apiKey) map[dn.apiKey] = dn.displayName; + map[dn.jobName] = dn.displayName; + } + return map; + }, [displayNames]); + + const isRunning = data + ? data.history.executionStatus === 'STARTED' + : false; + + const loadDetail = useCallback(async () => { + if (!historyId || isNaN(historyId)) { + setError('유효하지 않은 이력 ID입니다.'); + setLoading(false); + return; + } + try { + const result = await batchApi.getRecollectionDetail(historyId); + setData(result); + setError(null); + } catch (err) { + setError( + err instanceof Error + ? err.message + : '재수집 상세 정보를 불러오지 못했습니다.', + ); + } finally { + setLoading(false); + } + }, [historyId]); + + usePoller(loadDetail, isRunning ? POLLING_INTERVAL_MS : 30_000, [historyId]); + + if (loading) return ; + + if (error || !data) { + return ( +
+ + +
+ ); + } + + const { history, overlappingHistories, apiStats, stepExecutions } = data; + + return ( +
+ {/* 상단 내비게이션 */} + + + {/* 기본 정보 카드 */} +
+
+
+
+

+ 재수집 #{history.historyId} +

+ setGuideOpen(true)} /> +
+

+ {displayNameMap[history.apiKey] || history.apiKeyName || history.apiKey} · {history.jobName} +

+
+
+ + {history.hasOverlap && ( + + 기간 중복 + + )} +
+
+ +
+ + + + + + {history.jobExecutionId && ( + + )} +
+
+ + {/* 수집 기간 정보 */} +
+

+ 재수집 기간 +

+
+ + +
+
+ + {/* 처리 통계 카드 */} +
+ + + + +
+ + {/* API 응답시간 통계 */} + {apiStats && ( +
+

+ API 응답시간 통계 +

+
+
+

+ {apiStats.callCount.toLocaleString()} +

+

총 호출수

+
+
+

+ {apiStats.totalMs.toLocaleString()} +

+

총 응답시간(ms)

+
+
+

+ {Math.round(apiStats.avgMs).toLocaleString()} +

+

평균(ms)

+
+
+

+ {apiStats.maxMs.toLocaleString()} +

+

최대(ms)

+
+
+

+ {apiStats.minMs.toLocaleString()} +

+

최소(ms)

+
+
+
+ )} + + {/* 실패 사유 */} + {history.executionStatus === 'FAILED' && history.failureReason && ( +
+

+ 실패 사유 +

+
+                        {history.failureReason}
+                    
+
+ )} + + {/* 기간 중복 이력 */} + {overlappingHistories.length > 0 && ( +
+

+ 기간 중복 이력 + + ({overlappingHistories.length}건) + +

+
+ + + + + + + + + + + + + {overlappingHistories.map((oh) => ( + navigate(`/recollects/${oh.historyId}`)} + > + + + + + + + + ))} + +
이력 ID작업명수집 시작일수집 종료일상태실행자
+ #{oh.historyId} + + {displayNameMap[oh.apiKey] || oh.apiKeyName || oh.apiKey} + + {formatDateTime(oh.rangeFromDate)} + + {formatDateTime(oh.rangeToDate)} + + + + {oh.executor || '-'} +
+
+
+ )} + + {/* Step 실행 정보 */} +
+

+ Step 실행 정보 + + ({stepExecutions.length}개) + +

+ {stepExecutions.length === 0 ? ( + + ) : ( +
+ {stepExecutions.map((step) => ( + + ))} +
+ )} +
+ + setGuideOpen(false)} + /> +
+ ); +} + +const FAILED_PAGE_SIZE = 10; + +function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: FailedRecordDto[]; jobName: string; jobExecutionId: number }) { + const [open, setOpen] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); + const [showResolveConfirm, setShowResolveConfirm] = useState(false); + const [retrying, setRetrying] = useState(false); + const [resolving, setResolving] = useState(false); + const [showResetConfirm, setShowResetConfirm] = useState(false); + const [resetting, setResetting] = useState(false); + const [page, setPage] = useState(0); + const navigate = useNavigate(); + + const failedRecords = records.filter((r) => r.status === 'FAILED'); + const totalPages = Math.ceil(records.length / FAILED_PAGE_SIZE); + const pagedRecords = records.slice(page * FAILED_PAGE_SIZE, (page + 1) * FAILED_PAGE_SIZE); + + const statusColor = (status: string) => { + switch (status) { + case 'RESOLVED': return 'text-emerald-600 bg-emerald-50'; + case 'RETRY_PENDING': return 'text-amber-600 bg-amber-50'; + default: return 'text-red-600 bg-red-50'; + } + }; + + const MAX_RETRY_COUNT = 3; + + const retryStatusLabel = (record: FailedRecordDto) => { + if (record.status !== 'FAILED') return null; + if (record.retryCount >= MAX_RETRY_COUNT) return { label: '재시도 초과', color: 'text-red-600 bg-red-100' }; + if (record.retryCount > 0) return { label: `재시도 ${record.retryCount}/${MAX_RETRY_COUNT}`, color: 'text-amber-600 bg-amber-100' }; + return { label: '대기', color: 'text-blue-600 bg-blue-100' }; + }; + + const exceededRecords = failedRecords.filter((r) => r.retryCount >= MAX_RETRY_COUNT); + + const handleRetry = async () => { + setRetrying(true); + try { + const result = await batchApi.retryFailedRecords(jobName, failedRecords.length, jobExecutionId); + if (result.success) { + setShowConfirm(false); + if (result.executionId) { + navigate(`/executions/${result.executionId}`); + } else { + alert(result.message || '재수집이 요청되었습니다.'); + } + } else { + alert(result.message || '재수집 실행에 실패했습니다.'); + } + } catch { + alert('재수집 실행에 실패했습니다.'); + } finally { + setRetrying(false); + } + }; + + const handleResolve = async () => { + setResolving(true); + try { + const ids = failedRecords.map((r) => r.id); + await batchApi.resolveFailedRecords(ids); + setShowResolveConfirm(false); + navigate(0); + } catch { + alert('일괄 RESOLVED 처리에 실패했습니다.'); + } finally { + setResolving(false); + } + }; + + const handleResetRetry = async () => { + setResetting(true); + try { + const ids = exceededRecords.map((r) => r.id); + await batchApi.resetRetryCount(ids); + setShowResetConfirm(false); + navigate(0); + } catch { + alert('재시도 초기화에 실패했습니다.'); + } finally { + setResetting(false); + } + }; + + return ( +
+
+ + + {failedRecords.length > 0 && ( +
+ {exceededRecords.length > 0 && ( + + )} + + +
+ )} +
+ + {open && ( +
+
+ + + + + + + + + + + + {pagedRecords.map((record) => ( + + + + + + + + ))} + +
Record Key에러 메시지재시도상태생성 시간
+ {record.recordKey} + + {record.errorMessage || '-'} + + {(() => { + const info = retryStatusLabel(record); + return info ? ( + + {info.label} + + ) : ( + - + ); + })()} + + + {record.status} + + + {formatDateTime(record.createdAt)} +
+
+ +
+ )} + + {/* 재수집 확인 다이얼로그 */} + {showConfirm && ( +
+
+

+ 실패 건 재수집 확인 +

+

+ 다음 {failedRecords.length}건의 IMO에 대해 재수집을 실행합니다. +

+
+
+ {failedRecords.map((r) => ( + + {r.recordKey} + + ))} +
+
+
+ + +
+
+
+ )} + + {/* 일괄 RESOLVED 확인 다이얼로그 */} + {showResolveConfirm && ( +
+
+

+ 일괄 RESOLVED 확인 +

+

+ FAILED 상태의 {failedRecords.length}건을 RESOLVED로 변경합니다. + 이 작업은 되돌릴 수 없습니다. +

+
+ + +
+
+
+ )} + + {/* 재시도 초기화 확인 다이얼로그 */} + {showResetConfirm && ( +
+
+

+ 재시도 초기화 확인 +

+

+ 재시도 횟수를 초과한 {exceededRecords.length}건의 retryCount를 0으로 초기화합니다. + 초기화 후 다음 배치 실행 시 자동 재수집 대상에 포함됩니다. +

+
+ + +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/Recollects.tsx b/frontend/src/pages/Recollects.tsx new file mode 100644 index 0000000..127e58d --- /dev/null +++ b/frontend/src/pages/Recollects.tsx @@ -0,0 +1,1066 @@ +import { useState, useMemo, useCallback, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + batchApi, + type RecollectionHistoryDto, + type RecollectionSearchResponse, + type CollectionPeriodDto, + type LastCollectionStatusDto, + type JobDisplayName, +} from '../api/batchApi'; +import { formatDateTime, formatDuration } from '../utils/formatters'; +import { usePoller } from '../hooks/usePoller'; +import { useToastContext } from '../contexts/ToastContext'; +import StatusBadge from '../components/StatusBadge'; +import InfoModal from '../components/InfoModal'; +import EmptyState from '../components/EmptyState'; +import LoadingSpinner from '../components/LoadingSpinner'; +import GuideModal, { HelpButton } from '../components/GuideModal'; + +type StatusFilter = 'ALL' | 'COMPLETED' | 'FAILED' | 'STARTED'; + +const STATUS_FILTERS: { value: StatusFilter; label: string }[] = [ + { value: 'ALL', label: '전체' }, + { value: 'COMPLETED', label: '완료' }, + { value: 'FAILED', label: '실패' }, + { value: 'STARTED', label: '실행중' }, +]; + +const POLLING_INTERVAL_MS = 10_000; +const PAGE_SIZE = 20; + +/** datetime 문자열에서 date input용 값 추출 (YYYY-MM-DD) */ +function toDateInput(dt: string | null): string { + if (!dt) return ''; + return dt.substring(0, 10); +} + +/** datetime 문자열에서 time input용 값 추출 (HH:mm) */ +function toTimeInput(dt: string | null): string { + if (!dt) return '00:00'; + const t = dt.substring(11, 16); + return t || '00:00'; +} + +/** date + time을 ISO datetime 문자열로 결합 */ +function toIsoDateTime(date: string, time: string): string { + return `${date}T${time || '00:00'}:00`; +} + +interface PeriodEdit { + fromDate: string; + fromTime: string; + toDate: string; + toTime: string; +} + +// ── 수집 상태 임계값 (분 단위) ──────────────────────────────── +const THRESHOLD_WARN_MINUTES = 24 * 60; // 24시간 +const THRESHOLD_DANGER_MINUTES = 48 * 60; // 48시간 + +type CollectionStatusLevel = 'normal' | 'warn' | 'danger'; + +function getStatusLevel(elapsedMinutes: number): CollectionStatusLevel { + if (elapsedMinutes < 0) return 'danger'; + if (elapsedMinutes <= THRESHOLD_WARN_MINUTES) return 'normal'; + if (elapsedMinutes <= THRESHOLD_DANGER_MINUTES) return 'warn'; + return 'danger'; +} + +function getStatusLabel(level: CollectionStatusLevel): string { + if (level === 'normal') return '정상'; + if (level === 'warn') return '주의'; + return '경고'; +} + +function getStatusColor(level: CollectionStatusLevel): string { + if (level === 'normal') return 'text-green-500'; + if (level === 'warn') return 'text-yellow-500'; + return 'text-red-500'; +} + +function getStatusBgColor(level: CollectionStatusLevel): string { + if (level === 'normal') return 'bg-green-500/10 text-green-600 dark:text-green-400'; + if (level === 'warn') return 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-400'; + return 'bg-red-500/10 text-red-600 dark:text-red-400'; +} + +function formatElapsed(minutes: number): string { + if (minutes < 0) return '-'; + if (minutes < 60) return `${minutes}분 전`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}시간 ${minutes % 60}분 전`; + const days = Math.floor(hours / 24); + const remainHours = hours % 24; + return remainHours > 0 ? `${days}일 ${remainHours}시간 전` : `${days}일 전`; +} + +/** 기간 프리셋 정의 (시간 단위) */ +const DURATION_PRESETS = [ + { label: '6시간', hours: 6 }, + { label: '12시간', hours: 12 }, + { label: '하루', hours: 24 }, + { label: '일주일', hours: 168 }, +] as const; + +/** 시작 날짜+시간에 시간(hours)을 더해 종료 날짜+시간을 반환 */ +function addHoursToDateTime( + date: string, + time: string, + hours: number, +): { toDate: string; toTime: string } { + if (!date) return { toDate: '', toTime: '00:00' }; + const dt = new Date(`${date}T${time || '00:00'}:00`); + dt.setTime(dt.getTime() + hours * 60 * 60 * 1000); + const y = dt.getFullYear(); + const m = String(dt.getMonth() + 1).padStart(2, '0'); + const d = String(dt.getDate()).padStart(2, '0'); + const hh = String(dt.getHours()).padStart(2, '0'); + const mm = String(dt.getMinutes()).padStart(2, '0'); + return { toDate: `${y}-${m}-${d}`, toTime: `${hh}:${mm}` }; +} + +export default function Recollects() { + const navigate = useNavigate(); + const { showToast } = useToastContext(); + const [displayNames, setDisplayNames] = useState([]); + const [lastCollectionStatuses, setLastCollectionStatuses] = useState([]); + const [lastCollectionPanelOpen, setLastCollectionPanelOpen] = useState(false); + + const [periods, setPeriods] = useState([]); + const [histories, setHistories] = useState([]); + const [selectedApiKey, setSelectedApiKey] = useState(''); + const [apiDropdownOpen, setApiDropdownOpen] = useState(false); + const [statusFilter, setStatusFilter] = useState('ALL'); + const [loading, setLoading] = useState(true); + + // 날짜 범위 필터 + 페이지네이션 + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + const [page, setPage] = useState(0); + const [totalPages, setTotalPages] = useState(0); + const [totalCount, setTotalCount] = useState(0); + const [useSearch, setUseSearch] = useState(false); + + // 실패건 수 (jobExecutionId → count) + const [failedRecordCounts, setFailedRecordCounts] = useState>({}); + + // 실패 로그 모달 + const [failLogTarget, setFailLogTarget] = useState(null); + + // 가이드 모달 + const [guideOpen, setGuideOpen] = useState(false); + +const RECOLLECTS_GUIDE = [ + { + title: '재수집이란?', + content: '재수집은 특정 기간의 데이터를 다시 수집하는 기능입니다.\n수집 누락이나 데이터 오류가 발생했을 때 사용합니다.\n자동 재수집은 시스템이 실패 건을 자동으로 재시도하며, 수동 재수집은 사용자가 직접 요청합니다.', + }, + { + title: '마지막 수집 완료 일시', + content: '각 API별 마지막 수집 완료 일시를 보여줍니다.\n이 정보를 참고하여 재수집이 필요한 기간을 판단할 수 있습니다.', + }, + { + title: '재수집 기간 관리', + content: '1. "재수집 기간 관리" 영역에서 수집할 작업을 선택합니다\n2. 수집 기간(시작~종료)을 설정합니다\n3. 재수집 사유를 입력합니다 (선택)\n4. "재수집 요청" 버튼을 클릭합니다\n\n기간 설정 시 기존 수집 기간과 중복되면 경고가 표시됩니다.', + }, + { + title: '이력 조회', + content: '작업 선택, 상태 필터, 날짜 범위로 재수집 이력을 검색할 수 있습니다.\n상태: 완료(COMPLETED) / 실패(FAILED) / 실행중(STARTED)\n각 행을 클릭하면 상세 화면으로 이동합니다.', + }, +]; + + // 수집 기간 관리 패널 + const [periodPanelOpen, setPeriodPanelOpen] = useState(false); + const [selectedPeriodKey, setSelectedPeriodKey] = useState(''); + const [periodDropdownOpen, setPeriodDropdownOpen] = useState(false); + const [periodEdits, setPeriodEdits] = useState>({}); + const [savingApiKey, setSavingApiKey] = useState(null); + const [executingApiKey, setExecutingApiKey] = useState(null); + const [manualToDate, setManualToDate] = useState>({}); + const [selectedDuration, setSelectedDuration] = useState>({}); + + const getPeriodEdit = (p: CollectionPeriodDto): PeriodEdit => { + if (periodEdits[p.apiKey]) return periodEdits[p.apiKey]; + return { + fromDate: toDateInput(p.rangeFromDate), + fromTime: toTimeInput(p.rangeFromDate), + toDate: toDateInput(p.rangeToDate), + toTime: toTimeInput(p.rangeToDate), + }; + }; + + const updatePeriodEdit = (apiKey: string, field: keyof PeriodEdit, value: string) => { + const current = periodEdits[apiKey] || getPeriodEdit(periods.find((p) => p.apiKey === apiKey)!); + setPeriodEdits((prev) => ({ ...prev, [apiKey]: { ...current, [field]: value } })); + }; + + const applyDurationPreset = (apiKey: string, hours: number) => { + const p = periods.find((pp) => pp.apiKey === apiKey); + if (!p) return; + const edit = periodEdits[apiKey] || getPeriodEdit(p); + if (!edit.fromDate) { + showToast('재수집 시작일시를 먼저 선택해 주세요.', 'error'); + return; + } + const { toDate, toTime } = addHoursToDateTime(edit.fromDate, edit.fromTime, hours); + setSelectedDuration((prev) => ({ ...prev, [apiKey]: hours })); + setPeriodEdits((prev) => ({ + ...prev, + [apiKey]: { ...edit, toDate, toTime }, + })); + }; + + const handleFromDateChange = (apiKey: string, field: 'fromDate' | 'fromTime', value: string) => { + const p = periods.find((pp) => pp.apiKey === apiKey); + if (!p) return; + const edit = periodEdits[apiKey] || getPeriodEdit(p); + const updated = { ...edit, [field]: value }; + // 기간 프리셋이 선택된 상태면 종료일시도 자동 갱신 + const dur = selectedDuration[apiKey]; + if (dur != null && !manualToDate[apiKey]) { + const { toDate, toTime } = addHoursToDateTime(updated.fromDate, updated.fromTime, dur); + updated.toDate = toDate; + updated.toTime = toTime; + } + setPeriodEdits((prev) => ({ ...prev, [apiKey]: updated })); + }; + + const handleResetPeriod = async (p: CollectionPeriodDto) => { + setSavingApiKey(p.apiKey); + try { + await batchApi.resetCollectionPeriod(p.apiKey); + showToast(`${displayNameMap[p.apiKey] || p.apiKeyName || p.apiKey} 수집 기간이 초기화되었습니다.`, 'success'); + setPeriodEdits((prev) => { const next = { ...prev }; delete next[p.apiKey]; return next; }); + setSelectedDuration((prev) => ({ ...prev, [p.apiKey]: null })); + setManualToDate((prev) => ({ ...prev, [p.apiKey]: false })); + await loadPeriods(); + } catch (err) { + showToast(err instanceof Error ? err.message : '수집 기간 초기화에 실패했습니다.', 'error'); + } finally { + setSavingApiKey(null); + } + }; + + const handleSavePeriod = async (p: CollectionPeriodDto) => { + const edit = getPeriodEdit(p); + if (!edit.fromDate || !edit.toDate) { + showToast('시작일과 종료일을 모두 입력해 주세요.', 'error'); + return; + } + const from = toIsoDateTime(edit.fromDate, edit.fromTime); + const to = toIsoDateTime(edit.toDate, edit.toTime); + const now = new Date().toISOString().substring(0, 19); + if (from >= now) { + showToast('재수집 시작일시는 현재 시간보다 이전이어야 합니다.', 'error'); + return; + } + if (to >= now) { + showToast('재수집 종료일시는 현재 시간보다 이전이어야 합니다.', 'error'); + return; + } + if (from >= to) { + showToast('재수집 시작일시는 종료일시보다 이전이어야 합니다.', 'error'); + return; + } + setSavingApiKey(p.apiKey); + try { + await batchApi.updateCollectionPeriod(p.apiKey, { rangeFromDate: from, rangeToDate: to }); + showToast(`${displayNameMap[p.apiKey] || p.apiKeyName || p.apiKey} 수집 기간이 저장되었습니다.`, 'success'); + setPeriodEdits((prev) => { const next = { ...prev }; delete next[p.apiKey]; return next; }); + await loadPeriods(); + } catch (err) { + showToast(err instanceof Error ? err.message : '수집 기간 저장에 실패했습니다.', 'error'); + } finally { + setSavingApiKey(null); + } + }; + + const handleExecuteRecollect = async (p: CollectionPeriodDto) => { + if (!p.jobName) { + showToast('연결된 Job이 없습니다.', 'error'); + return; + } + setExecutingApiKey(p.apiKey); + try { + const result = await batchApi.executeJob(p.jobName, { + executionMode: 'RECOLLECT', + apiKey: p.apiKey, + executor: 'MANUAL', + reason: '수집 기간 관리 화면에서 수동 실행', + }); + showToast(result.message || `${displayNameMap[p.apiKey] || p.apiKeyName || p.apiKey} 재수집이 시작되었습니다.`, 'success'); + setLoading(true); + } catch (err) { + showToast(err instanceof Error ? err.message : '재수집 실행에 실패했습니다.', 'error'); + } finally { + setExecutingApiKey(null); + } + }; + + const loadLastCollectionStatuses = useCallback(async () => { + try { + const data = await batchApi.getLastCollectionStatuses(); + setLastCollectionStatuses(data); + } catch { + /* 수집 성공일시 로드 실패 무시 (폴링 중 반복 에러 toast 방지) */ + } + }, []); + + const loadPeriods = useCallback(async () => { + try { + const data = await batchApi.getCollectionPeriods(); + setPeriods(data); + } catch { + /* 수집기간 로드 실패 무시 (폴링 중 반복 에러 toast 방지) */ + } + }, []); + + const loadMetadata = useCallback(async () => { + await Promise.all([loadLastCollectionStatuses(), loadPeriods()]); + }, [loadLastCollectionStatuses, loadPeriods]); + + const [initialLoad, setInitialLoad] = useState(true); + + const loadHistories = useCallback(async () => { + try { + const params: { + apiKey?: string; + status?: string; + fromDate?: string; + toDate?: string; + page?: number; + size?: number; + } = { + page: useSearch ? page : 0, + size: PAGE_SIZE, + }; + if (selectedApiKey) params.apiKey = selectedApiKey; + if (statusFilter !== 'ALL') params.status = statusFilter; + if (useSearch && startDate) params.fromDate = `${startDate}T00:00:00`; + if (useSearch && endDate) params.toDate = `${endDate}T23:59:59`; + + const data: RecollectionSearchResponse = await batchApi.searchRecollections(params); + setHistories(data.content); + setTotalPages(data.totalPages); + setTotalCount(data.totalElements); + setFailedRecordCounts(data.failedRecordCounts ?? {}); + if (!useSearch) setPage(data.number); + setInitialLoad(false); + } catch (err) { + console.error('이력 조회 실패:', err); + /* 초기 로드 실패만 toast, 폴링 중 실패는 console.error만 */ + if (initialLoad) { + showToast('재수집 이력을 불러오지 못했습니다.', 'error'); + } + setHistories([]); + setTotalPages(0); + setTotalCount(0); + } finally { + setLoading(false); + } + }, [selectedApiKey, statusFilter, startDate, endDate, useSearch, page, showToast, initialLoad]); + + usePoller(loadMetadata, 60_000, []); + usePoller(loadHistories, POLLING_INTERVAL_MS, [selectedApiKey, statusFilter, useSearch, page]); + + const collectionStatusSummary = useMemo(() => { + let normal = 0, warn = 0, danger = 0; + for (const s of lastCollectionStatuses) { + const level = getStatusLevel(s.elapsedMinutes); + if (level === 'normal') normal++; + else if (level === 'warn') warn++; + else danger++; + } + return { normal, warn, danger, total: lastCollectionStatuses.length }; + }, [lastCollectionStatuses]); + + const filteredHistories = useMemo(() => { + if (useSearch) return histories; + if (statusFilter === 'ALL') return histories; + return histories.filter((h) => h.executionStatus === statusFilter); + }, [histories, statusFilter, useSearch]); + + const handleSearch = async () => { + setUseSearch(true); + setPage(0); + setLoading(true); + await loadHistories(); + }; + + const handleResetSearch = () => { + setUseSearch(false); + setStartDate(''); + setEndDate(''); + setPage(0); + setTotalPages(0); + setTotalCount(0); + setLoading(true); + }; + + const handlePageChange = (newPage: number) => { + if (newPage < 0 || newPage >= totalPages) return; + setPage(newPage); + setLoading(true); + }; + + useEffect(() => { + batchApi.getDisplayNames() + .then(setDisplayNames) + .catch(() => { /* displayName 로드 실패 무시 */ }); + }, []); + + const displayNameMap = useMemo>(() => { + const map: Record = {}; + for (const dn of displayNames) { + if (dn.apiKey) map[dn.apiKey] = dn.displayName; + } + return map; + }, [displayNames]); + + const getApiLabel = (apiKey: string) => { + if (displayNameMap[apiKey]) return displayNameMap[apiKey]; + const p = periods.find((p) => p.apiKey === apiKey); + return p?.apiKeyName || apiKey; + }; + + return ( +
+ {/* 헤더 */} +
+
+

재수집 이력

+ setGuideOpen(true)} /> +
+

+ 배치 재수집 실행 이력을 조회하고 관리합니다. +

+
+ + {/* 마지막 수집 성공일시 패널 */} +
+ + + {lastCollectionPanelOpen && ( +
+ {lastCollectionStatuses.length === 0 ? ( +
+ 수집 이력이 없습니다. +
+ ) : ( + <> + {/* 요약 바 */} +
+
+ + 정상 + {collectionStatusSummary.normal} +
+
+ + 주의 + {collectionStatusSummary.warn} +
+
+ + 경고 + {collectionStatusSummary.danger} +
+ + 기준: 정상 24h 이내 · 주의 24~48h · 경고 48h 초과 + +
+ + {/* 테이블 */} +
+ + + + + + + + + + + {lastCollectionStatuses.map((s) => { + const level = getStatusLevel(s.elapsedMinutes); + return ( + + + + + + + ); + })} + +
배치 작업명마지막 수집 완료일시경과시간상태
+
{displayNameMap[s.apiKey] || s.apiDesc || s.apiKey}
+
+ {formatDateTime(s.lastSuccessDate)} + + {formatElapsed(s.elapsedMinutes)} + + + {level === 'normal' && '●'} + {level === 'warn' && '▲'} + {level === 'danger' && '■'} + {' '}{getStatusLabel(level)} + +
+
+ + )} +
+ )} +
+ + {/* 수집 기간 관리 패널 */} +
+ + + {periodPanelOpen && ( +
+ {periods.length === 0 ? ( +
+ 등록된 수집 기간이 없습니다. +
+ ) : ( + <> + {/* 작업 선택 드롭다운 */} +
+ +
+ + {periodDropdownOpen && ( + <> +
setPeriodDropdownOpen(false)} /> +
+ {periods.map((p) => ( + + ))} +
+ + )} +
+
+ + {/* 선택된 작업의 기간 편집 */} + {selectedPeriodKey && (() => { + const p = periods.find((pp) => pp.apiKey === selectedPeriodKey); + if (!p) return null; + const edit = getPeriodEdit(p); + const hasChange = !!periodEdits[p.apiKey]; + const isSaving = savingApiKey === p.apiKey; + const isExecuting = executingApiKey === p.apiKey; + const isManual = !!manualToDate[p.apiKey]; + const activeDur = selectedDuration[p.apiKey] ?? null; + return ( +
+
+ 작업명: + {p.jobName || '-'} +
+ + {/* Line 1: 재수집 시작일시 */} +
+ +
+ handleFromDateChange(p.apiKey, 'fromDate', e.target.value)} + className="flex-[3] min-w-0 rounded border border-wing-border bg-wing-surface px-2 py-1.5 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent" + /> + handleFromDateChange(p.apiKey, 'fromTime', e.target.value)} + className="flex-[2] min-w-0 rounded border border-wing-border bg-wing-surface px-2 py-1.5 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent" + /> +
+
+ + {/* Line 2: 기간 선택 버튼 + 직접입력 토글 */} +
+ {DURATION_PRESETS.map(({ label, hours }) => ( + + ))} +
+ 직접입력 + +
+
+ + {/* Line 3: 재수집 종료일시 */} +
+ +
+ updatePeriodEdit(p.apiKey, 'toDate', e.target.value)} + className={`flex-[3] min-w-0 rounded border border-wing-border px-2 py-1.5 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent ${ + isManual ? 'bg-wing-surface' : 'bg-wing-card text-wing-muted cursor-not-allowed' + }`} + /> + updatePeriodEdit(p.apiKey, 'toTime', e.target.value)} + className={`flex-[2] min-w-0 rounded border border-wing-border px-2 py-1.5 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent ${ + isManual ? 'bg-wing-surface' : 'bg-wing-card text-wing-muted cursor-not-allowed' + }`} + /> +
+
+ +
+ + + +
+
+ ); + })()} + + )} +
+ )} +
+ + {/* 필터 영역 */} +
+
+ {/* API 선택 */} +
+
+ +
+ + {apiDropdownOpen && ( + <> +
setApiDropdownOpen(false)} /> +
+ + {periods.map((p) => ( + + ))} +
+ + )} +
+ {selectedApiKey && ( + + )} +
+ {selectedApiKey && ( +
+ + {getApiLabel(selectedApiKey)} + + +
+ )} +
+ + {/* 상태 필터 버튼 그룹 */} +
+ {STATUS_FILTERS.map(({ value, label }) => ( + + ))} +
+
+ + {/* 날짜 범위 필터 */} +
+ +
+ setStartDate(e.target.value)} + className="block rounded-lg border border-wing-border bg-wing-surface px-3 py-2 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent" + /> + ~ + setEndDate(e.target.value)} + className="block rounded-lg border border-wing-border bg-wing-surface px-3 py-2 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent" + /> +
+
+ + {useSearch && ( + + )} +
+
+
+ + {/* 재수집 이력 테이블 */} +
+ {loading ? ( + + ) : filteredHistories.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + + + + {filteredHistories.map((hist) => ( + + + + + + + + + + + + ))} + +
재수집 ID작업명상태배치 실행일시재수집 시작일시재수집 종료일시소요시간실패건액션
+ #{hist.historyId} + +
+ {displayNameMap[hist.apiKey] || hist.apiKeyName || hist.apiKey} +
+
+ {hist.executionStatus === 'FAILED' ? ( + + ) : ( + + )} + {hist.hasOverlap && ( + + ! + )} + +
{formatDateTime(hist.executionStartTime)}
+
+
{formatDateTime(hist.rangeFromDate)}
+
+
{formatDateTime(hist.rangeToDate)}
+
+ {formatDuration(hist.durationMs)} + + {(() => { + const count = hist.jobExecutionId + ? (failedRecordCounts[hist.jobExecutionId] ?? 0) + : 0; + if (hist.executionStatus === 'STARTED') { + return -; + } + return count > 0 ? ( + + {count}건 + + ) : ( + 0 + ); + })()} + + +
+
+ )} + + {/* 결과 건수 + 페이지네이션 */} + {!loading && filteredHistories.length > 0 && ( +
+
+ 총 {totalCount}건 +
+ {totalPages > 1 && ( +
+ + + {page + 1} / {totalPages} + + +
+ )} +
+ )} +
+ + {/* 실패 로그 뷰어 모달 */} + setFailLogTarget(null)} + > + {failLogTarget && ( +
+
+

+ 실행 상태 +

+

+ {failLogTarget.executionStatus} +

+
+
+

+ 실패 사유 +

+
+                                {failLogTarget.failureReason || '실패 사유 없음'}
+                            
+
+
+ )} +
+ + setGuideOpen(false)} + /> +
+ ); +} diff --git a/frontend/src/pages/Schedules.tsx b/frontend/src/pages/Schedules.tsx new file mode 100644 index 0000000..a248c3e --- /dev/null +++ b/frontend/src/pages/Schedules.tsx @@ -0,0 +1,841 @@ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { batchApi, type ScheduleResponse, type JobDisplayName } from '../api/batchApi'; +import { formatDateTime } from '../utils/formatters'; +import { useToastContext } from '../contexts/ToastContext'; +import ConfirmModal from '../components/ConfirmModal'; +import EmptyState from '../components/EmptyState'; +import LoadingSpinner from '../components/LoadingSpinner'; +import { getNextExecutions } from '../utils/cronPreview'; +import GuideModal, { HelpButton } from '../components/GuideModal'; + +type ScheduleMode = 'new' | 'existing'; +type ScheduleViewMode = 'card' | 'table'; +type ActiveFilterKey = 'ALL' | 'ACTIVE' | 'INACTIVE'; +type ScheduleSortKey = 'name' | 'nextFire' | 'active'; + +interface ActiveTabConfig { + key: ActiveFilterKey; + label: string; +} + +const ACTIVE_TABS: ActiveTabConfig[] = [ + { key: 'ALL', label: '전체' }, + { key: 'ACTIVE', label: '활성' }, + { key: 'INACTIVE', label: '비활성' }, +]; + +interface ConfirmAction { + type: 'toggle' | 'delete'; + schedule: ScheduleResponse; +} + +const CRON_PRESETS = [ + { label: '매 분', cron: '0 * * * * ?' }, + { label: '매시 정각', cron: '0 0 * * * ?' }, + { label: '매 15분', cron: '0 0/15 * * * ?' }, + { label: '매일 00:00', cron: '0 0 0 * * ?' }, + { label: '매일 12:00', cron: '0 0 12 * * ?' }, + { label: '매주 월 00:00', cron: '0 0 0 ? * MON' }, +]; + +function CronPreview({ cron }: { cron: string }) { + const nextDates = useMemo(() => getNextExecutions(cron, 5), [cron]); + + if (nextDates.length === 0) { + return ( +
+

미리보기 불가 (복잡한 표현식)

+
+ ); + } + + const fmt = new Intl.DateTimeFormat('ko-KR', { + month: '2-digit', + day: '2-digit', + weekday: 'short', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + + return ( +
+ +
+ {nextDates.map((d, i) => ( + + {fmt.format(d)} + + ))} +
+
+ ); +} + +function getTriggerStateStyle(state: string | null): string { + switch (state) { + case 'NORMAL': + return 'bg-emerald-100 text-emerald-700'; + case 'PAUSED': + return 'bg-amber-100 text-amber-700'; + case 'BLOCKED': + return 'bg-red-100 text-red-700'; + case 'ERROR': + return 'bg-red-100 text-red-700'; + default: + return 'bg-wing-card text-wing-muted'; + } +} + +const SCHEDULES_GUIDE = [ + { + title: '스케줄이란?', + content: '스케줄은 배치 작업을 자동으로 실행하는 설정입니다.\nCron 표현식으로 실행 주기를 지정하면 해당 시간에 자동 실행됩니다.\n활성화된 스케줄만 자동 실행되며, 비활성화하면 일시 중지됩니다.', + }, + { + title: '스케줄 등록/수정', + content: '"+ 새 스케줄" 버튼 또는 기존 스케줄의 "편집" 버튼을 클릭하면 설정 팝업이 열립니다.\n1. 작업 선택: 자동 실행할 배치 작업을 선택합니다\n2. Cron 표현식: 실행 주기를 설정합니다 (프리셋 버튼으로 간편 설정 가능)\n3. 설명: 스케줄에 대한 메모를 입력합니다 (선택)\n\n"다음 5회 실행 예정" 미리보기로 설정이 올바른지 확인하세요.', + }, + { + title: 'Cron 표현식', + content: 'Cron 표현식은 "초 분 시 일 월 요일" 6자리로 구성됩니다.\n예시:\n• 0 0/15 * * * ? → 매 15분마다\n• 0 0 0 * * ? → 매일 자정\n• 0 0 12 * * ? → 매일 정오\n• 0 0 0 ? * MON → 매주 월요일 자정\n\n프리셋 버튼을 활용하면 직접 입력하지 않아도 됩니다.', + }, + { + title: '스케줄 관리', + content: '• 편집: 스케줄 설정(Cron, 설명)을 수정합니다\n• 활성화/비활성화: 자동 실행을 켜거나 끕니다\n• 삭제: 스케줄을 완전히 제거합니다\n\n상태 표시:\n• 활성 (초록): 정상 동작 중\n• 비활성 (회색): 일시 중지 상태\n• NORMAL: 트리거 정상\n• PAUSED: 트리거 일시 중지\n• BLOCKED: 이전 실행이 아직 진행 중\n• ERROR: 트리거 오류 발생', + }, +]; + +export default function Schedules() { + const { showToast } = useToastContext(); + + // Guide modal state + const [guideOpen, setGuideOpen] = useState(false); + + // Form state + const [jobs, setJobs] = useState([]); + const [selectedJob, setSelectedJob] = useState(''); + const [cronExpression, setCronExpression] = useState(''); + const [description, setDescription] = useState(''); + const [scheduleMode, setScheduleMode] = useState('new'); + const [formLoading, setFormLoading] = useState(false); + const [saving, setSaving] = useState(false); + + // Schedule list state + const [schedules, setSchedules] = useState([]); + const [listLoading, setListLoading] = useState(true); + const [displayNames, setDisplayNames] = useState([]); + + // View mode state + const [viewMode, setViewMode] = useState('table'); + + // Search / filter / sort state + const [searchTerm, setSearchTerm] = useState(''); + const [activeFilter, setActiveFilter] = useState('ALL'); + const [sortKey, setSortKey] = useState('name'); + + // Confirm modal state + const [confirmAction, setConfirmAction] = useState(null); + + // Form modal state + const [formOpen, setFormOpen] = useState(false); + + const loadSchedules = useCallback(async () => { + try { + const result = await batchApi.getSchedules(); + setSchedules(result.schedules); + } catch (err) { + showToast('스케줄 목록 조회 실패', 'error'); + console.error(err); + } finally { + setListLoading(false); + } + }, [showToast]); + + const loadJobs = useCallback(async () => { + try { + const result = await batchApi.getJobs(); + setJobs(result); + } catch (err) { + showToast('작업 목록 조회 실패', 'error'); + console.error(err); + } + }, [showToast]); + + useEffect(() => { + loadJobs(); + loadSchedules(); + batchApi.getDisplayNames().then(setDisplayNames).catch(() => {}); + }, [loadJobs, loadSchedules]); + + const displayNameMap = useMemo>(() => { + const map: Record = {}; + for (const dn of displayNames) { + map[dn.jobName] = dn.displayName; + } + return map; + }, [displayNames]); + + const activeCounts = useMemo(() => { + const searchFiltered = searchTerm.trim() + ? schedules.filter((s) => { + const term = searchTerm.toLowerCase(); + return s.jobName.toLowerCase().includes(term) + || (displayNameMap[s.jobName]?.toLowerCase().includes(term) ?? false) + || (s.description?.toLowerCase().includes(term) ?? false); + }) + : schedules; + + return ACTIVE_TABS.reduce>( + (acc, tab) => { + acc[tab.key] = searchFiltered.filter((s) => { + if (tab.key === 'ALL') return true; + if (tab.key === 'ACTIVE') return s.active; + return !s.active; + }).length; + return acc; + }, + { ALL: 0, ACTIVE: 0, INACTIVE: 0 }, + ); + }, [schedules, searchTerm, displayNameMap]); + + const filteredSchedules = useMemo(() => { + let result = schedules; + + // 검색 필터 + if (searchTerm.trim()) { + const term = searchTerm.toLowerCase(); + result = result.filter((s) => + s.jobName.toLowerCase().includes(term) + || (displayNameMap[s.jobName]?.toLowerCase().includes(term) ?? false) + || (s.description?.toLowerCase().includes(term) ?? false), + ); + } + + // 활성/비활성 필터 + if (activeFilter === 'ACTIVE') { + result = result.filter((s) => s.active); + } else if (activeFilter === 'INACTIVE') { + result = result.filter((s) => !s.active); + } + + // 정렬 + result = [...result].sort((a, b) => { + if (sortKey === 'name') { + const aName = displayNameMap[a.jobName] || a.jobName; + const bName = displayNameMap[b.jobName] || b.jobName; + return aName.localeCompare(bName); + } + if (sortKey === 'nextFire') { + const aTime = a.nextFireTime ? new Date(a.nextFireTime).getTime() : Number.MAX_SAFE_INTEGER; + const bTime = b.nextFireTime ? new Date(b.nextFireTime).getTime() : Number.MAX_SAFE_INTEGER; + return aTime - bTime; + } + if (sortKey === 'active') { + if (a.active === b.active) { + const aName = displayNameMap[a.jobName] || a.jobName; + const bName = displayNameMap[b.jobName] || b.jobName; + return aName.localeCompare(bName); + } + return a.active ? -1 : 1; + } + return 0; + }); + + return result; + }, [schedules, searchTerm, activeFilter, sortKey, displayNameMap]); + + const handleJobSelect = async (jobName: string) => { + setSelectedJob(jobName); + setCronExpression(''); + setDescription(''); + setScheduleMode('new'); + + if (!jobName) return; + + setFormLoading(true); + try { + const schedule = await batchApi.getSchedule(jobName); + setCronExpression(schedule.cronExpression); + setDescription(schedule.description ?? ''); + setScheduleMode('existing'); + } catch { + // 404 = new schedule + setScheduleMode('new'); + } finally { + setFormLoading(false); + } + }; + + const handleSave = async () => { + if (!selectedJob) { + showToast('작업을 선택해주세요', 'error'); + return; + } + if (!cronExpression.trim()) { + showToast('Cron 표현식을 입력해주세요', 'error'); + return; + } + + setSaving(true); + try { + if (scheduleMode === 'existing') { + await batchApi.updateSchedule(selectedJob, { + cronExpression: cronExpression.trim(), + description: description.trim() || undefined, + }); + showToast('스케줄이 수정되었습니다', 'success'); + } else { + await batchApi.createSchedule({ + jobName: selectedJob, + cronExpression: cronExpression.trim(), + description: description.trim() || undefined, + }); + showToast('스케줄이 등록되었습니다', 'success'); + } + await loadSchedules(); + setFormOpen(false); + resetForm(); + } catch (err) { + const message = err instanceof Error ? err.message : '저장 실패'; + showToast(message, 'error'); + } finally { + setSaving(false); + } + }; + + const handleToggle = async (schedule: ScheduleResponse) => { + try { + await batchApi.toggleSchedule(schedule.jobName, !schedule.active); + showToast( + `${schedule.jobName} 스케줄이 ${schedule.active ? '비활성화' : '활성화'}되었습니다`, + 'success', + ); + await loadSchedules(); + } catch (err) { + const message = err instanceof Error ? err.message : '토글 실패'; + showToast(message, 'error'); + } + setConfirmAction(null); + }; + + const handleDelete = async (schedule: ScheduleResponse) => { + try { + await batchApi.deleteSchedule(schedule.jobName); + showToast(`${schedule.jobName} 스케줄이 삭제되었습니다`, 'success'); + await loadSchedules(); + // Close form if deleted schedule was being edited + if (selectedJob === schedule.jobName) { + resetForm(); + setFormOpen(false); + } + } catch (err) { + const message = err instanceof Error ? err.message : '삭제 실패'; + showToast(message, 'error'); + } + setConfirmAction(null); + }; + + const resetForm = () => { + setSelectedJob(''); + setCronExpression(''); + setDescription(''); + setScheduleMode('new'); + }; + + const handleEditFromCard = (schedule: ScheduleResponse) => { + setSelectedJob(schedule.jobName); + setCronExpression(schedule.cronExpression); + setDescription(schedule.description ?? ''); + setScheduleMode('existing'); + setFormOpen(true); + }; + + const handleNewSchedule = () => { + resetForm(); + setFormOpen(true); + }; + + const getScheduleLabel = (schedule: ScheduleResponse) => + displayNameMap[schedule.jobName] || schedule.jobName; + + if (listLoading) return ; + + return ( +
+ {/* Form Modal */} + {formOpen && ( +
+
setFormOpen(false)} /> +
+
+

+ {scheduleMode === 'existing' ? '스케줄 수정' : '스케줄 등록'} +

+ +
+ +
+ {/* Job Select */} +
+ +
+ + {selectedJob && ( + + {scheduleMode === 'existing' ? '기존 스케줄' : '새 스케줄'} + + )} + {formLoading && ( +
+ )} +
+
+ + {/* Cron Expression */} +
+ + setCronExpression(e.target.value)} + placeholder="0 0/15 * * * ?" + className="w-full rounded-lg border border-wing-border px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-wing-accent focus:border-wing-accent" + disabled={!selectedJob || formLoading} + /> +
+ + {/* Cron Presets */} +
+ +
+ {CRON_PRESETS.map(({ label, cron }) => ( + + ))} +
+
+ + {/* Cron Preview */} + {cronExpression.trim() && ( + + )} + + {/* Description */} +
+ + setDescription(e.target.value)} + placeholder="스케줄 설명 (선택)" + className="w-full rounded-lg border border-wing-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-wing-accent focus:border-wing-accent" + disabled={!selectedJob || formLoading} + /> +
+
+ + {/* Modal Footer */} +
+ + +
+
+
+ )} + + {/* Header */} +
+
+

스케줄 관리

+ setGuideOpen(true)} /> +
+
+ + + + 총 {schedules.length}개 스케줄 + +
+
+ + {/* Active Filter Tabs */} +
+ {ACTIVE_TABS.map((tab) => ( + + ))} +
+ + {/* Search + Sort + View Toggle */} +
+
+ {/* Search */} +
+ + + + + + setSearchTerm(e.target.value)} + className="w-full pl-10 pr-4 py-2 border border-wing-border rounded-lg text-sm + focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none" + /> + {searchTerm && ( + + )} +
+ + {/* Sort dropdown */} + + + {/* View mode toggle */} +
+ + +
+
+ + {searchTerm && ( +

+ {filteredSchedules.length}개 스케줄 검색됨 +

+ )} +
+ + {/* Schedule List */} + {filteredSchedules.length === 0 ? ( +
+ +
+ ) : viewMode === 'card' ? ( + /* Card View */ +
+ {filteredSchedules.map((schedule) => ( +
+
+
+

+ {getScheduleLabel(schedule)} +

+
+
+ + {schedule.active ? '활성' : '비활성'} + + {schedule.triggerState && ( + + {schedule.triggerState} + + )} +
+
+ + {/* Detail Info */} +
+
+ + {schedule.cronExpression} + +
+

+ 다음 실행: {formatDateTime(schedule.nextFireTime)} +

+ {schedule.previousFireTime && ( +

+ 이전 실행: {formatDateTime(schedule.previousFireTime)} +

+ )} +
+ + {/* Action Buttons */} +
+ + + +
+
+ ))} +
+ ) : ( + /* Table View */ +
+
+ + + + + + + + + + + + + {filteredSchedules.map((schedule) => ( + + + + + + + + + ))} + +
작업명Cron 표현식상태다음 실행이전 실행액션
+ {getScheduleLabel(schedule)} + + {schedule.cronExpression} + + + {schedule.active ? '활성' : '비활성'} + + {formatDateTime(schedule.nextFireTime)}{schedule.previousFireTime ? formatDateTime(schedule.previousFireTime) : '-'} +
+ + + +
+
+
+
+ )} + + {/* Confirm Modal */} + {confirmAction?.type === 'toggle' && ( + handleToggle(confirmAction.schedule)} + onCancel={() => setConfirmAction(null)} + /> + )} + {confirmAction?.type === 'delete' && ( + handleDelete(confirmAction.schedule)} + onCancel={() => setConfirmAction(null)} + /> + )} + setGuideOpen(false)} + pageTitle="스케줄 관리" + sections={SCHEDULES_GUIDE} + /> +
+ ); +} diff --git a/frontend/src/pages/Timeline.tsx b/frontend/src/pages/Timeline.tsx new file mode 100644 index 0000000..d0b95c6 --- /dev/null +++ b/frontend/src/pages/Timeline.tsx @@ -0,0 +1,513 @@ +import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { batchApi, type ExecutionInfo, type JobDisplayName, type JobExecutionDto, type PeriodInfo, type ScheduleTimeline } from '../api/batchApi'; +import { formatDateTime, calculateDuration } from '../utils/formatters'; +import { usePoller } from '../hooks/usePoller'; +import { useToastContext } from '../contexts/ToastContext'; +import { getStatusColor } from '../components/StatusBadge'; +import StatusBadge from '../components/StatusBadge'; +import LoadingSpinner from '../components/LoadingSpinner'; +import EmptyState from '../components/EmptyState'; +import GuideModal, { HelpButton } from '../components/GuideModal'; + +type ViewType = 'day' | 'week' | 'month'; + +interface TooltipData { + jobName: string; + period: PeriodInfo; + execution: ExecutionInfo; + x: number; + y: number; +} + +interface SelectedCell { + jobName: string; + periodKey: string; + periodLabel: string; +} + +const VIEW_OPTIONS: { value: ViewType; label: string }[] = [ + { value: 'day', label: 'Day' }, + { value: 'week', label: 'Week' }, + { value: 'month', label: 'Month' }, +]; + +const LEGEND_ITEMS = [ + { status: 'COMPLETED', color: '#10b981', label: '완료' }, + { status: 'FAILED', color: '#ef4444', label: '실패' }, + { status: 'STARTED', color: '#3b82f6', label: '실행중' }, + { status: 'SCHEDULED', color: '#8b5cf6', label: '예정' }, + { status: 'NONE', color: '#e5e7eb', label: '없음' }, +]; + +const JOB_COL_WIDTH = 200; +const CELL_MIN_WIDTH = 80; +const POLLING_INTERVAL = 30000; + +function formatDateStr(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +function shiftDate(date: Date, view: ViewType, delta: number): Date { + const next = new Date(date); + switch (view) { + case 'day': + next.setDate(next.getDate() + delta); + break; + case 'week': + next.setDate(next.getDate() + delta * 7); + break; + case 'month': + next.setMonth(next.getMonth() + delta); + break; + } + return next; +} + +function isRunning(status: string): boolean { + return status === 'STARTED' || status === 'STARTING'; +} + +const TIMELINE_GUIDE = [ + { + title: '타임라인이란?', + content: '타임라인은 배치 작업의 실행 스케줄과 결과를 시각적으로 보여주는 화면입니다.\n세로축은 작업 목록, 가로축은 시간대를 나타냅니다.\n각 셀의 색상으로 실행 상태를 한눈에 파악할 수 있습니다.', + }, + { + title: '보기 모드', + content: '3가지 보기 모드를 제공합니다.\n• Day: 하루 단위 (시간대별 상세 보기)\n• Week: 일주일 단위\n• Month: 한 달 단위\n\n이전/다음 버튼으로 기간을 이동하고, "오늘" 버튼으로 현재 날짜로 돌아옵니다.', + }, + { + title: '색상 범례', + content: '각 셀의 색상은 실행 상태를 나타냅니다.\n• 초록색: 완료 (성공적으로 실행됨)\n• 빨간색: 실패 (오류 발생)\n• 파란색: 실행 중 (현재 진행 중)\n• 보라색: 예정 (아직 실행 전)\n• 회색: 없음 (해당 시간대에 실행 기록 없음)', + }, + { + title: '상세 보기', + content: '셀 위에 마우스를 올리면 툴팁으로 작업명, 기간, 상태 등 요약 정보를 보여줍니다.\n셀을 클릭하면 하단에 상세 패널이 열리며, 해당 시간대의 실행 이력 목록을 확인할 수 있습니다.\n"상세" 링크를 클릭하면 실행 상세 화면으로 이동합니다.', + }, +]; + +export default function Timeline() { + const { showToast } = useToastContext(); + + // Guide modal state + const [guideOpen, setGuideOpen] = useState(false); + + const [view, setView] = useState('day'); + const [currentDate, setCurrentDate] = useState(() => new Date()); + const [periodLabel, setPeriodLabel] = useState(''); + const [periods, setPeriods] = useState([]); + const [schedules, setSchedules] = useState([]); + const [loading, setLoading] = useState(true); + + const [displayNames, setDisplayNames] = useState([]); + + useEffect(() => { + batchApi.getDisplayNames().then(setDisplayNames).catch(() => {}); + }, []); + + const displayNameMap = useMemo>(() => { + const map: Record = {}; + for (const dn of displayNames) { + map[dn.jobName] = dn.displayName; + } + return map; + }, [displayNames]); + + // Tooltip + const [tooltip, setTooltip] = useState(null); + const tooltipTimeoutRef = useRef | null>(null); + + // Selected cell & detail panel + const [selectedCell, setSelectedCell] = useState(null); + const [detailExecutions, setDetailExecutions] = useState([]); + const [detailLoading, setDetailLoading] = useState(false); + + const loadTimeline = useCallback(async () => { + try { + const dateStr = formatDateStr(currentDate); + const result = await batchApi.getTimeline(view, dateStr); + setPeriodLabel(result.periodLabel); + setPeriods(result.periods); + setSchedules(result.schedules); + } catch (err) { + showToast('타임라인 조회 실패', 'error'); + console.error(err); + } finally { + setLoading(false); + } + }, [view, currentDate, showToast]); + + usePoller(loadTimeline, POLLING_INTERVAL, [view, currentDate]); + + const handlePrev = () => setCurrentDate((d) => shiftDate(d, view, -1)); + const handleNext = () => setCurrentDate((d) => shiftDate(d, view, 1)); + const handleToday = () => setCurrentDate(new Date()); + + const handleRefresh = async () => { + setLoading(true); + await loadTimeline(); + }; + + // Tooltip handlers + const handleCellMouseEnter = ( + e: React.MouseEvent, + jobName: string, + period: PeriodInfo, + execution: ExecutionInfo, + ) => { + if (tooltipTimeoutRef.current) { + clearTimeout(tooltipTimeoutRef.current); + } + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + setTooltip({ + jobName, + period, + execution, + x: rect.left + rect.width / 2, + y: rect.top, + }); + }; + + const handleCellMouseLeave = () => { + tooltipTimeoutRef.current = setTimeout(() => { + setTooltip(null); + }, 100); + }; + + // Clean up tooltip timeout + useEffect(() => { + return () => { + if (tooltipTimeoutRef.current) { + clearTimeout(tooltipTimeoutRef.current); + } + }; + }, []); + + // Cell click -> detail panel + const handleCellClick = async (jobName: string, periodKey: string, periodLabel: string) => { + // Toggle off if clicking same cell + if (selectedCell?.jobName === jobName && selectedCell?.periodKey === periodKey) { + setSelectedCell(null); + setDetailExecutions([]); + return; + } + + setSelectedCell({ jobName, periodKey, periodLabel }); + setDetailLoading(true); + setDetailExecutions([]); + + try { + const executions = await batchApi.getPeriodExecutions(jobName, view, periodKey); + setDetailExecutions(executions); + } catch (err) { + showToast('구간 실행 이력 조회 실패', 'error'); + console.error(err); + } finally { + setDetailLoading(false); + } + }; + + const closeDetail = () => { + setSelectedCell(null); + setDetailExecutions([]); + }; + + const gridTemplateColumns = `${JOB_COL_WIDTH}px repeat(${periods.length}, minmax(${CELL_MIN_WIDTH}px, 1fr))`; + + return ( +
+ {/* Controls */} +
+
+ {/* View Toggle */} +
+ {VIEW_OPTIONS.map((opt) => ( + + ))} +
+ + {/* Navigation */} +
+ + + +
+ + {/* Period Label */} + + {periodLabel} + + + {/* Refresh */} + + + {/* Help */} + setGuideOpen(true)} /> +
+
+ + {/* Legend */} +
+ {LEGEND_ITEMS.map((item) => ( +
+
+ {item.label} +
+ ))} +
+ + {/* Timeline Grid */} +
+ {loading ? ( + + ) : schedules.length === 0 ? ( + + ) : ( +
+
+ {/* Header Row */} +
+ 작업명 +
+ {periods.map((period) => ( +
+ {period.label} +
+ ))} + + {/* Data Rows */} + {schedules.map((schedule) => ( + <> + {/* Job Name (sticky) */} +
+ {displayNameMap[schedule.jobName] || schedule.jobName} +
+ + {/* Execution Cells */} + {periods.map((period) => { + const exec = schedule.executions[period.key]; + const hasExec = exec !== null && exec !== undefined; + const isSelected = + selectedCell?.jobName === schedule.jobName && + selectedCell?.periodKey === period.key; + const running = hasExec && isRunning(exec.status); + + return ( +
+ handleCellClick(schedule.jobName, period.key, period.label) + } + onMouseEnter={ + hasExec + ? (e) => handleCellMouseEnter(e, schedule.jobName, period, exec) + : undefined + } + onMouseLeave={hasExec ? handleCellMouseLeave : undefined} + > + {hasExec && ( +
+ )} +
+ ); + })} + + ))} +
+
+ )} +
+ + {/* Tooltip */} + {tooltip && ( +
+
+
{displayNameMap[tooltip.jobName] || tooltip.jobName}
+
+
기간: {tooltip.period.label}
+
+ 상태:{' '} + + {tooltip.execution.status} + +
+ {tooltip.execution.startTime && ( +
시작: {formatDateTime(tooltip.execution.startTime)}
+ )} + {tooltip.execution.endTime && ( +
종료: {formatDateTime(tooltip.execution.endTime)}
+ )} + {tooltip.execution.executionId && ( +
실행 ID: {tooltip.execution.executionId}
+ )} +
+ {/* Arrow */} +
+
+
+ )} + + {/* Detail Panel */} + {selectedCell && ( +
+
+
+

+ {displayNameMap[selectedCell.jobName] || selectedCell.jobName} +

+

+ 구간: {selectedCell.periodLabel} +

+
+ +
+ + {detailLoading ? ( + + ) : detailExecutions.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + {detailExecutions.map((exec) => ( + + + + + + + + + ))} + +
+ 실행 ID + + 상태 + + 시작 시간 + + 종료 시간 + + 소요 시간 + + 상세 +
+ #{exec.executionId} + + + + {formatDateTime(exec.startTime)} + + {formatDateTime(exec.endTime)} + + {calculateDuration(exec.startTime, exec.endTime)} + + + 상세 + +
+
+ )} +
+ )} + setGuideOpen(false)} + pageTitle="타임라인" + sections={TIMELINE_GUIDE} + /> +
+ ); +} diff --git a/frontend/src/theme/base.css b/frontend/src/theme/base.css new file mode 100644 index 0000000..de7d284 --- /dev/null +++ b/frontend/src/theme/base.css @@ -0,0 +1,101 @@ +body { + font-family: 'Noto Sans KR', sans-serif; + background: var(--wing-bg); + color: var(--wing-text); + transition: background-color 0.2s ease, color 0.2s ease; +} + +/* Scrollbar styling for dark mode */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--wing-surface); +} + +::-webkit-scrollbar-thumb { + background: var(--wing-muted); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--wing-accent); +} + +/* Main Menu Cards */ +.gc-cards { + padding: 2rem 0; + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-auto-rows: 1fr; + gap: 2rem; + width: 80%; + margin: 0 auto; +} + +@media (max-width: 768px) { + .gc-cards { + grid-template-columns: 1fr; + width: 90%; + } +} + +.gc-card { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 2.5rem 2rem; + border: 1px solid var(--wing-border); + border-radius: 12px; + background: var(--wing-surface); + text-decoration: none !important; + color: inherit !important; + transition: all 0.2s ease; + height: 100%; +} + +.gc-card:hover { + border-color: #4183c4; + box-shadow: 0 4px 16px rgba(65, 131, 196, 0.15); + transform: translateY(-2px); +} + +.gc-card-icon { + color: #4183c4; + margin-bottom: 1rem; +} + +.gc-card-icon-guide { + color: #21ba45; +} + +.gc-card-icon-nexus { + color: #f2711c; +} + +.gc-card h3 { + font-size: 1.3rem; + margin-bottom: 0.5rem; + color: var(--wing-text); +} + +.gc-card p { + font-size: 0.95rem; + color: var(--wing-muted); + line-height: 1.5; + margin-bottom: 1rem; +} + +.gc-card-link { + font-size: 0.9rem; + color: #4183c4; + font-weight: 600; + margin-top: auto; +} + +.gc-card:hover .gc-card-link { + text-decoration: underline; +} diff --git a/frontend/src/theme/tokens.css b/frontend/src/theme/tokens.css new file mode 100644 index 0000000..5543625 --- /dev/null +++ b/frontend/src/theme/tokens.css @@ -0,0 +1,84 @@ +/* Dark theme (default) */ +:root, +[data-theme='dark'] { + --wing-bg: #020617; + --wing-surface: #0f172a; + --wing-card: #1e293b; + --wing-border: #1e3a5f; + --wing-text: #e2e8f0; + --wing-muted: #64748b; + --wing-accent: #3b82f6; + --wing-danger: #ef4444; + --wing-warning: #f59e0b; + --wing-success: #22c55e; + --wing-glass: rgba(15, 23, 42, 0.92); + --wing-glass-dense: rgba(15, 23, 42, 0.95); + --wing-overlay: rgba(2, 6, 23, 0.42); + --wing-card-alpha: rgba(30, 41, 59, 0.55); + --wing-subtle: rgba(255, 255, 255, 0.03); + --wing-hover: rgba(255, 255, 255, 0.05); + --wing-input-bg: #0f172a; + --wing-input-border: #334155; + --wing-rag-red-bg: rgba(127, 29, 29, 0.15); + --wing-rag-red-text: #fca5a5; + --wing-rag-amber-bg: rgba(120, 53, 15, 0.15); + --wing-rag-amber-text: #fcd34d; + --wing-rag-green-bg: rgba(5, 46, 22, 0.15); + --wing-rag-green-text: #86efac; +} + +/* Light theme */ +[data-theme='light'] { + --wing-bg: #e2e8f0; + --wing-surface: #ffffff; + --wing-card: #f1f5f9; + --wing-border: #94a3b8; + --wing-text: #0f172a; + --wing-muted: #64748b; + --wing-accent: #2563eb; + --wing-danger: #dc2626; + --wing-warning: #d97706; + --wing-success: #16a34a; + --wing-glass: rgba(255, 255, 255, 0.92); + --wing-glass-dense: rgba(255, 255, 255, 0.95); + --wing-overlay: rgba(0, 0, 0, 0.25); + --wing-card-alpha: rgba(226, 232, 240, 0.6); + --wing-subtle: rgba(0, 0, 0, 0.03); + --wing-hover: rgba(0, 0, 0, 0.04); + --wing-input-bg: #ffffff; + --wing-input-border: #cbd5e1; + --wing-rag-red-bg: #fef2f2; + --wing-rag-red-text: #b91c1c; + --wing-rag-amber-bg: #fffbeb; + --wing-rag-amber-text: #b45309; + --wing-rag-green-bg: #f0fdf4; + --wing-rag-green-text: #15803d; +} + +@theme { + --color-wing-bg: var(--wing-bg); + --color-wing-surface: var(--wing-surface); + --color-wing-card: var(--wing-card); + --color-wing-border: var(--wing-border); + --color-wing-text: var(--wing-text); + --color-wing-muted: var(--wing-muted); + --color-wing-accent: var(--wing-accent); + --color-wing-danger: var(--wing-danger); + --color-wing-warning: var(--wing-warning); + --color-wing-success: var(--wing-success); + --color-wing-glass: var(--wing-glass); + --color-wing-glass-dense: var(--wing-glass-dense); + --color-wing-overlay: var(--wing-overlay); + --color-wing-card-alpha: var(--wing-card-alpha); + --color-wing-subtle: var(--wing-subtle); + --color-wing-hover: var(--wing-hover); + --color-wing-input-bg: var(--wing-input-bg); + --color-wing-input-border: var(--wing-input-border); + --color-wing-rag-red-bg: var(--wing-rag-red-bg); + --color-wing-rag-red-text: var(--wing-rag-red-text); + --color-wing-rag-amber-bg: var(--wing-rag-amber-bg); + --color-wing-rag-amber-text: var(--wing-rag-amber-text); + --color-wing-rag-green-bg: var(--wing-rag-green-bg); + --color-wing-rag-green-text: var(--wing-rag-green-text); + --font-sans: 'Noto Sans KR', sans-serif; +} diff --git a/frontend/src/utils/cronPreview.ts b/frontend/src/utils/cronPreview.ts new file mode 100644 index 0000000..7ed0e84 --- /dev/null +++ b/frontend/src/utils/cronPreview.ts @@ -0,0 +1,154 @@ +/** + * Quartz 형식 Cron 표현식의 다음 실행 시간을 계산한다. + * 형식: 초 분 시 일 월 요일 + */ +export function getNextExecutions(cron: string, count: number): Date[] { + const parts = cron.trim().split(/\s+/); + if (parts.length < 6) return []; + + const [secField, minField, hourField, dayField, monthField, dowField] = parts; + + if (hasUnsupportedToken(dayField) || hasUnsupportedToken(dowField)) { + return []; + } + + const seconds = parseField(secField, 0, 59); + const minutes = parseField(minField, 0, 59); + const hours = parseField(hourField, 0, 23); + const daysOfMonth = parseField(dayField, 1, 31); + const months = parseField(monthField, 1, 12); + const daysOfWeek = parseDowField(dowField); + + if (!seconds || !minutes || !hours || !months) return []; + + const results: Date[] = []; + const now = new Date(); + const cursor = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), now.getMinutes(), now.getSeconds() + 1); + cursor.setMilliseconds(0); + + const limit = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000); + + while (results.length < count && cursor.getTime() <= limit.getTime()) { + const month = cursor.getMonth() + 1; + if (!months.includes(month)) { + cursor.setMonth(cursor.getMonth() + 1, 1); + cursor.setHours(0, 0, 0, 0); + continue; + } + + const day = cursor.getDate(); + const dayMatches = daysOfMonth ? daysOfMonth.includes(day) : true; + const dowMatches = daysOfWeek ? daysOfWeek.includes(cursor.getDay()) : true; + + const needDayCheck = dayField !== '?' && dowField !== '?'; + const dayOk = needDayCheck ? dayMatches && dowMatches : dayMatches && dowMatches; + + if (!dayOk) { + cursor.setDate(cursor.getDate() + 1); + cursor.setHours(0, 0, 0, 0); + continue; + } + + const hour = cursor.getHours(); + if (!hours.includes(hour)) { + cursor.setHours(cursor.getHours() + 1, 0, 0, 0); + continue; + } + + const minute = cursor.getMinutes(); + if (!minutes.includes(minute)) { + cursor.setMinutes(cursor.getMinutes() + 1, 0, 0); + continue; + } + + const second = cursor.getSeconds(); + if (!seconds.includes(second)) { + cursor.setSeconds(cursor.getSeconds() + 1, 0); + continue; + } + + results.push(new Date(cursor)); + cursor.setSeconds(cursor.getSeconds() + 1); + } + + return results; +} + +function hasUnsupportedToken(field: string): boolean { + return /[LW#]/.test(field); +} + +function parseField(field: string, min: number, max: number): number[] | null { + if (field === '?') return null; + if (field === '*') return range(min, max); + + const values = new Set(); + + for (const part of field.split(',')) { + const stepMatch = part.match(/^(.+)\/(\d+)$/); + if (stepMatch) { + const [, base, stepStr] = stepMatch; + const step = parseInt(stepStr, 10); + if (step <= 0) return range(min, max); + let start = min; + let end = max; + + if (base === '*') { + start = min; + } else if (base.includes('-')) { + const [lo, hi] = base.split('-').map(Number); + start = lo; + end = hi; + } else { + start = parseInt(base, 10); + } + + for (let v = start; v <= end; v += step) { + if (v >= min && v <= max) values.add(v); + } + continue; + } + + const rangeMatch = part.match(/^(\d+)-(\d+)$/); + if (rangeMatch) { + const lo = parseInt(rangeMatch[1], 10); + const hi = parseInt(rangeMatch[2], 10); + for (let v = lo; v <= hi; v++) { + if (v >= min && v <= max) values.add(v); + } + continue; + } + + const num = parseInt(part, 10); + if (!isNaN(num) && num >= min && num <= max) { + values.add(num); + } + } + + return values.size > 0 ? Array.from(values).sort((a, b) => a - b) : range(min, max); +} + +function parseDowField(field: string): number[] | null { + if (field === '?' || field === '*') return null; + + const dayMap: Record = { + SUN: '0', MON: '1', TUE: '2', WED: '3', THU: '4', FRI: '5', SAT: '6', + }; + + let normalized = field.toUpperCase(); + for (const [name, num] of Object.entries(dayMap)) { + normalized = normalized.replace(new RegExp(name, 'g'), num); + } + + // Quartz uses 1=SUN..7=SAT, convert to JS 0=SUN..6=SAT + const parsed = parseField(normalized, 1, 7); + if (!parsed) return null; + + return parsed.map((v) => v - 1); +} + +function range(min: number, max: number): number[] { + const result: number[] = []; + for (let i = min; i <= max; i++) result.push(i); + return result; +} diff --git a/frontend/src/utils/formatters.ts b/frontend/src/utils/formatters.ts new file mode 100644 index 0000000..c1cda20 --- /dev/null +++ b/frontend/src/utils/formatters.ts @@ -0,0 +1,58 @@ +export function formatDateTime(dateTimeStr: string | null | undefined): string { + if (!dateTimeStr) return '-'; + try { + const date = new Date(dateTimeStr); + if (isNaN(date.getTime())) return '-'; + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + const h = String(date.getHours()).padStart(2, '0'); + const min = String(date.getMinutes()).padStart(2, '0'); + const s = String(date.getSeconds()).padStart(2, '0'); + return `${y}-${m}-${d} ${h}:${min}:${s}`; + } catch { + return '-'; + } +} + +export function formatDateTimeShort(dateTimeStr: string | null | undefined): string { + if (!dateTimeStr) return '-'; + try { + const date = new Date(dateTimeStr); + if (isNaN(date.getTime())) return '-'; + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + const h = String(date.getHours()).padStart(2, '0'); + const min = String(date.getMinutes()).padStart(2, '0'); + return `${m}/${d} ${h}:${min}`; + } catch { + return '-'; + } +} + +export function formatDuration(ms: number | null | undefined): string { + if (ms == null || ms < 0) return '-'; + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) return `${hours}시간 ${minutes}분 ${seconds}초`; + if (minutes > 0) return `${minutes}분 ${seconds}초`; + return `${seconds}초`; +} + +export function calculateDuration( + startTime: string | null | undefined, + endTime: string | null | undefined, +): string { + if (!startTime) return '-'; + const start = new Date(startTime).getTime(); + if (isNaN(start)) return '-'; + + if (!endTime) return '실행 중...'; + const end = new Date(endTime).getTime(); + if (isNaN(end)) return '-'; + + return formatDuration(end - start); +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..a9b5a59 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..8a67f62 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..4a83652 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + plugins: [react(), tailwindcss()], + server: { + port: 5173, + proxy: { + '/snp-collector/api': { + target: 'http://localhost:8041', + changeOrigin: true, + }, + }, + }, + base: '/snp-collector/', + build: { + outDir: '../src/main/resources/static', + emptyOutDir: true, + }, +}) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..55ae0c7 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "snp-batch-validation", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..2cb816f --- /dev/null +++ b/pom.xml @@ -0,0 +1,206 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.1 + + + + com.snp + snp-collector + 1.0.0 + SNP Collector + S&P Collector - 해양 데이터 수집 배치 시스템 + + + 17 + UTF-8 + 17 + 17 + + + 3.2.1 + 5.1.0 + 42.7.6 + 1.18.30 + 2.5.0 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-batch + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.postgresql + postgresql + ${postgresql.version} + + + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + + + org.springframework.boot + spring-boot-starter-quartz + + + + + com.fasterxml.jackson.core + jackson-databind + + + + + org.projectlombok + lombok + ${lombok.version} + true + + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + org.springframework.boot + spring-boot-starter-webflux + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.3.0 + + + + + + com.github.ben-manes.caffeine + caffeine + 3.1.8 + + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.batch + spring-batch-test + test + + + + com.google.code.findbugs + jsr305 + 3.0.2 + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + + + org.projectlombok + lombok + + + + + + com.github.eirslett + frontend-maven-plugin + 1.15.1 + + frontend + v20.19.0 + + + + install-node-and-npm + install-node-and-npm + + + npm-install + npm + + install + + + + npm-build + npm + + run build + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 17 + 17 + UTF-8 + + + org.projectlombok + lombok + ${lombok.version} + + + + + + + diff --git a/src/main/java/com/snp/batch/SnpCollectorApplication.java b/src/main/java/com/snp/batch/SnpCollectorApplication.java new file mode 100644 index 0000000..f4f495f --- /dev/null +++ b/src/main/java/com/snp/batch/SnpCollectorApplication.java @@ -0,0 +1,16 @@ +package com.snp.batch; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@EnableScheduling +@ConfigurationPropertiesScan +public class SnpCollectorApplication { + + public static void main(String[] args) { + SpringApplication.run(SnpCollectorApplication.class, args); + } +} diff --git a/src/main/java/com/snp/batch/api/logging/ApiAccessLoggingFilter.java b/src/main/java/com/snp/batch/api/logging/ApiAccessLoggingFilter.java new file mode 100644 index 0000000..2322337 --- /dev/null +++ b/src/main/java/com/snp/batch/api/logging/ApiAccessLoggingFilter.java @@ -0,0 +1,149 @@ +package com.snp.batch.api.logging; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.ContentCachingRequestWrapper; +import org.springframework.web.util.ContentCachingResponseWrapper; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +/** + * API 요청/응답 로깅 필터 + * + * 로그 파일: logs/api-access.log + * 기록 내용: 요청 IP, HTTP Method, URI, 파라미터, 응답 상태, 처리 시간 + */ +@Slf4j +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class ApiAccessLoggingFilter extends OncePerRequestFilter { + + private static final int MAX_PAYLOAD_LENGTH = 1000; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + // 정적 리소스 및 actuator 제외 + String uri = request.getRequestURI(); + if (shouldSkip(uri)) { + filterChain.doFilter(request, response); + return; + } + + // 요청 래핑 (body 읽기용) + ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request); + ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(response); + + String requestId = UUID.randomUUID().toString().substring(0, 8); + long startTime = System.currentTimeMillis(); + + try { + filterChain.doFilter(requestWrapper, responseWrapper); + } finally { + long duration = System.currentTimeMillis() - startTime; + logRequest(requestId, requestWrapper, responseWrapper, duration); + responseWrapper.copyBodyToResponse(); + } + } + + private boolean shouldSkip(String uri) { + return uri.startsWith("/actuator") + || uri.startsWith("/css") + || uri.startsWith("/js") + || uri.startsWith("/images") + || uri.startsWith("/favicon") + || uri.endsWith(".html") + || uri.endsWith(".css") + || uri.endsWith(".js") + || uri.endsWith(".ico"); + } + + private void logRequest(String requestId, + ContentCachingRequestWrapper request, + ContentCachingResponseWrapper response, + long duration) { + + String clientIp = getClientIp(request); + String method = request.getMethod(); + String uri = request.getRequestURI(); + String queryString = request.getQueryString(); + int status = response.getStatus(); + + StringBuilder logMessage = new StringBuilder(); + logMessage.append(String.format("[%s] %s %s %s", + requestId, clientIp, method, uri)); + + // Query String + if (queryString != null && !queryString.isEmpty()) { + logMessage.append("?").append(truncate(queryString, 200)); + } + + // Request Body (POST/PUT/PATCH) + if (isBodyRequest(method)) { + String body = getRequestBody(request); + if (!body.isEmpty()) { + logMessage.append(" | body=").append(truncate(body, MAX_PAYLOAD_LENGTH)); + } + } + + // Response + logMessage.append(String.format(" | status=%d | %dms", status, duration)); + + // 상태에 따른 로그 레벨 + if (status >= 500) { + log.error(logMessage.toString()); + } else if (status >= 400) { + log.warn(logMessage.toString()); + } else { + log.info(logMessage.toString()); + } + } + + private String getClientIp(HttpServletRequest request) { + String ip = request.getHeader("X-Forwarded-For"); + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("X-Real-IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getRemoteAddr(); + } + // 여러 IP가 있는 경우 첫 번째만 + if (ip != null && ip.contains(",")) { + ip = ip.split(",")[0].trim(); + } + return ip; + } + + private boolean isBodyRequest(String method) { + return "POST".equalsIgnoreCase(method) + || "PUT".equalsIgnoreCase(method) + || "PATCH".equalsIgnoreCase(method); + } + + private String getRequestBody(ContentCachingRequestWrapper request) { + byte[] content = request.getContentAsByteArray(); + if (content.length == 0) { + return ""; + } + return new String(content, StandardCharsets.UTF_8) + .replaceAll("\\s+", " ") + .trim(); + } + + private String truncate(String str, int maxLength) { + if (str == null) return ""; + if (str.length() <= maxLength) return str; + return str.substring(0, maxLength) + "..."; + } +} diff --git a/src/main/java/com/snp/batch/common/batch/config/BaseJobConfig.java b/src/main/java/com/snp/batch/common/batch/config/BaseJobConfig.java new file mode 100644 index 0000000..cb7f2dd --- /dev/null +++ b/src/main/java/com/snp/batch/common/batch/config/BaseJobConfig.java @@ -0,0 +1,138 @@ +package com.snp.batch.common.batch.config; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.transaction.PlatformTransactionManager; + +/** + * Batch Job 설정을 위한 추상 클래스 + * Reader → Processor → Writer 패턴의 표준 Job 구성 제공 + * + * @param 입력 타입 (Reader 출력, Processor 입력) + * @param 출력 타입 (Processor 출력, Writer 입력) + */ +@Slf4j +@RequiredArgsConstructor +public abstract class BaseJobConfig { + + protected final JobRepository jobRepository; + protected final PlatformTransactionManager transactionManager; + + /** + * Job 이름 반환 (하위 클래스에서 구현) + * 예: "shipDataImportJob" + */ + protected abstract String getJobName(); + + /** + * Step 이름 반환 (선택사항, 기본: {jobName}Step) + */ + protected String getStepName() { + return getJobName() + "Step"; + } + + /** + * Reader 생성 (하위 클래스에서 구현) + */ + protected abstract ItemReader createReader(); + + /** + * Processor 생성 (하위 클래스에서 구현) + * 처리 로직이 없는 경우 null 반환 가능 + */ + protected abstract ItemProcessor createProcessor(); + + /** + * Writer 생성 (하위 클래스에서 구현) + */ + protected abstract ItemWriter createWriter(); + + /** + * Chunk 크기 반환 (선택사항, 기본: 100) + */ + protected int getChunkSize() { + return 100; + } + + /** + * Job 시작 전 실행 (선택사항) + * Job Listener 등록 시 사용 + */ + protected void configureJob(JobBuilder jobBuilder) { + // 기본 구현: 아무것도 하지 않음 + // 하위 클래스에서 필요시 오버라이드 + // 예: jobBuilder.listener(jobExecutionListener()) + } + + /** + * Step 커스터마이징 (선택사항) + * Step Listener, FaultTolerant 등 설정 시 사용 + */ + protected void configureStep(StepBuilder stepBuilder) { + // 기본 구현: 아무것도 하지 않음 + // 하위 클래스에서 필요시 오버라이드 + // 예: stepBuilder.listener(stepExecutionListener()) + // stepBuilder.faultTolerant().skip(Exception.class).skipLimit(10) + } + + /** + * Step 생성 (표준 구현 제공) + */ + public Step step() { + log.info("Step 생성: {}", getStepName()); + + ItemProcessor processor = createProcessor(); + StepBuilder stepBuilder = new StepBuilder(getStepName(), jobRepository); + + // Processor가 있는 경우 + if (processor != null) { + var chunkBuilder = stepBuilder + .chunk(getChunkSize(), transactionManager) + .reader(createReader()) + .processor(processor) + .writer(createWriter()); + + // 커스텀 설정 적용 + configureStep(stepBuilder); + + return chunkBuilder.build(); + } + // Processor가 없는 경우 (I == O 타입 가정) + else { + @SuppressWarnings("unchecked") + var chunkBuilder = stepBuilder + .chunk(getChunkSize(), transactionManager) + .reader(createReader()) + .writer((ItemWriter) createWriter()); + + // 커스텀 설정 적용 + configureStep(stepBuilder); + + return chunkBuilder.build(); + } + } + + /** + * Job 생성 (표준 구현 제공) + */ + public Job job() { + log.info("Job 생성: {}", getJobName()); + + JobBuilder jobBuilder = new JobBuilder(getJobName(), jobRepository); + + // 커스텀 설정 적용 + configureJob(jobBuilder); + + return jobBuilder + .start(step()) + .build(); + } +} diff --git a/src/main/java/com/snp/batch/common/batch/config/BaseMultiStepJobConfig.java b/src/main/java/com/snp/batch/common/batch/config/BaseMultiStepJobConfig.java new file mode 100644 index 0000000..2d2053c --- /dev/null +++ b/src/main/java/com/snp/batch/common/batch/config/BaseMultiStepJobConfig.java @@ -0,0 +1,44 @@ +package com.snp.batch.common.batch.config; + +import org.springframework.batch.core.Job; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.transaction.PlatformTransactionManager; + +/** + * 기존 단일 스텝 기능을 유지하면서 멀티 스텝 구성을 지원하는 확장 클래스 + */ +public abstract class BaseMultiStepJobConfig extends BaseJobConfig { + + public BaseMultiStepJobConfig(JobRepository jobRepository, PlatformTransactionManager transactionManager) { + super(jobRepository, transactionManager); + } + + /** + * 하위 클래스에서 멀티 스텝 흐름을 정의합니다. + */ + protected abstract Job createJobFlow(JobBuilder jobBuilder); + + /** + * 부모의 job() 메서드를 오버라이드하여 멀티 스텝 흐름을 태웁니다. + */ + @Override + public Job job() { + JobBuilder jobBuilder = new JobBuilder(getJobName(), jobRepository); + configureJob(jobBuilder); // 기존 리스너 등 설정 유지 + + return createJobFlow(jobBuilder); + } + + // 단일 스텝용 Reader/Processor/Writer는 사용하지 않을 경우 + // 기본적으로 null이나 예외를 던지도록 구현하여 구현 부담을 줄일 수 있습니다. + @Override + protected ItemReader createReader() { return null; } + @Override + protected ItemProcessor createProcessor() { return null; } + @Override + protected ItemWriter createWriter() { return null; } +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/common/batch/config/BasePartitionedJobConfig.java b/src/main/java/com/snp/batch/common/batch/config/BasePartitionedJobConfig.java new file mode 100644 index 0000000..7b5a119 --- /dev/null +++ b/src/main/java/com/snp/batch/common/batch/config/BasePartitionedJobConfig.java @@ -0,0 +1,82 @@ +package com.snp.batch.common.batch.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.flow.FlowExecutionStatus; +import org.springframework.batch.core.job.flow.JobExecutionDecider; +import org.springframework.batch.core.partition.support.Partitioner; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.core.task.TaskExecutor; +import org.springframework.transaction.PlatformTransactionManager; + +/** + * 파티션 기반 병렬 처리 Job 구성을 위한 추상 클래스. + * 키 목록 조회 → 파티션 병렬 처리 → 후처리 패턴의 공통 인프라 제공. + * + * @param 입력 타입 (Reader 출력, Processor 입력) + * @param 출력 타입 (Processor 출력, Writer 입력) + */ +@Slf4j +public abstract class BasePartitionedJobConfig extends BaseMultiStepJobConfig { + + public BasePartitionedJobConfig(JobRepository jobRepository, PlatformTransactionManager transactionManager) { + super(jobRepository, transactionManager); + } + + /** + * 파티션 Step을 생성합니다. + * + * @param stepName 파티션 Step 이름 + * @param workerStepName Worker Step 이름 (partitioner 등록에 사용) + * @param partitioner Partitioner 인스턴스 + * @param workerStep Worker Step 인스턴스 + * @param taskExecutor 병렬 실행용 TaskExecutor + * @param gridSize 파티션 수 + * @return 구성된 파티션 Step + */ + protected Step createPartitionedStep(String stepName, String workerStepName, + Partitioner partitioner, Step workerStep, + TaskExecutor taskExecutor, int gridSize) { + return new StepBuilder(stepName, jobRepository) + .partitioner(workerStepName, partitioner) + .step(workerStep) + .taskExecutor(taskExecutor) + .gridSize(gridSize) + .build(); + } + + /** + * 키 건수 기반 Decider를 생성합니다. + * JobExecutionContext의 지정된 키 값이 0이면 EMPTY_RESPONSE, 아니면 NORMAL 반환. + * + * @param contextKey JobExecutionContext에서 조회할 int 키 이름 + * @param jobName 로그에 표시할 Job 이름 + * @return 키 건수 기반 JobExecutionDecider + */ + protected JobExecutionDecider createKeyCountDecider(String contextKey, String jobName) { + return (jobExecution, stepExecution) -> { + int totalCount = jobExecution.getExecutionContext().getInt(contextKey, 0); + if (totalCount == 0) { + log.info("[{}] Decider: EMPTY_RESPONSE - {} 0건으로 후속 스텝 스킵", jobName, contextKey); + return new FlowExecutionStatus("EMPTY_RESPONSE"); + } + log.info("[{}] Decider: NORMAL - {} {} 건 처리 시작", jobName, contextKey, totalCount); + return new FlowExecutionStatus("NORMAL"); + }; + } + + /** + * LastExecution 업데이트 Step을 생성합니다. + * + * @param stepName Step 이름 + * @param tasklet LastExecutionUpdateTasklet 인스턴스 + * @return 구성된 LastExecution 업데이트 Step + */ + protected Step createLastExecutionUpdateStep(String stepName, Tasklet tasklet) { + return new StepBuilder(stepName, jobRepository) + .tasklet(tasklet, transactionManager) + .build(); + } +} diff --git a/src/main/java/com/snp/batch/common/batch/entity/BaseEntity.java b/src/main/java/com/snp/batch/common/batch/entity/BaseEntity.java new file mode 100644 index 0000000..bedd1ee --- /dev/null +++ b/src/main/java/com/snp/batch/common/batch/entity/BaseEntity.java @@ -0,0 +1,64 @@ +package com.snp.batch.common.batch.entity; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDateTime; + +/** + * 모든 Entity의 공통 베이스 클래스 - JDBC 전용 + * 생성/수정 감사(Audit) 필드 제공 + * + * 이 필드들은 Repository의 Insert/Update 시 자동으로 설정됩니다. + * BaseJdbcRepository가 감사 필드를 자동으로 관리합니다. + */ +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +public abstract class BaseEntity { + + /** + * 생성 일시 + * 컬럼: created_at (TIMESTAMP) + */ + private LocalDateTime createdAt; + + /** + * 수정 일시 + * 컬럼: updated_at (TIMESTAMP) + */ + private LocalDateTime updatedAt; + + /** + * 생성자 + * 컬럼: created_by (VARCHAR(100)) + */ + private String createdBy; + + /** + * 수정자 + * 컬럼: updated_by (VARCHAR(100)) + */ + private String updatedBy; + + /** + * 배치 잡 실행 ID + * 컬럼: job_execution_id (int8) + */ + private Long jobExecutionId; + + /** + * 배치 공통 필드 설정을 위한 편의 메서드 + */ + @SuppressWarnings("unchecked") + public T setBatchInfo(Long jobExecutionId, String createdBy) { + this.jobExecutionId = jobExecutionId; + this.createdBy = createdBy; + // 필요시 생성일시 강제 설정 (JPA Auditing을 안 쓸 경우) + if (this.createdAt == null) this.createdAt = LocalDateTime.now(); + return (T) this; + } +} diff --git a/src/main/java/com/snp/batch/common/batch/listener/AutoRetryJobExecutionListener.java b/src/main/java/com/snp/batch/common/batch/listener/AutoRetryJobExecutionListener.java new file mode 100644 index 0000000..70a1cf6 --- /dev/null +++ b/src/main/java/com/snp/batch/common/batch/listener/AutoRetryJobExecutionListener.java @@ -0,0 +1,113 @@ +package com.snp.batch.common.batch.listener; + +import com.snp.batch.global.repository.BatchFailedRecordRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobExecutionListener; +import org.springframework.batch.core.StepExecution; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * 배치 Job 완료 후 실패 레코드가 있으면 자동으로 재수집을 트리거하는 리스너. + * + * 동작 조건: + * - Job 상태가 COMPLETED일 때만 실행 + * - executionMode가 RECOLLECT가 아닌 일반 모드일 때만 실행 (무한 루프 방지) + * - StepExecution의 ExecutionContext에 failedRecordKeys가 존재할 때만 실행 + * - 모든 Step의 failedRecordKeys를 Job 레벨에서 병합한 후 1회만 triggerRetryAsync 호출 + * - retryCount가 MAX_AUTO_RETRY_COUNT 이상인 키는 재수집에서 제외 (무한 루프 방지) + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AutoRetryJobExecutionListener implements JobExecutionListener { + + private static final String FAILED_RECORD_KEYS = "failedRecordKeys"; + private static final String FAILED_JOB_EXECUTION_ID = "failedJobExecutionId"; + private static final String FAILED_API_KEY = "failedApiKey"; + private static final int MAX_AUTO_RETRY_COUNT = 3; + + private final AutoRetryTriggerService autoRetryTriggerService; + private final BatchFailedRecordRepository batchFailedRecordRepository; + + @Override + public void beforeJob(JobExecution jobExecution) { + // no-op + } + + @Override + public void afterJob(JobExecution jobExecution) { + String executionMode = jobExecution.getJobParameters() + .getString("executionMode", "NORMAL"); + + // 재수집 모드에서는 자동 재수집을 트리거하지 않음 (무한 루프 방지) + if ("RECOLLECT".equals(executionMode)) { + return; + } + + // Job이 정상 완료된 경우에만 재수집 트리거 + if (jobExecution.getStatus() != BatchStatus.COMPLETED) { + return; + } + + String jobName = jobExecution.getJobInstance().getJobName(); + + // 모든 Step의 failedRecordKeys를 Set으로 병합 (중복 제거) + Set mergedKeys = new LinkedHashSet<>(); + Long sourceJobExecutionId = jobExecution.getId(); + String apiKey = null; + + for (StepExecution stepExecution : jobExecution.getStepExecutions()) { + String failedKeys = stepExecution.getExecutionContext() + .getString(FAILED_RECORD_KEYS, null); + + if (failedKeys == null || failedKeys.isBlank()) { + continue; + } + + Arrays.stream(failedKeys.split(",")) + .map(String::trim) + .filter(key -> !key.isBlank()) + .forEach(mergedKeys::add); + + // apiKey: non-null인 것 중 첫 번째 사용 + if (apiKey == null) { + apiKey = stepExecution.getExecutionContext() + .getString(FAILED_API_KEY, null); + } + } + + if (mergedKeys.isEmpty()) { + return; + } + + // retryCount가 MAX_AUTO_RETRY_COUNT 이상인 키 필터링 + List exceededKeys = batchFailedRecordRepository.findExceededRetryKeys( + jobName, List.copyOf(mergedKeys), MAX_AUTO_RETRY_COUNT); + + if (!exceededKeys.isEmpty()) { + log.warn("[AutoRetry] {} Job: 최대 재시도 횟수({})를 초과한 키 {}건 제외: {}", + jobName, MAX_AUTO_RETRY_COUNT, exceededKeys.size(), exceededKeys); + mergedKeys.removeAll(exceededKeys); + } + + if (mergedKeys.isEmpty()) { + log.warn("[AutoRetry] {} Job: 모든 실패 키가 최대 재시도 횟수를 초과하여 재수집을 건너뜁니다.", jobName); + return; + } + + log.info("[AutoRetry] {} Job 완료 후 실패 건 {}건 감지 → 자동 재수집 트리거", + jobName, mergedKeys.size()); + + // sourceJobExecutionId 기반으로 1회만 triggerRetryAsync 호출 (실패 키는 DB에서 직접 조회) + autoRetryTriggerService.triggerRetryAsync( + jobName, mergedKeys.size(), sourceJobExecutionId, apiKey); + } +} diff --git a/src/main/java/com/snp/batch/common/batch/listener/AutoRetryTriggerService.java b/src/main/java/com/snp/batch/common/batch/listener/AutoRetryTriggerService.java new file mode 100644 index 0000000..5485efb --- /dev/null +++ b/src/main/java/com/snp/batch/common/batch/listener/AutoRetryTriggerService.java @@ -0,0 +1,66 @@ +package com.snp.batch.common.batch.listener; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.context.annotation.Lazy; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.util.Map; + +/** + * 자동 재수집 Job 비동기 트리거 서비스. + * JobExecutionListener 내부 self-invocation으로는 @Async 프록시가 동작하지 않으므로 + * 별도 빈으로 분리하여 프록시를 통한 비동기 호출을 보장합니다. + */ +@Slf4j +@Service +public class AutoRetryTriggerService { + + private final JobLauncher jobLauncher; + private final Map jobMap; + + public AutoRetryTriggerService(JobLauncher jobLauncher, @Lazy Map jobMap) { + this.jobLauncher = jobLauncher; + this.jobMap = jobMap; + } + + @Async("autoRetryExecutor") + public void triggerRetryAsync(String jobName, int failedCount, + Long sourceJobExecutionId, String apiKey) { + try { + Job job = jobMap.get(jobName); + if (job == null) { + log.error("[AutoRetry] Job을 찾을 수 없습니다: {}", jobName); + return; + } + + JobParametersBuilder builder = new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis()) + .addString("sourceJobExecutionId", String.valueOf(sourceJobExecutionId)) + .addString("executionMode", "RECOLLECT") + .addString("reason", "자동 재수집 (실패 건 자동 처리)") + .addString("executor", "AUTO_RETRY"); + + if (apiKey != null) { + builder.addString("apiKey", apiKey); + } + + JobParameters retryParams = builder.toJobParameters(); + + log.info("[AutoRetry] 재수집 Job 실행 시작: jobName={}, 실패건={}, sourceJobExecutionId={}", + jobName, failedCount, sourceJobExecutionId); + + JobExecution retryExecution = jobLauncher.run(job, retryParams); + + log.info("[AutoRetry] 재수집 Job 실행 완료: jobName={}, executionId={}, status={}", + jobName, retryExecution.getId(), retryExecution.getStatus()); + } catch (Exception e) { + log.error("[AutoRetry] 재수집 Job 실행 실패: jobName={}, error={}", jobName, e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/snp/batch/common/batch/listener/RecollectionJobExecutionListener.java b/src/main/java/com/snp/batch/common/batch/listener/RecollectionJobExecutionListener.java new file mode 100644 index 0000000..814ccf7 --- /dev/null +++ b/src/main/java/com/snp/batch/common/batch/listener/RecollectionJobExecutionListener.java @@ -0,0 +1,120 @@ +package com.snp.batch.common.batch.listener; + +import com.snp.batch.service.RecollectionHistoryService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobExecutionListener; +import org.springframework.batch.core.StepExecution; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RecollectionJobExecutionListener implements JobExecutionListener { + + private final RecollectionHistoryService recollectionHistoryService; + + @Override + public void beforeJob(JobExecution jobExecution) { + String executionMode = jobExecution.getJobParameters() + .getString("executionMode", "NORMAL"); + + if (!"RECOLLECT".equals(executionMode)) { + return; + } + + Long jobExecutionId = jobExecution.getId(); + String jobName = jobExecution.getJobInstance().getJobName(); + String apiKey = resolveApiKey(jobExecution); + String executor = jobExecution.getJobParameters().getString("executor", "SYSTEM"); + String reason = jobExecution.getJobParameters().getString("reason"); + + try { + // 재수집 이력 기록 + recollectionHistoryService.recordStart( + jobName, jobExecutionId, apiKey, executor, reason); + } catch (Exception e) { + log.error("[RecollectionListener] beforeJob 처리 실패: jobExecutionId={}", jobExecutionId, e); + } + } + + @Override + public void afterJob(JobExecution jobExecution) { + String executionMode = jobExecution.getJobParameters() + .getString("executionMode", "NORMAL"); + + if (!"RECOLLECT".equals(executionMode)) { + return; + } + + Long jobExecutionId = jobExecution.getId(); + String status = jobExecution.getStatus().name(); + + // Step별 통계 집계 + long totalRead = 0; + long totalWrite = 0; + long totalSkip = 0; + int totalApiCalls = 0; + + for (StepExecution step : jobExecution.getStepExecutions()) { + totalRead += step.getReadCount(); + totalWrite += step.getWriteCount(); + totalSkip += step.getReadSkipCount() + + step.getProcessSkipCount() + + step.getWriteSkipCount(); + + if (step.getExecutionContext().containsKey("totalApiCalls")) { + totalApiCalls += step.getExecutionContext().getInt("totalApiCalls", 0); + } + } + + // 실패 사유 추출 + String failureReason = null; + if ("FAILED".equals(status)) { + failureReason = jobExecution.getExitStatus().getExitDescription(); + if (failureReason == null || failureReason.isEmpty()) { + failureReason = jobExecution.getStepExecutions().stream() + .filter(s -> "FAILED".equals(s.getStatus().name())) + .map(s -> s.getExitStatus().getExitDescription()) + .filter(desc -> desc != null && !desc.isEmpty()) + .findFirst() + .orElse("Unknown error"); + } + if (failureReason != null && failureReason.length() > 2000) { + failureReason = failureReason.substring(0, 2000) + "..."; + } + } + + // 재수집 이력 완료 기록 + try { + recollectionHistoryService.recordCompletion( + jobExecutionId, status, + totalRead, totalWrite, totalSkip, + totalApiCalls, null, + failureReason); + } catch (Exception e) { + log.error("[RecollectionListener] 재수집 이력 완료 기록 실패: jobExecutionId={}", jobExecutionId, e); + } + } + + /** + * Job 파라미터에서 apiKey를 읽고, 없으면 jobName으로 BatchCollectionPeriod에서 조회합니다. + * 수동 재수집(UI 실패건 재수집)에서는 apiKey가 파라미터로 전달되지 않을 수 있으므로 + * jobName → apiKey 매핑을 fallback으로 사용합니다. + */ + private String resolveApiKey(JobExecution jobExecution) { + String apiKey = jobExecution.getJobParameters().getString("apiKey"); + if (apiKey != null) { + return apiKey; + } + + // fallback: jobName으로 BatchCollectionPeriod에서 apiKey 조회 + String jobName = jobExecution.getJobInstance().getJobName(); + apiKey = recollectionHistoryService.findApiKeyByJobName(jobName); + if (apiKey != null) { + log.info("[RecollectionListener] apiKey를 jobName에서 조회: jobName={}, apiKey={}", jobName, apiKey); + } + return apiKey; + } +} diff --git a/src/main/java/com/snp/batch/common/batch/partition/StringListPartitioner.java b/src/main/java/com/snp/batch/common/batch/partition/StringListPartitioner.java new file mode 100644 index 0000000..7a99493 --- /dev/null +++ b/src/main/java/com/snp/batch/common/batch/partition/StringListPartitioner.java @@ -0,0 +1,61 @@ +package com.snp.batch.common.batch.partition; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.partition.support.Partitioner; +import org.springframework.batch.item.ExecutionContext; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 문자열 키 목록을 N개 파티션으로 균등 분할하는 범용 Partitioner. + * + *

각 파티션의 ExecutionContext에 다음 값을 저장한다.

+ *
    + *
  • {@code {contextKeyName}} — 해당 파티션에 할당된 키 목록 (CSV 형식)
  • + *
  • {@code partitionIndex} — 파티션 인덱스 (0-based)
  • + *
  • {@code partitionSize} — 해당 파티션의 키 수
  • + *
+ */ +@Slf4j +public class StringListPartitioner implements Partitioner { + + private final List allKeys; + private final int partitionCount; + private final String contextKeyName; + + public StringListPartitioner(List allKeys, int partitionCount, String contextKeyName) { + this.allKeys = allKeys; + this.partitionCount = partitionCount; + this.contextKeyName = contextKeyName; + } + + @Override + public Map partition(int gridSize) { + int totalSize = allKeys.size(); + int actualPartitionCount = Math.min(partitionCount, Math.max(1, totalSize)); + Map partitions = new LinkedHashMap<>(); + int partitionSize = (int) Math.ceil((double) totalSize / actualPartitionCount); + + for (int i = 0; i < actualPartitionCount; i++) { + int fromIndex = i * partitionSize; + int toIndex = Math.min(fromIndex + partitionSize, totalSize); + if (fromIndex >= totalSize) break; + + List partitionKeys = allKeys.subList(fromIndex, toIndex); + ExecutionContext context = new ExecutionContext(); + context.putString(contextKeyName, String.join(",", partitionKeys)); + context.putInt("partitionIndex", i); + context.putInt("partitionSize", partitionKeys.size()); + + String partitionKey = "partition" + i; + partitions.put(partitionKey, context); + log.info("[StringListPartitioner] {} : 키 {} 건 (index {}-{})", + partitionKey, partitionKeys.size(), fromIndex, toIndex - 1); + } + log.info("[StringListPartitioner] 총 {} 개 파티션 생성 (전체 키: {} 건)", + partitions.size(), totalSize); + return partitions; + } +} diff --git a/src/main/java/com/snp/batch/common/batch/processor/BaseProcessor.java b/src/main/java/com/snp/batch/common/batch/processor/BaseProcessor.java new file mode 100644 index 0000000..a9ad40c --- /dev/null +++ b/src/main/java/com/snp/batch/common/batch/processor/BaseProcessor.java @@ -0,0 +1,61 @@ +package com.snp.batch.common.batch.processor; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.ItemProcessor; + +/** + * ItemProcessor 추상 클래스 (v2.0) + * 데이터 변환 및 처리 로직을 위한 템플릿 제공 + * + * Template Method Pattern: + * - process(): 공통 로직 (null 체크, 로깅) + * - processItem(): 하위 클래스에서 변환 로직 구현 + * + * 기본 용도: + * - 단순 변환: DTO → Entity + * - 데이터 필터링: null 반환 시 해당 아이템 스킵 + * - 데이터 검증: 유효하지 않은 데이터 필터링 + * + * 고급 용도 (다중 depth JSON 처리): + * - 중첩된 JSON을 여러 Entity로 분해 + * - 1:N 관계 처리 (Order → OrderItems) + * - CompositeWriter와 조합하여 여러 테이블에 저장 + * + * 예제: + * - 단순 변환: ProductDataProcessor (DTO → Entity) + * - 복잡한 처리: 복잡한 JSON 처리 예제 참고 + * + * @param 입력 DTO 타입 + * @param 출력 Entity 타입 + */ +@Slf4j +public abstract class BaseProcessor implements ItemProcessor { + + /** + * 데이터 변환 로직 (하위 클래스에서 구현) + * DTO → Entity 변환 등의 비즈니스 로직 구현 + * + * @param item 입력 DTO + * @return 변환된 Entity (필터링 시 null 반환 가능) + * @throws Exception 처리 중 오류 발생 시 + */ + protected abstract O processItem(I item) throws Exception; + + /** + * Spring Batch ItemProcessor 인터페이스 구현 + * 데이터 변환 및 필터링 수행 + * + * @param item 입력 DTO + * @return 변환된 Entity (null이면 해당 아이템 스킵) + * @throws Exception 처리 중 오류 발생 시 + */ + @Override + public O process(I item) throws Exception { + if (item == null) { + return null; + } + +// log.debug("데이터 처리 중: {}", item); + return processItem(item); + } +} diff --git a/src/main/java/com/snp/batch/common/batch/reader/BaseApiReader.java b/src/main/java/com/snp/batch/common/batch/reader/BaseApiReader.java new file mode 100644 index 0000000..e3b64d2 --- /dev/null +++ b/src/main/java/com/snp/batch/common/batch/reader/BaseApiReader.java @@ -0,0 +1,1001 @@ +package com.snp.batch.common.batch.reader; + +import com.snp.batch.global.model.BatchApiLog; +import com.snp.batch.service.BatchApiLogService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.annotation.BeforeStep; +import org.springframework.batch.item.ExecutionContext; +import org.springframework.batch.item.ItemReader; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import org.springframework.web.util.UriBuilder; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +/** + * REST API 기반 ItemReader 추상 클래스 (v3.0 - Chunk 기반) + * + * 주요 기능: + * - HTTP Method 지원: GET, POST + * - 다중 Query Parameter 처리 + * - Path Variable 지원 + * - Request Body 지원 (POST) + * - 동적 Header 설정 + * - 복잡한 JSON 응답 파싱 + * - ✨ Chunk 기반 배치 처리 (Iterator 패턴) + * + * Template Method Pattern: + * - read(): 공통 로직 (1건씩 순차 반환) + * - fetchNextBatch(): 다음 배치 조회 (구현체에서 오버라이드) + * - 새로운 훅 메서드들: HTTP Method, 파라미터, 헤더 등 + * + * 동작 방식: + * 1. read() 호출 시 currentBatch가 비어있으면 fetchNextBatch() 호출 + * 2. fetchNextBatch()가 100건 반환 + * 3. read()가 100번 호출되면서 1건씩 반환 + * 4. 100건 모두 반환되면 다시 fetchNextBatch() 호출 + * 5. fetchNextBatch()가 null/empty 반환 시 Job 종료 + * + * 하위 호환성: + * - 기존 fetchDataFromApi() 메서드 계속 지원 + * - 새로운 fetchNextBatch() 메서드 사용 권장 + * + * @param DTO 타입 (API 응답 데이터) + */ +@Slf4j +public abstract class BaseApiReader implements ItemReader { + + // Chunk 기반 Iterator 패턴 + private java.util.Iterator currentBatch; + private boolean initialized = false; + private boolean useChunkMode = false; // Chunk 모드 사용 여부 + + // 하위 호환성을 위한 필드 (fetchDataFromApi 사용 시) + private List legacyDataList; + private int legacyNextIndex = 0; + + // WebClient는 하위 클래스에서 주입받아 사용 + protected WebClient webClient; + + // StepExecution - API 정보 저장용 + protected StepExecution stepExecution; + + // API 호출 통계 + private int totalApiCalls = 0; + private int completedApiCalls = 0; + + // Batch Execution Id + private Long jobExecutionId; // 현재 Job 실행 ID + private Long stepExecutionId; // 현재 Step 실행 ID + /** + * 스프링 배치가 Step을 시작할 때 실행 ID를 주입해줍니다. + */ + public void setExecutionIds(Long jobExecutionId, Long stepExecutionId) { + this.jobExecutionId = jobExecutionId; + this.stepExecutionId = stepExecutionId; + } + + protected Long getJobExecutionId() { + return this.jobExecutionId; + } + + protected Long getStepExecutionId() { + return this.stepExecutionId; + } + /** + * 기본 생성자 (WebClient 없이 사용 - Mock 데이터용) + */ + protected BaseApiReader() { + this.webClient = null; + } + /** + * API 호출 및 로그 적재 통합 메서드 + * Response Json 구조 : [...] + */ + protected List executeListApiCall( + String baseUrl, + String path, + Map params, + ParameterizedTypeReference> typeReference, + BatchApiLogService logService) { + + // 1. 전체 URI 생성 (로그용) + MultiValueMap multiValueParams = new LinkedMultiValueMap<>(); + if (params != null) { + params.forEach((key, value) -> + multiValueParams.put(key, Collections.singletonList(value)) + ); + } + + String fullUri = UriComponentsBuilder.fromHttpUrl(baseUrl) + .path(path) + .queryParams(multiValueParams) + .build() + .toUriString(); + + long startTime = System.currentTimeMillis(); + int statusCode = 200; + String errorMessage = null; + Long responseSize = 0L; + + try { + log.info("[{}] API 요청 시작: {}", getReaderName(), fullUri); + + List result = webClient.get() + .uri(uriBuilder -> { + uriBuilder.path(path); + if (params != null) params.forEach(uriBuilder::queryParam); + return uriBuilder.build(); + }) + .retrieve() + .bodyToMono(typeReference) + .block(); + + responseSize = (result != null) ? (long) result.size() : 0L; + return result; + + } catch (WebClientResponseException e) { + // API 서버에서 응답은 왔으나 에러인 경우 (4xx, 5xx) + statusCode = e.getStatusCode().value(); + errorMessage = String.format("API Error: %s", e.getResponseBodyAsString()); + throw e; + } catch (Exception e) { + // 네트워크 오류, 타임아웃 등 기타 예외 + statusCode = 500; + errorMessage = String.format("System Error: %s", e.getMessage()); + throw e; + } finally { + // 성공/실패 여부와 관계없이 무조건 로그 저장 + long duration = System.currentTimeMillis() - startTime; + + logService.saveLog(BatchApiLog.builder() + .apiRequestLocation(getReaderName()) + .requestUri(fullUri) + .httpMethod("GET") + .statusCode(statusCode) + .responseTimeMs(duration) + .responseCount(responseSize) + .errorMessage(errorMessage) + .createdAt(LocalDateTime.now()) + .jobExecutionId(this.jobExecutionId) // 추가 + .stepExecutionId(this.stepExecutionId) // 추가 + .build()); + } + } + + protected List executeWrapperApiCall( + String baseUrl, + String path, + Class responseWrapperClass, // Stat5CodeApiResponse.class 등을 받음 + Function> listExtractor, // 결과 객체에서 리스트를 꺼내는 로직 + BatchApiLogService logService) { + + String fullUri = UriComponentsBuilder.fromHttpUrl(baseUrl) + .path(path) + .build() + .toUriString(); + + long startTime = System.currentTimeMillis(); + int statusCode = 200; + String errorMessage = null; + Long responseSize = 0L; + + try { + log.info("[{}] API 요청 시작: {}", getReaderName(), fullUri); + + // 1. List이 아닌 Wrapper 객체(T)로 받아옵니다. + T response = webClient.get() + .uri(uriBuilder -> uriBuilder.path(path).build()) + .retrieve() + .bodyToMono(responseWrapperClass) + .block(); + + // 2. 추출 함수(listExtractor)를 사용하여 내부 리스트를 꺼냅니다. + List result = (response != null) ? listExtractor.apply(response) : Collections.emptyList(); + + responseSize = (long) result.size(); + return result; + + } catch (WebClientResponseException e) { + statusCode = e.getStatusCode().value(); + errorMessage = String.format("API Error: %s", e.getResponseBodyAsString()); + throw e; + } catch (Exception e) { + statusCode = 500; + errorMessage = String.format("System Error: %s", e.getMessage()); + throw e; + } finally { + long duration = System.currentTimeMillis() - startTime; + + logService.saveLog(BatchApiLog.builder() + .apiRequestLocation(getReaderName()) + .requestUri(fullUri) + .httpMethod("GET") + .statusCode(statusCode) + .responseTimeMs(duration) + .responseCount(responseSize) + .errorMessage(errorMessage) + .createdAt(LocalDateTime.now()) + .jobExecutionId(this.jobExecutionId) + .stepExecutionId(this.stepExecutionId) + .build()); + } + } + + protected List executeListApiCall( + String baseUrl, + String path, + ParameterizedTypeReference> typeReference, + BatchApiLogService logService) { + + String fullUri = UriComponentsBuilder.fromHttpUrl(baseUrl) + .path(path) + .build() + .toUriString(); + + long startTime = System.currentTimeMillis(); + int statusCode = 200; + String errorMessage = null; + Long responseSize = 0L; + + try { + log.info("[{}] API 요청 시작: {}", getReaderName(), fullUri); + + List result = webClient.get() + .uri(uriBuilder -> { + uriBuilder.path(path); + return uriBuilder.build(); + }) + .retrieve() + .bodyToMono(typeReference) + .block(); + + responseSize = (result != null) ? (long) result.size() : 0L; + return result; + + } catch (WebClientResponseException e) { + // API 서버에서 응답은 왔으나 에러인 경우 (4xx, 5xx) + statusCode = e.getStatusCode().value(); + errorMessage = String.format("API Error: %s", e.getResponseBodyAsString()); + throw e; + } catch (Exception e) { + // 네트워크 오류, 타임아웃 등 기타 예외 + statusCode = 500; + errorMessage = String.format("System Error: %s", e.getMessage()); + throw e; + } finally { + // 성공/실패 여부와 관계없이 무조건 로그 저장 + long duration = System.currentTimeMillis() - startTime; + + logService.saveLog(BatchApiLog.builder() + .apiRequestLocation(getReaderName()) + .requestUri(fullUri) + .httpMethod("GET") + .statusCode(statusCode) + .responseTimeMs(duration) + .responseCount(responseSize) + .errorMessage(errorMessage) + .createdAt(LocalDateTime.now()) + .jobExecutionId(this.jobExecutionId) // 추가 + .stepExecutionId(this.stepExecutionId) // 추가 + .build()); + } + } + + /** + * API 호출 및 로그 적재 통합 메서드 + * Response Json 구조 : { "data": [...] } + */ + protected R executeSingleApiCall( + String baseUrl, + String path, + Map params, + ParameterizedTypeReference typeReference, + BatchApiLogService logService, + Function sizeExtractor) { // 사이즈 추출 함수 추가 + + // 1. 전체 URI 생성 (로그용) + MultiValueMap multiValueParams = new LinkedMultiValueMap<>(); + if (params != null) { + params.forEach((key, value) -> + multiValueParams.put(key, Collections.singletonList(value)) + ); + } + + String fullUri = UriComponentsBuilder.fromHttpUrl(baseUrl) + .path(path) + .queryParams(multiValueParams) + .build() + .toUriString(); + + long startTime = System.currentTimeMillis(); + int statusCode = 200; + String errorMessage = null; + R result = null; + + try { + log.info("[{}] Single API 요청 시작: {}", getReaderName(), fullUri); + + result = webClient.get() + .uri(uriBuilder -> { + uriBuilder.path(path); + if (params != null) params.forEach(uriBuilder::queryParam); + return uriBuilder.build(); + }) + .retrieve() + .bodyToMono(typeReference) + .block(); + + return result; + + } catch (WebClientResponseException e) { + statusCode = e.getStatusCode().value(); + errorMessage = String.format("API Error: %s", e.getResponseBodyAsString()); + throw e; + } catch (Exception e) { + statusCode = 500; + errorMessage = String.format("System Error: %s", e.getMessage()); + throw e; + } finally { + long duration = System.currentTimeMillis() - startTime; + + // 2. 주입받은 함수를 통해 데이터 건수(size) 계산 + long size = 0L; + if (result != null && sizeExtractor != null) { + try { + size = sizeExtractor.apply(result); + } catch (Exception e) { + log.warn("[{}] 사이즈 추출 중 오류 발생: {}", getReaderName(), e.getMessage()); + } + } + + // 3. 로그 저장 (api_request_location, response_size 반영) + logService.saveLog(BatchApiLog.builder() + .apiRequestLocation(getReaderName()) + .jobExecutionId(this.jobExecutionId) + .stepExecutionId(this.stepExecutionId) + .requestUri(fullUri) + .httpMethod("GET") + .statusCode(statusCode) + .responseTimeMs(duration) + .responseCount(size) + .errorMessage(errorMessage) + .createdAt(LocalDateTime.now()) + .build()); + } + } + + + /** + * WebClient를 주입받는 생성자 (실제 API 연동용) + * + * @param webClient Spring WebClient 인스턴스 + */ + protected BaseApiReader(WebClient webClient) { + this.webClient = webClient; + } + /** + /** + * Step 실행 전 초기화 및 API 정보 저장 + * Spring Batch가 자동으로 StepExecution을 주입하고 이 메서드를 호출함 + * + * @param stepExecution Step 실행 정보 + */ + @BeforeStep + public void saveApiInfoToContext(StepExecution stepExecution) { + this.stepExecution = stepExecution; + + // Reader 상태 초기화 (Job 재실행 시 필수) + resetReaderState(); + + // API 정보를 StepExecutionContext에 저장 + ExecutionContext context = stepExecution.getExecutionContext(); + + // WebClient가 있는 경우에만 API 정보 저장 + if (webClient != null) { + // 1. API URL 저장 + String baseUrl = getApiBaseUrl(); + String apiPath = getApiPath(); + String fullUrl = baseUrl != null ? baseUrl + apiPath : apiPath; + context.putString("apiUrl", fullUrl); + + // 2. HTTP Method 저장 + context.putString("apiMethod", getHttpMethod()); + + // 3. API Parameters 저장 + Map params = new HashMap<>(); + Map queryParams = getQueryParams(); + if (queryParams != null && !queryParams.isEmpty()) { + params.putAll(queryParams); + } + Map pathVars = getPathVariables(); + if (pathVars != null && !pathVars.isEmpty()) { + params.putAll(pathVars); + } + context.put("apiParameters", params); + + // 4. 통계 초기화 + context.putInt("totalApiCalls", 0); + context.putInt("completedApiCalls", 0); + + log.info("[{}] API 정보 저장: {} {}", getReaderName(), getHttpMethod(), fullUrl); + } + } + + /** + * API Base URL 반환 (WebClient의 baseUrl) + * 하위 클래스에서 필요 시 오버라이드 + */ + protected String getApiBaseUrl() { + return ""; + } + + /** + * Reader 상태 초기화 + * Job 재실행 시 이전 실행의 상태를 클리어하여 새로 데이터를 읽을 수 있도록 함 + */ + private void resetReaderState() { + // Chunk 모드 상태 초기화 + this.currentBatch = null; + this.initialized = false; + + // Legacy 모드 상태 초기화 + this.legacyDataList = null; + this.legacyNextIndex = 0; + + // 통계 초기화 + this.totalApiCalls = 0; + this.completedApiCalls = 0; + + // 하위 클래스 상태 초기화 훅 호출 + resetCustomState(); + + log.debug("[{}] Reader 상태 초기화 완료", getReaderName()); + } + + /** + * 하위 클래스 커스텀 상태 초기화 훅 + * Chunk 모드에서 사용하는 currentBatchIndex, allImoNumbers 등의 필드를 초기화할 때 오버라이드 + * + * 예시: + *
+     * @Override
+     * protected void resetCustomState() {
+     *     this.currentBatchIndex = 0;
+     *     this.allImoNumbers = null;
+     *     this.dbMasterHashes = null;
+     * }
+     * 
+ */ + protected void resetCustomState() { + // 기본 구현: 아무것도 하지 않음 + // 하위 클래스에서 필요 시 오버라이드 + } + + /** + * API 호출 통계 업데이트 + */ + protected void updateApiCallStats(int totalCalls, int completedCalls) { + if (stepExecution != null) { + ExecutionContext context = stepExecution.getExecutionContext(); + context.putInt("totalApiCalls", totalCalls); + context.putInt("completedApiCalls", completedCalls); + + // 마지막 호출 시간 저장 + String lastCallTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + context.putString("lastCallTime", lastCallTime); + + this.totalApiCalls = totalCalls; + this.completedApiCalls = completedCalls; + } + } + + // ======================================== + // ItemReader 구현 (공통 로직) + // ======================================== + + /** + * Spring Batch ItemReader 인터페이스 구현 + * 데이터를 순차적으로 하나씩 반환 + * + * Chunk 기반 동작: + * 1. currentBatch가 비어있으면 fetchNextBatch() 호출하여 다음 배치 로드 + * 2. Iterator에서 1건씩 반환 + * 3. Iterator가 비면 다시 1번으로 + * 4. fetchNextBatch()가 null/empty 반환하면 Job 종료 + * + * @return 다음 데이터 항목 (더 이상 없으면 null) + */ + @Override + public T read() throws Exception { + // Chunk 모드 사용 여부는 첫 호출 시 결정 + if (!initialized && !useChunkMode) { + // Legacy 모드로 시작 + return readLegacyMode(); + } + + // Chunk 모드가 활성화된 경우 + if (useChunkMode) { + return readChunkMode(); + } + + // Legacy 모드 + return readLegacyMode(); + } + + /** + * Chunk 모드 활성화 (하위 클래스에서 명시적 호출) + */ + protected void enableChunkMode() { + this.useChunkMode = true; + } + + /** + * Chunk 기반 read() 구현 (신규 방식) + */ + private T readChunkMode() throws Exception { + // 최초 호출 시 초기화 + if (!initialized) { + beforeFetch(); + initialized = true; + } + + // currentBatch가 비어있으면 다음 배치 로드 + /*if (currentBatch == null || !currentBatch.hasNext()) { + List nextBatch = fetchNextBatch(); + + // 더 이상 데이터가 없으면 종료 +// if (nextBatch == null || nextBatch.isEmpty()) { + if (nextBatch == null ) { + afterFetch(null); + log.info("[{}] 모든 배치 처리 완료", getReaderName()); + return null; + } + + + // Iterator 갱신 + currentBatch = nextBatch.iterator(); + log.debug("[{}] 배치 로드 완료: {} 건", getReaderName(), nextBatch.size()); + }*/ + // currentBatch가 비어있으면 다음 배치 로드 + while (currentBatch == null || !currentBatch.hasNext()) { + List nextBatch = fetchNextBatch(); + + if (nextBatch == null) { // 진짜 종료 + afterFetch(null); + log.info("[{}] 모든 배치 처리 완료", getReaderName()); + return null; + } + + if (nextBatch.isEmpty()) { // emptyList면 다음 batch를 시도 + log.warn("[{}] 빈 배치 수신 → 다음 배치 재요청", getReaderName()); + continue; // while 반복문으로 다시 fetch + } + + currentBatch = nextBatch.iterator(); + log.debug("[{}] 배치 로드 완료: {} 건", getReaderName(), nextBatch.size()); + } + + + // Iterator에서 1건씩 반환 + return currentBatch.next(); + } + + /** + * Legacy 모드 read() 구현 (하위 호환성) + * 기존 fetchDataFromApi()를 오버라이드한 구현체 지원 + */ + private T readLegacyMode() throws Exception { + // 최초 호출 시 API에서 전체 데이터 조회 + if (legacyDataList == null) { + beforeFetch(); + legacyDataList = fetchDataFromApi(); + afterFetch(legacyDataList); + log.info("[{}] 데이터 {}건 조회 완료 (Legacy 모드)", + getReaderName(), legacyDataList != null ? legacyDataList.size() : 0); + } + + // 데이터를 순차적으로 반환 + if (legacyDataList != null && legacyNextIndex < legacyDataList.size()) { + return legacyDataList.get(legacyNextIndex++); + } else { + return null; // 데이터 끝 + } + } + + + // ======================================== + // 핵심 추상 메서드 (하위 클래스에서 구현) + // ======================================== + + /** + * ✨ 다음 배치 데이터를 조회하여 리스트로 반환 (신규 방식 - Chunk 기반) + * + * Chunk 기반 배치 처리를 위한 메서드: + * - read()가 호출될 때마다 필요 시 이 메서드가 호출됨 + * - 일반적으로 100~1000건씩 반환 + * - 더 이상 데이터가 없으면 null 또는 빈 리스트 반환 + * + * 구현 예시: + *
+     * private int currentPage = 0;
+     * private final int pageSize = 100;
+     *
+     * @Override
+     * protected List fetchNextBatch() {
+     *     if (currentPage >= totalPages) {
+     *         return null; // 종료
+     *     }
+     *
+     *     // API 호출 (100건씩)
+     *     ProductApiResponse response = callApiForPage(currentPage, pageSize);
+     *     currentPage++;
+     *
+     *     return response.getProducts();
+     * }
+     * 
+ * + * @return 다음 배치 데이터 리스트 (null 또는 빈 리스트면 종료) + * @throws Exception API 호출 실패 등 + */ + protected List fetchNextBatch() throws Exception { + // 기본 구현: Legacy 모드 fallback + // 하위 클래스에서 오버라이드 안 하면 fetchDataFromApi() 사용 + return null; + } + + /** + * API에서 데이터를 조회하여 리스트로 반환 (Legacy 방식 - 하위 호환성) + * + * ⚠️ Deprecated: fetchNextBatch()를 사용하세요. + * + * 구현 방법: + * 1. WebClient 없이 Mock 데이터 생성 (sample용) + * 2. WebClient로 실제 API 호출 (실전용) + * 3. callApi() 헬퍼 메서드 사용 (권장) + * + * @return API에서 조회한 데이터 리스트 (전체) + */ + protected List fetchDataFromApi() { + // 기본 구현: 빈 리스트 반환 + // 하위 클래스에서 오버라이드 필요 + return new ArrayList<>(); + } + + /** + * Reader 이름 반환 (로깅용) + * + * @return Reader 이름 (예: "ProductDataReader") + */ + protected abstract String getReaderName(); + + // ======================================== + // HTTP 요청 설정 메서드 (선택적 오버라이드) + // ======================================== + + /** + * HTTP Method 반환 + * + * 기본값: GET + * POST 요청 시 오버라이드 + * + * @return HTTP Method ("GET" 또는 "POST") + */ + protected String getHttpMethod() { + return "GET"; + } + + /** + * API 엔드포인트 경로 반환 + * + * 예제: + * - "/api/v1/products" + * - "/api/v1/orders/{orderId}" (Path Variable 포함) + * + * @return API 경로 + */ + protected String getApiPath() { + return ""; + } + + /** + * Query Parameter 맵 반환 + * + * 예제: + * Map params = new HashMap<>(); + * params.put("status", "active"); + * params.put("page", 1); + * params.put("size", 100); + * return params; + * + * @return Query Parameter 맵 (null이면 파라미터 없음) + */ + protected Map getQueryParams() { + return null; + } + + /** + * Path Variable 맵 반환 + * + * 예제: + * Map pathVars = new HashMap<>(); + * pathVars.put("orderId", "ORD-001"); + * return pathVars; + * + * @return Path Variable 맵 (null이면 Path Variable 없음) + */ + protected Map getPathVariables() { + return null; + } + + /** + * Request Body 반환 (POST 요청용) + * + * 예제: + * return RequestDto.builder() + * .startDate("2025-01-01") + * .endDate("2025-12-31") + * .build(); + * + * @return Request Body 객체 (null이면 Body 없음) + */ + protected Object getRequestBody() { + return null; + } + + /** + * HTTP Header 맵 반환 + * + * 예제: + * Map headers = new HashMap<>(); + * headers.put("Authorization", "Bearer token123"); + * headers.put("X-Custom-Header", "value"); + * return headers; + * + * 기본 헤더 (자동 추가): + * - Content-Type: application/json + * - Accept: application/json + * + * @return HTTP Header 맵 (null이면 기본 헤더만 사용) + */ + protected Map getHeaders() { + return null; + } + + /** + * API 응답 타입 반환 + * + * 예제: + * return ProductApiResponse.class; + * + * @return 응답 클래스 타입 + */ + protected Class getResponseType() { + return Object.class; + } + + /** + * API 응답에서 데이터 리스트 추출 + * + * 복잡한 JSON 응답 구조 처리: + * - 단순: response.getData() + * - 중첩: response.getResult().getItems() + * + * @param response API 응답 객체 + * @return 추출된 데이터 리스트 + */ + protected List extractDataFromResponse(Object response) { + return Collections.emptyList(); + } + + // ======================================== + // 라이프사이클 훅 메서드 (선택적 오버라이드) + // ======================================== + + /** + * API 호출 전 전처리 + * + * 사용 예: + * - 파라미터 검증 + * - 로깅 + * - 캐시 확인 + */ + protected void beforeFetch() { + log.debug("[{}] API 호출 준비 중...", getReaderName()); + } + + /** + * API 호출 후 후처리 + * + * 사용 예: + * - 데이터 검증 + * - 로깅 + * - 캐시 저장 + * + * @param data 조회된 데이터 리스트 + */ + protected void afterFetch(List data) { + log.debug("[{}] API 호출 완료", getReaderName()); + } + + /** + * API 호출 실패 시 에러 처리 + * + * 기본 동작: 빈 리스트 반환 (Job 실패 방지) + * 오버라이드 시: 예외 던지기 또는 재시도 로직 구현 + * + * @param e 발생한 예외 + * @return 대체 데이터 리스트 (빈 리스트 또는 캐시 데이터) + */ + protected List handleApiError(Exception e) { + log.error("[{}] API 호출 실패: {}", getReaderName(), e.getMessage(), e); + return new ArrayList<>(); + } + + // ======================================== + // 헬퍼 메서드 (하위 클래스에서 사용 가능) + // ======================================== + + /** + * WebClient를 사용한 API 호출 (GET/POST 자동 처리) + * + * 사용 방법 (fetchDataFromApi()에서): + * + * @Override + * protected List fetchDataFromApi() { + * ProductApiResponse response = callApi(); + * return extractDataFromResponse(response); + * } + * + * @param 응답 타입 + * @return API 응답 객체 + */ + @SuppressWarnings("unchecked") + protected R callApi() { + if (webClient == null) { + throw new IllegalStateException("WebClient가 초기화되지 않았습니다. 생성자에서 WebClient를 주입하세요."); + } + + try { + String method = getHttpMethod().toUpperCase(); + String path = getApiPath(); + + log.info("[{}] {} 요청 시작: {}", getReaderName(), method, path); + + if ("GET".equals(method)) { + return callGetApi(); + } else if ("POST".equals(method)) { + return callPostApi(); + } else { + throw new UnsupportedOperationException("지원하지 않는 HTTP Method: " + method); + } + + } catch (Exception e) { + log.error("[{}] API 호출 중 오류 발생", getReaderName(), e); + throw new RuntimeException("API 호출 실패", e); + } + } + + /** + * GET 요청 내부 처리 + */ + @SuppressWarnings("unchecked") + private R callGetApi() { + return (R) webClient + .get() + .uri(buildUri()) + .headers(this::applyHeaders) + .retrieve() + .bodyToMono(getResponseType()) + .block(); + } + + /** + * POST 요청 내부 처리 + */ + @SuppressWarnings("unchecked") + private R callPostApi() { + Object requestBody = getRequestBody(); + + if (requestBody == null) { + // Body 없는 POST 요청 + return (R) webClient + .post() + .uri(buildUri()) + .headers(this::applyHeaders) + .retrieve() + .bodyToMono(getResponseType()) + .block(); + } else { + // Body 있는 POST 요청 + return (R) webClient + .post() + .uri(buildUri()) + .headers(this::applyHeaders) + .bodyValue(requestBody) + .retrieve() + .bodyToMono(getResponseType()) + .block(); + } + } + + /** + * URI 빌드 (Path + Query Parameters + Path Variables) + */ + private Function buildUri() { + return uriBuilder -> { + // 1. Path 설정 + String path = getApiPath(); + uriBuilder.path(path); + + // 2. Query Parameters 추가 + Map queryParams = getQueryParams(); + if (queryParams != null && !queryParams.isEmpty()) { + queryParams.forEach((key, value) -> { + if (value != null) { + uriBuilder.queryParam(key, value); + } + }); + log.debug("[{}] Query Parameters: {}", getReaderName(), queryParams); + } + + // 3. Path Variables 적용 + Map pathVars = getPathVariables(); + if (pathVars != null && !pathVars.isEmpty()) { + log.debug("[{}] Path Variables: {}", getReaderName(), pathVars); + return uriBuilder.build(pathVars); + } else { + return uriBuilder.build(); + } + }; + } + + /** + * HTTP Header 적용 + */ + private void applyHeaders(HttpHeaders httpHeaders) { + // 1. 기본 헤더 설정 + httpHeaders.setContentType(MediaType.APPLICATION_JSON); + httpHeaders.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + + // 2. 커스텀 헤더 추가 + Map customHeaders = getHeaders(); + if (customHeaders != null && !customHeaders.isEmpty()) { + customHeaders.forEach(httpHeaders::set); + log.debug("[{}] Custom Headers: {}", getReaderName(), customHeaders); + } + } + + // ======================================== + // 유틸리티 메서드 + // ======================================== + + /** + * 데이터 리스트가 비어있는지 확인 + */ + protected boolean isEmpty(List data) { + return data == null || data.isEmpty(); + } + + /** + * 데이터 리스트 크기 반환 (null-safe) + */ + protected int getDataSize(List data) { + return data != null ? data.size() : 0; + } +} diff --git a/src/main/java/com/snp/batch/common/batch/repository/BaseJdbcRepository.java b/src/main/java/com/snp/batch/common/batch/repository/BaseJdbcRepository.java new file mode 100644 index 0000000..c7634e6 --- /dev/null +++ b/src/main/java/com/snp/batch/common/batch/repository/BaseJdbcRepository.java @@ -0,0 +1,353 @@ +package com.snp.batch.common.batch.repository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +/** + * JdbcTemplate 기반 Repository 추상 클래스 + * 모든 Repository가 상속받아 일관된 CRUD 패턴 제공 + * + * @param Entity 타입 + * @param ID 타입 + */ +@Slf4j +@RequiredArgsConstructor +@Transactional(readOnly = true) +public abstract class BaseJdbcRepository { + + protected final JdbcTemplate jdbcTemplate; + + /** + * 대상 스키마 이름 반환 (하위 클래스에서 구현) + * application.yml의 app.batch.target-schema.name 값을 @Value로 주입받아 반환 + */ + protected abstract String getTargetSchema(); + + /** + * 테이블명만 반환 (스키마 제외, 하위 클래스에서 구현) + */ + protected abstract String getSimpleTableName(); + + /** + * 전체 테이블명 반환 (스키마.테이블) + * 하위 클래스에서는 getSimpleTableName()만 구현하면 됨 + */ + protected String getTableName() { + return getTargetSchema() + "." + getSimpleTableName(); + } + + /** + * ID 컬럼명 반환 (기본값: "id") + */ + protected String getIdColumnName() { + return "id"; + } + protected String getIdColumnName(String customId) { + return customId; + } + + /** + * RowMapper 반환 (하위 클래스에서 구현) + */ + protected abstract RowMapper getRowMapper(); + + /** + * Entity에서 ID 추출 (하위 클래스에서 구현) + */ + protected abstract ID extractId(T entity); + + /** + * INSERT SQL 생성 (하위 클래스에서 구현) + */ + protected abstract String getInsertSql(); + + /** + * UPDATE SQL 생성 (하위 클래스에서 구현) + */ + protected abstract String getUpdateSql(); + + /** + * INSERT용 PreparedStatement 파라미터 설정 (하위 클래스에서 구현) + */ + protected abstract void setInsertParameters(PreparedStatement ps, T entity) throws Exception; + + /** + * UPDATE용 PreparedStatement 파라미터 설정 (하위 클래스에서 구현) + */ + protected abstract void setUpdateParameters(PreparedStatement ps, T entity) throws Exception; + + /** + * 엔티티명 반환 (로깅용) + */ + protected abstract String getEntityName(); + + // ==================== CRUD 메서드 ==================== + + /** + * ID로 조회 + */ + public Optional findById(ID id) { + String sql = String.format("SELECT * FROM %s WHERE %s = ?", getTableName(), getIdColumnName()); + log.debug("{} 조회: ID={}", getEntityName(), id); + + List results = jdbcTemplate.query(sql, getRowMapper(), id); + return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); + } + + /** + * 전체 조회 + */ + public List findAll() { + String sql = String.format("SELECT * FROM %s ORDER BY %s DESC", getTableName(), getIdColumnName()); + log.debug("{} 전체 조회", getEntityName()); + return jdbcTemplate.query(sql, getRowMapper()); + } + + /** + * 개수 조회 + */ + public long count() { + String sql = String.format("SELECT COUNT(*) FROM %s", getTableName()); + Long count = jdbcTemplate.queryForObject(sql, Long.class); + return count != null ? count : 0L; + } + + /** + * 존재 여부 확인 + */ + public boolean existsById(ID id) { + String sql = String.format("SELECT COUNT(*) FROM %s WHERE %s = ?", getTableName(), getIdColumnName()); + Long count = jdbcTemplate.queryForObject(sql, Long.class, id); + return count != null && count > 0; + } + + /** + * 단건 저장 (INSERT 또는 UPDATE) + */ + @Transactional + public T save(T entity) { + ID id = extractId(entity); + + if (id == null || !existsById(id)) { + return insert(entity); + } else { + return update(entity); + } + } + + /** + * 단건 INSERT + */ + @Transactional + protected T insert(T entity) { + log.info("{} 삽입 시작", getEntityName()); + + KeyHolder keyHolder = new GeneratedKeyHolder(); + + jdbcTemplate.update(connection -> { + PreparedStatement ps = connection.prepareStatement(getInsertSql(), Statement.RETURN_GENERATED_KEYS); + try { + setInsertParameters(ps, entity); + } catch (Exception e) { + log.error("{} 삽입 파라미터 설정 실패", getEntityName(), e); + throw new RuntimeException("Failed to set insert parameters", e); + } + return ps; + }, keyHolder); + + // 생성된 ID 조회 + if (keyHolder.getKeys() != null && !keyHolder.getKeys().isEmpty()) { + Object idValue = keyHolder.getKeys().get(getIdColumnName()); + if (idValue != null) { + @SuppressWarnings("unchecked") + ID generatedId = (ID) (idValue instanceof Number ? ((Number) idValue).longValue() : idValue); + log.info("{} 삽입 완료: ID={}", getEntityName(), generatedId); + return findById(generatedId).orElse(entity); + } + } + + log.info("{} 삽입 완료 (ID 미반환)", getEntityName()); + return entity; + } + + /** + * 단건 UPDATE + */ + @Transactional + protected T update(T entity) { + ID id = extractId(entity); + log.info("{} 수정 시작: ID={}", getEntityName(), id); + + int updated = jdbcTemplate.update(connection -> { + PreparedStatement ps = connection.prepareStatement(getUpdateSql()); + try { + setUpdateParameters(ps, entity); + } catch (Exception e) { + log.error("{} 수정 파라미터 설정 실패", getEntityName(), e); + throw new RuntimeException("Failed to set update parameters", e); + } + return ps; + }); + + if (updated == 0) { + throw new IllegalStateException(getEntityName() + " 수정 실패: ID=" + id); + } + + log.info("{} 수정 완료: ID={}", getEntityName(), id); + return findById(id).orElse(entity); + } + + /** + * 배치 INSERT (대량 삽입) + */ + @Transactional + public void batchInsert(List entities) { + if (entities == null || entities.isEmpty()) { + return; + } + + log.info("{} 배치 삽입 시작: {} 건", getEntityName(), entities.size()); + + jdbcTemplate.batchUpdate(getInsertSql(), entities, entities.size(), + (ps, entity) -> { + try { + setInsertParameters(ps, entity); + } catch (Exception e) { + log.error("배치 삽입 파라미터 설정 실패", e); + throw new RuntimeException(e); + } + }); + + log.info("{} 배치 삽입 완료: {} 건", getEntityName(), entities.size()); + } + + /** + * 배치 UPDATE (대량 수정) + */ + @Transactional + public void batchUpdate(List entities) { + if (entities == null || entities.isEmpty()) { + return; + } + + log.info("{} 배치 수정 시작: {} 건", getEntityName(), entities.size()); + + jdbcTemplate.batchUpdate(getUpdateSql(), entities, entities.size(), + (ps, entity) -> { + try { + setUpdateParameters(ps, entity); + } catch (Exception e) { + log.error("배치 수정 파라미터 설정 실패", e); + throw new RuntimeException(e); + } + }); + + log.info("{} 배치 수정 완료: {} 건", getEntityName(), entities.size()); + } + + /** + * 전체 저장 (INSERT 또는 UPDATE) + */ + @Transactional + public void saveAll(List entities) { + if (entities == null || entities.isEmpty()) { + return; + } + + log.info("{} 전체 저장 시작: {} 건", getEntityName(), entities.size()); + + // INSERT와 UPDATE 분리 + List toInsert = entities.stream() + .filter(e -> extractId(e) == null || !existsById(extractId(e))) + .toList(); + + List toUpdate = entities.stream() + .filter(e -> extractId(e) != null && existsById(extractId(e))) + .toList(); + + if (!toInsert.isEmpty()) { + batchInsert(toInsert); + } + + if (!toUpdate.isEmpty()) { + batchUpdate(toUpdate); + } + + log.info("{} 전체 저장 완료: 삽입={} 건, 수정={} 건", getEntityName(), toInsert.size(), toUpdate.size()); + } + + /** + * ID로 삭제 + */ + @Transactional + public void deleteById(ID id) { + String sql = String.format("DELETE FROM %s WHERE %s = ?", getTableName(), getIdColumnName()); + log.info("{} 삭제: ID={}", getEntityName(), id); + + int deleted = jdbcTemplate.update(sql, id); + + if (deleted == 0) { + log.warn("{} 삭제 실패 (존재하지 않음): ID={}", getEntityName(), id); + } else { + log.info("{} 삭제 완료: ID={}", getEntityName(), id); + } + } + + /** + * 전체 삭제 + */ + @Transactional + public void deleteAll() { + String sql = String.format("DELETE FROM %s", getTableName()); + log.warn("{} 전체 삭제", getEntityName()); + + int deleted = jdbcTemplate.update(sql); + log.info("{} 전체 삭제 완료: {} 건", getEntityName(), deleted); + } + + // ==================== 헬퍼 메서드 ==================== + + /** + * 현재 시각 반환 (감사 필드용) + */ + protected LocalDateTime now() { + return LocalDateTime.now(); + } + + /** + * 커스텀 쿼리 실행 (단건 조회) + */ + protected Optional executeQueryForObject(String sql, Object... params) { + log.debug("커스텀 쿼리 실행: {}", sql); + List results = jdbcTemplate.query(sql, getRowMapper(), params); + return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); + } + + /** + * 커스텀 쿼리 실행 (다건 조회) + */ + protected List executeQueryForList(String sql, Object... params) { + log.debug("커스텀 쿼리 실행: {}", sql); + return jdbcTemplate.query(sql, getRowMapper(), params); + } + + /** + * 커스텀 업데이트 실행 + */ + @Transactional + protected int executeUpdate(String sql, Object... params) { + log.debug("커스텀 업데이트 실행: {}", sql); + return jdbcTemplate.update(sql, params); + } +} diff --git a/src/main/java/com/snp/batch/common/batch/tasklet/LastExecutionUpdateTasklet.java b/src/main/java/com/snp/batch/common/batch/tasklet/LastExecutionUpdateTasklet.java new file mode 100644 index 0000000..7ef7455 --- /dev/null +++ b/src/main/java/com/snp/batch/common/batch/tasklet/LastExecutionUpdateTasklet.java @@ -0,0 +1,73 @@ +package com.snp.batch.common.batch.tasklet; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.sql.Timestamp; +import java.time.LocalDateTime; + +/** + * 배치 작업 완료 후 BATCH_LAST_EXECUTION 테이블의 LAST_SUCCESS_DATE를 업데이트하는 공통 Tasklet. + * + *

RECOLLECT 모드일 경우 업데이트를 스킵하며, + * Job ExecutionContext에 저장된 {@code batchToDate}를 기준으로 성공 날짜를 계산합니다. + * {@code batchToDate}가 없을 경우 현재 시간에서 {@code bufferHours}를 차감하여 사용합니다.

+ */ +@Slf4j +public class LastExecutionUpdateTasklet implements Tasklet { + + private static final String RECOLLECT_MODE = "RECOLLECT"; + + private final JdbcTemplate jdbcTemplate; + private final String targetSchema; + private final String apiKey; + private final int bufferHours; + + public LastExecutionUpdateTasklet(JdbcTemplate jdbcTemplate, String targetSchema, + String apiKey, int bufferHours) { + this.jdbcTemplate = jdbcTemplate; + this.targetSchema = targetSchema; + this.apiKey = apiKey; + this.bufferHours = bufferHours; + } + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + String executionMode = chunkContext.getStepContext() + .getStepExecution().getJobExecution() + .getJobParameters().getString("executionMode", "NORMAL"); + + if (RECOLLECT_MODE.equals(executionMode)) { + log.info(">>>>> RECOLLECT 모드 - LAST_EXECUTION 업데이트 스킵"); + return RepeatStatus.FINISHED; + } + + String toDateStr = chunkContext.getStepContext() + .getStepExecution().getJobExecution() + .getExecutionContext().getString("batchToDate", null); + + LocalDateTime successDate; + if (toDateStr != null) { + successDate = LocalDateTime.parse(toDateStr).minusHours(bufferHours); + log.info(">>>>> BATCH_LAST_EXECUTION 업데이트 시작 (캡처된 toDate - {}시간 버퍼: {})", + bufferHours, successDate); + } else { + successDate = LocalDateTime.now().minusHours(bufferHours); + log.warn(">>>>> batchToDate가 없어 현재 시간 - {}시간 버퍼 사용: {}", bufferHours, successDate); + } + + jdbcTemplate.update( + String.format( + "UPDATE %s.BATCH_LAST_EXECUTION SET LAST_SUCCESS_DATE = ?, UPDATED_AT = NOW() WHERE API_KEY = ?", + targetSchema), + Timestamp.valueOf(successDate), apiKey + ); + + log.info(">>>>> BATCH_LAST_EXECUTION 업데이트 완료 (LAST_SUCCESS_DATE = {})", successDate); + return RepeatStatus.FINISHED; + } +} diff --git a/src/main/java/com/snp/batch/common/batch/writer/BaseWriter.java b/src/main/java/com/snp/batch/common/batch/writer/BaseWriter.java new file mode 100644 index 0000000..6169d5b --- /dev/null +++ b/src/main/java/com/snp/batch/common/batch/writer/BaseWriter.java @@ -0,0 +1,61 @@ +package com.snp.batch.common.batch.writer; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; + +import java.util.ArrayList; +import java.util.List; + +/** + * ItemWriter 추상 클래스 + * 데이터 저장 로직을 위한 템플릿 제공 + * + * Template Method Pattern: + * - write(): 공통 로직 (로깅, null 체크) + * - writeItems(): 하위 클래스에서 저장 로직 구현 + * + * @param Entity 타입 + */ +@Slf4j +@RequiredArgsConstructor +public abstract class BaseWriter implements ItemWriter { + + private final String entityName; + + /** + * 실제 데이터 저장 로직 (하위 클래스에서 구현) + * Repository의 saveAll() 또는 batchInsert() 호출 등 + * + * @param items 저장할 Entity 리스트 + * @throws Exception 저장 중 오류 발생 시 + */ + protected abstract void writeItems(List items) throws Exception; + + /** + * Spring Batch ItemWriter 인터페이스 구현 + * Chunk 단위로 데이터를 저장 + * + * @param chunk 저장할 데이터 청크 + * @throws Exception 저장 중 오류 발생 시 + */ + @Override + public void write(Chunk chunk) throws Exception { + List items = new ArrayList<>(chunk.getItems()); + + if (items.isEmpty()) { + log.debug("{} 저장할 데이터가 없습니다", entityName); + return; + } + + try { + log.info("{} 데이터 {}건 저장 시작", entityName, items.size()); + writeItems(items); + log.info("{} 데이터 {}건 저장 완료", entityName, items.size()); + } catch (Exception e) { + log.error("{} 데이터 저장 실패", entityName, e); + throw e; + } + } +} diff --git a/src/main/java/com/snp/batch/common/web/ApiResponse.java b/src/main/java/com/snp/batch/common/web/ApiResponse.java new file mode 100644 index 0000000..b646cb0 --- /dev/null +++ b/src/main/java/com/snp/batch/common/web/ApiResponse.java @@ -0,0 +1,75 @@ +package com.snp.batch.common.web; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 통일된 API 응답 형식 + * + * @param 응답 데이터 타입 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "공통 API 응답 래퍼") +public class ApiResponse { + + @Schema(description = "성공 여부", example = "true") + private boolean success; + + @Schema(description = "응답 메시지", example = "Success") + private String message; + + @Schema(description = "응답 데이터") + private T data; + + @Schema(description = "에러 코드 (실패 시에만 존재)", example = "NOT_FOUND", nullable = true) + private String errorCode; + + /** + * 성공 응답 생성 + */ + public static ApiResponse success(T data) { + return ApiResponse.builder() + .success(true) + .message("Success") + .data(data) + .build(); + } + + /** + * 성공 응답 생성 (메시지 포함) + */ + public static ApiResponse success(String message, T data) { + return ApiResponse.builder() + .success(true) + .message(message) + .data(data) + .build(); + } + + /** + * 실패 응답 생성 + */ + public static ApiResponse error(String message) { + return ApiResponse.builder() + .success(false) + .message(message) + .build(); + } + + /** + * 실패 응답 생성 (에러 코드 포함) + */ + public static ApiResponse error(String message, String errorCode) { + return ApiResponse.builder() + .success(false) + .message(message) + .errorCode(errorCode) + .build(); + } +} diff --git a/src/main/java/com/snp/batch/global/cleanup/LogCleanupConfig.java b/src/main/java/com/snp/batch/global/cleanup/LogCleanupConfig.java new file mode 100644 index 0000000..31561fb --- /dev/null +++ b/src/main/java/com/snp/batch/global/cleanup/LogCleanupConfig.java @@ -0,0 +1,37 @@ +package com.snp.batch.global.cleanup; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +/** + * 배치 로그 정리 설정 + * + * 로그 종류별 보존 기간(일) 설정 + * + * 설정 예시: + * app.batch.log-cleanup: + * api-log-retention-days: 30 + * batch-meta-retention-days: 90 + * failed-record-retention-days: 90 + * recollection-history-retention-days: 90 + */ +@Getter +@Setter +@Configuration +@ConfigurationProperties(prefix = "app.batch.log-cleanup") +public class LogCleanupConfig { + + /** batch_api_log 보존 기간 (일) */ + private int apiLogRetentionDays = 30; + + /** Spring Batch 메타 테이블 보존 기간 (일) */ + private int batchMetaRetentionDays = 90; + + /** batch_failed_record (RESOLVED) 보존 기간 (일) */ + private int failedRecordRetentionDays = 90; + + /** batch_recollection_history 보존 기간 (일) */ + private int recollectionHistoryRetentionDays = 90; +} diff --git a/src/main/java/com/snp/batch/global/cleanup/LogCleanupJobConfig.java b/src/main/java/com/snp/batch/global/cleanup/LogCleanupJobConfig.java new file mode 100644 index 0000000..1081c8a --- /dev/null +++ b/src/main/java/com/snp/batch/global/cleanup/LogCleanupJobConfig.java @@ -0,0 +1,69 @@ +package com.snp.batch.global.cleanup; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobExecutionListener; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +/** + * 배치 로그 정리 Job Config + * + * 스케줄: 매일 02:00 (0 0 2 * * ?) + * + * 동작: + * - 보존 기간이 지난 배치 로그 데이터를 삭제 + * - batch_api_log (30일), Spring Batch 메타 (90일), + * batch_failed_record/RESOLVED (90일), batch_recollection_history (90일) + */ +@Slf4j +@Configuration +public class LogCleanupJobConfig { + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final LogCleanupTasklet logCleanupTasklet; + + public LogCleanupJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + LogCleanupTasklet logCleanupTasklet) { + this.jobRepository = jobRepository; + this.transactionManager = transactionManager; + this.logCleanupTasklet = logCleanupTasklet; + } + + @Bean(name = "logCleanupStep") + public Step logCleanupStep() { + return new StepBuilder("logCleanupStep", jobRepository) + .tasklet(logCleanupTasklet, transactionManager) + .build(); + } + + @Bean(name = "LogCleanupJob") + public Job logCleanupJob() { + log.info("Job 생성: LogCleanupJob"); + + return new JobBuilder("LogCleanupJob", jobRepository) + .listener(new JobExecutionListener() { + @Override + public void beforeJob(JobExecution jobExecution) { + log.info("[LogCleanupJob] 배치 로그 정리 Job 시작"); + } + + @Override + public void afterJob(JobExecution jobExecution) { + log.info("[LogCleanupJob] 배치 로그 정리 Job 완료 - 상태: {}", + jobExecution.getStatus()); + } + }) + .start(logCleanupStep()) + .build(); + } +} diff --git a/src/main/java/com/snp/batch/global/cleanup/LogCleanupTasklet.java b/src/main/java/com/snp/batch/global/cleanup/LogCleanupTasklet.java new file mode 100644 index 0000000..79f0da0 --- /dev/null +++ b/src/main/java/com/snp/batch/global/cleanup/LogCleanupTasklet.java @@ -0,0 +1,148 @@ +package com.snp.batch.global.cleanup; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class LogCleanupTasklet implements Tasklet { + + private final JdbcTemplate jdbcTemplate; + private final LogCleanupConfig config; + + @Value("${app.batch.target-schema.name}") + private String schema; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { + log.info("========================================"); + log.info("배치 로그 정리 Job 시작"); + log.info("========================================"); + + int totalDeleted = 0; + + // 1. batch_api_log 정리 + totalDeleted += cleanupApiLog(); + + // 2. Spring Batch 메타 테이블 정리 (FK 순서) + totalDeleted += cleanupBatchMeta(); + + // 3. batch_failed_record 정리 (RESOLVED만) + totalDeleted += cleanupFailedRecord(); + + // 4. batch_recollection_history 정리 + totalDeleted += cleanupRecollectionHistory(); + + log.info("========================================"); + log.info("배치 로그 정리 Job 완료 - 총 삭제: {} 건", totalDeleted); + log.info("========================================"); + + return RepeatStatus.FINISHED; + } + + private int cleanupApiLog() { + int days = config.getApiLogRetentionDays(); + String sql = String.format( + "DELETE FROM %s.batch_api_log WHERE created_at < NOW() - INTERVAL '%d days'", + schema, days); + int deleted = jdbcTemplate.update(sql); + log.info("[batch_api_log] 보존기간: {}일, 삭제: {}건", days, deleted); + return deleted; + } + + private int cleanupBatchMeta() { + int days = config.getBatchMetaRetentionDays(); + int totalDeleted = 0; + + // FK 의존 순서: step_execution_context → step_execution → job_execution_context → job_execution_params → job_execution → job_instance(orphan) + + // 1. batch_step_execution_context + String sql1 = String.format( + "DELETE FROM %s.batch_step_execution_context WHERE step_execution_id IN (" + + "SELECT se.step_execution_id FROM %s.batch_step_execution se " + + "JOIN %s.batch_job_execution je ON se.job_execution_id = je.job_execution_id " + + "WHERE je.create_time < NOW() - INTERVAL '%d days')", + schema, schema, schema, days); + int deleted = jdbcTemplate.update(sql1); + totalDeleted += deleted; + log.info("[batch_step_execution_context] 삭제: {}건", deleted); + + // 2. batch_step_execution + String sql2 = String.format( + "DELETE FROM %s.batch_step_execution WHERE job_execution_id IN (" + + "SELECT job_execution_id FROM %s.batch_job_execution " + + "WHERE create_time < NOW() - INTERVAL '%d days')", + schema, schema, days); + deleted = jdbcTemplate.update(sql2); + totalDeleted += deleted; + log.info("[batch_step_execution] 삭제: {}건", deleted); + + // 3. batch_job_execution_context + String sql3 = String.format( + "DELETE FROM %s.batch_job_execution_context WHERE job_execution_id IN (" + + "SELECT job_execution_id FROM %s.batch_job_execution " + + "WHERE create_time < NOW() - INTERVAL '%d days')", + schema, schema, days); + deleted = jdbcTemplate.update(sql3); + totalDeleted += deleted; + log.info("[batch_job_execution_context] 삭제: {}건", deleted); + + // 4. batch_job_execution_params + String sql4 = String.format( + "DELETE FROM %s.batch_job_execution_params WHERE job_execution_id IN (" + + "SELECT job_execution_id FROM %s.batch_job_execution " + + "WHERE create_time < NOW() - INTERVAL '%d days')", + schema, schema, days); + deleted = jdbcTemplate.update(sql4); + totalDeleted += deleted; + log.info("[batch_job_execution_params] 삭제: {}건", deleted); + + // 5. batch_job_execution + String sql5 = String.format( + "DELETE FROM %s.batch_job_execution WHERE create_time < NOW() - INTERVAL '%d days'", + schema, days); + deleted = jdbcTemplate.update(sql5); + totalDeleted += deleted; + log.info("[batch_job_execution] 삭제: {}건", deleted); + + // 6. batch_job_instance (참조 없는 인스턴스만) + String sql6 = String.format( + "DELETE FROM %s.batch_job_instance WHERE job_instance_id NOT IN (" + + "SELECT DISTINCT job_instance_id FROM %s.batch_job_execution)", + schema, schema); + deleted = jdbcTemplate.update(sql6); + totalDeleted += deleted; + log.info("[batch_job_instance] orphan 삭제: {}건", deleted); + + log.info("[Spring Batch 메타] 보존기간: {}일, 총 삭제: {}건", days, totalDeleted); + return totalDeleted; + } + + private int cleanupFailedRecord() { + int days = config.getFailedRecordRetentionDays(); + String sql = String.format( + "DELETE FROM %s.batch_failed_record WHERE status = 'RESOLVED' AND resolved_at < NOW() - INTERVAL '%d days'", + schema, days); + int deleted = jdbcTemplate.update(sql); + log.info("[batch_failed_record] 보존기간: {}일 (RESOLVED만), 삭제: {}건", days, deleted); + return deleted; + } + + private int cleanupRecollectionHistory() { + int days = config.getRecollectionHistoryRetentionDays(); + String sql = String.format( + "DELETE FROM %s.batch_recollection_history WHERE created_at < NOW() - INTERVAL '%d days'", + schema, days); + int deleted = jdbcTemplate.update(sql); + log.info("[batch_recollection_history] 보존기간: {}일, 삭제: {}건", days, deleted); + return deleted; + } +} diff --git a/src/main/java/com/snp/batch/global/config/AsyncConfig.java b/src/main/java/com/snp/batch/global/config/AsyncConfig.java new file mode 100644 index 0000000..e9b273e --- /dev/null +++ b/src/main/java/com/snp/batch/global/config/AsyncConfig.java @@ -0,0 +1,56 @@ +package com.snp.batch.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import org.springframework.core.task.TaskExecutor; + +import java.util.concurrent.Executor; + +@Configuration +@EnableAsync // 비동기 기능 활성화 +public class AsyncConfig { + + @Bean(name = "apiLogExecutor") + public Executor apiLogExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); // 기본 스레드 수 + executor.setMaxPoolSize(5); // 최대 스레드 수 + executor.setQueueCapacity(500); // 대기 큐 크기 + executor.setThreadNamePrefix("ApiLogThread-"); + executor.initialize(); + return executor; + } + + /** + * 자동 재수집 전용 Executor. + * 재수집 Job은 장시간 실행되므로 apiLogExecutor와 분리하여 별도 풀로 관리. + */ + @Bean(name = "autoRetryExecutor") + public Executor autoRetryExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(1); // 재수집은 순차적으로 충분 + executor.setMaxPoolSize(2); // 동시 최대 2개까지 허용 + executor.setQueueCapacity(10); // 대기 큐 (초과 시 CallerRunsPolicy) + executor.setThreadNamePrefix("AutoRetry-"); + executor.initialize(); + return executor; + } + + /** + * 배치 파티션 병렬 실행 전용 Executor. + * ShipDetailUpdate 파티셔닝 등 배치 Step 병렬 처리에 사용. + */ + @Bean(name = "batchPartitionExecutor") + public TaskExecutor batchPartitionExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(4); // 기본 파티션 수 + executor.setMaxPoolSize(8); // 최대 파티션 수 + executor.setQueueCapacity(20); // 대기 큐 + executor.setThreadNamePrefix("BatchPartition-"); + executor.initialize(); + return executor; + } +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/global/config/MaritimeApiWebClientConfig.java b/src/main/java/com/snp/batch/global/config/MaritimeApiWebClientConfig.java new file mode 100644 index 0000000..0d599e5 --- /dev/null +++ b/src/main/java/com/snp/batch/global/config/MaritimeApiWebClientConfig.java @@ -0,0 +1,161 @@ +package com.snp.batch.global.config; + +import io.netty.channel.ChannelOption; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.netty.http.client.HttpClient; + +import java.time.Duration; + +/** + * Maritime API WebClient 설정 + * + * 목적: + * - Maritime API 서버에 대한 WebClient Bean 등록 + * - 동일한 API 서버를 사용하는 여러 Job에서 재사용 + * - 설정 변경 시 한 곳에서만 수정 + * + * 사용 Job: + * - 각 도메인 Job에서 공통으로 재사용 + * + * 다른 API 서버 추가 시: + * - 새로운 Config 클래스 생성 (예: OtherApiWebClientConfig) + * - Bean 이름을 다르게 지정 (예: @Bean(name = "otherApiWebClient")) + */ +@Slf4j +@Configuration +public class MaritimeApiWebClientConfig { + + @Value("${app.batch.ship-api.url}") + private String maritimeApiUrl; + + @Value("${app.batch.ais-api.url}") + private String maritimeAisApiUrl; + + @Value("${app.batch.webservice-api.url}") + private String maritimeServiceApiUrl; + + + @Value("${app.batch.api-auth.username}") + private String maritimeApiUsername; + + @Value("${app.batch.api-auth.password}") + private String maritimeApiPassword; + + /** + * Maritime API용 WebClient Bean + * + * 설정: + * - Base URL: Maritime API 서버 주소 + * - 인증: Basic Authentication + * - 버퍼: 20MB (대용량 응답 처리) + * + * @return Maritime API WebClient + */ + @Bean(name = "maritimeApiWebClient") + public WebClient maritimeApiWebClient() { + log.info("========================================"); + log.info("Maritime API WebClient 생성"); + log.info("Base URL: {}", maritimeApiUrl); + log.info("========================================"); + + HttpClient httpClient = HttpClient.create() + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10_000) // 연결 타임아웃 10초 + .responseTimeout(Duration.ofSeconds(60)); // 응답 대기 60초 + + return WebClient.builder() + .clientConnector(new ReactorClientHttpConnector(httpClient)) + .baseUrl(maritimeApiUrl) + .defaultHeaders(headers -> headers.setBasicAuth(maritimeApiUsername, maritimeApiPassword)) + .codecs(configurer -> configurer + .defaultCodecs() + .maxInMemorySize(100 * 1024 * 1024)) // 100MB 버퍼 + .build(); + } + + @Bean(name = "maritimeAisApiWebClient") + public WebClient maritimeAisApiWebClient(){ + log.info("========================================"); + log.info("Maritime AIS API WebClient 생성"); + log.info("Base URL: {}", maritimeAisApiUrl); + log.info("========================================"); + + HttpClient httpClient = HttpClient.create() + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10_000) // 연결 타임아웃 10초 + .responseTimeout(Duration.ofSeconds(60)); // 응답 대기 60초 + + return WebClient.builder() + .clientConnector(new ReactorClientHttpConnector(httpClient)) + .baseUrl(maritimeAisApiUrl) + .defaultHeaders(headers -> headers.setBasicAuth(maritimeApiUsername, maritimeApiPassword)) + .codecs(configurer -> configurer + .defaultCodecs() + .maxInMemorySize(100 * 1024 * 1024)) // 100MB 버퍼 + .build(); + } + + @Bean(name = "maritimeServiceApiWebClient") + public WebClient maritimeServiceApiWebClient(){ + log.info("========================================"); + log.info("Maritime Service API WebClient 생성"); + log.info("Base URL: {}", maritimeServiceApiUrl); + log.info("========================================"); + + HttpClient httpClient = HttpClient.create() + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10_000) + .responseTimeout(Duration.ofMinutes(5)); + + return WebClient.builder() + .clientConnector(new ReactorClientHttpConnector(httpClient)) + .baseUrl(maritimeServiceApiUrl) + .defaultHeaders(headers -> headers.setBasicAuth(maritimeApiUsername, maritimeApiPassword)) + .codecs(configurer -> configurer + .defaultCodecs() + .maxInMemorySize(256 * 1024 * 1024)) // 256MB 버퍼 + .build(); + } +} + + +/** + * ======================================== + * 다른 API 서버 추가 예시 + * ======================================== + * + * 1. 새로운 Config 클래스 생성: + * + * @Configuration + * public class ExternalApiWebClientConfig { + * + * @Bean(name = "externalApiWebClient") + * public WebClient externalApiWebClient( + * @Value("${app.batch.external-api.url}") String url, + * @Value("${app.batch.external-api.token}") String token) { + * + * return WebClient.builder() + * .baseUrl(url) + * .defaultHeader("Authorization", "Bearer " + token) + * .build(); + * } + * } + * + * 2. JobConfig에서 사용: + * + * public ExternalJobConfig( + * ..., + * @Qualifier("externalApiWebClient") WebClient externalApiWebClient) { + * this.webClient = externalApiWebClient; + * } + * + * 3. application.yml에 설정 추가: + * + * app: + * batch: + * external-api: + * url: https://external-api.example.com + * token: ${EXTERNAL_API_TOKEN} + */ diff --git a/src/main/java/com/snp/batch/global/config/QuartzConfig.java b/src/main/java/com/snp/batch/global/config/QuartzConfig.java new file mode 100644 index 0000000..6d42e24 --- /dev/null +++ b/src/main/java/com/snp/batch/global/config/QuartzConfig.java @@ -0,0 +1,85 @@ +package com.snp.batch.global.config; + +import org.quartz.spi.TriggerFiredBundle; +import org.springframework.beans.factory.config.AutowireCapableBeanFactory; +import org.springframework.boot.autoconfigure.quartz.QuartzProperties; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.quartz.SchedulerFactoryBean; +import org.springframework.scheduling.quartz.SpringBeanJobFactory; + +import javax.sql.DataSource; +import java.util.Properties; + +/** + * Quartz 설정 + * 커스텀 SchedulerFactoryBean을 정의하면 Spring Boot auto-configuration이 비활성화되므로 + * DataSource와 QuartzProperties를 명시적으로 주입해야 한다. + */ +@Configuration +public class QuartzConfig { + + /** + * Quartz Scheduler Factory Bean 설정 + * DataSource, QuartzProperties를 명시적으로 주입하여 JDBC Store 사용 보장 + */ + @Bean + public SchedulerFactoryBean schedulerFactoryBean( + ApplicationContext applicationContext, + DataSource dataSource, + QuartzProperties quartzProperties) { + + SchedulerFactoryBean factory = new SchedulerFactoryBean(); + factory.setJobFactory(springBeanJobFactory(applicationContext)); + factory.setDataSource(dataSource); + factory.setOverwriteExistingJobs(true); + // SchedulerInitializer에서 직접 start() 호출하므로 자동 시작 비활성화 + // 자동 시작 시 JDBC Store의 기존 trigger가 로드되어 중복 실행 발생 가능 + factory.setAutoStartup(false); + + // application.yml의 spring.quartz.properties 적용 + // jobStore.class는 setDataSource()가 LocalDataSourceJobStore로 대체하므로 제외 + // driverDelegateClass는 PostgreSQLDelegate가 필요하므로 유지 + Properties properties = new Properties(); + quartzProperties.getProperties().forEach((key, value) -> { + if (!key.contains("jobStore.class")) { + properties.put(key, value); + } + }); + factory.setQuartzProperties(properties); + + return factory; + } + + /** + * Spring Bean 자동 주입을 지원하는 JobFactory + */ + @Bean + public SpringBeanJobFactory springBeanJobFactory(ApplicationContext applicationContext) { + AutowiringSpringBeanJobFactory jobFactory = new AutowiringSpringBeanJobFactory(); + jobFactory.setApplicationContext(applicationContext); + return jobFactory; + } + + /** + * Quartz Job에서 Spring Bean 자동 주입을 가능하게 하는 Factory + */ + public static class AutowiringSpringBeanJobFactory extends SpringBeanJobFactory implements ApplicationContextAware { + + private AutowireCapableBeanFactory beanFactory; + + @Override + public void setApplicationContext(ApplicationContext applicationContext) { + beanFactory = applicationContext.getAutowireCapableBeanFactory(); + } + + @Override + protected Object createJobInstance(TriggerFiredBundle bundle) throws Exception { + Object jobInstance = super.createJobInstance(bundle); + beanFactory.autowireBean(jobInstance); + return jobInstance; + } + } +} diff --git a/src/main/java/com/snp/batch/global/config/SwaggerConfig.java b/src/main/java/com/snp/batch/global/config/SwaggerConfig.java new file mode 100644 index 0000000..aadb66d --- /dev/null +++ b/src/main/java/com/snp/batch/global/config/SwaggerConfig.java @@ -0,0 +1,102 @@ +package com.snp.batch.global.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.servers.Server; +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +/** + * Swagger/OpenAPI 3.0 설정 + * + * Swagger UI 접속 URL: + * - Swagger UI: http://localhost:8041/snp-collector/swagger-ui/index.html + * - API 문서 (JSON): http://localhost:8041/snp-collector/v3/api-docs + * - API 문서 (YAML): http://localhost:8041/snp-collector/v3/api-docs.yaml + */ +@Configuration +public class SwaggerConfig { + + @Value("${server.port:8041}") + private int serverPort; + + @Value("${server.servlet.context-path:}") + private String contextPath; + + @Value("${app.environment:dev}") + private String environment; + + @Bean + @ConditionalOnProperty(name = "app.environment", havingValue = "dev", matchIfMissing = true) + public GroupedOpenApi batchManagementApi() { + return GroupedOpenApi.builder() + .group("1. Batch Management") + .pathsToMatch("/api/batch/**") + .addOpenApiCustomizer(openApi -> openApi.info(new Info() + .title("Batch Management API") + .description("배치 Job 실행, 이력 조회, 스케줄 관리 API") + .version("v1.0.0"))) + .build(); + } + + @Bean + public OpenAPI openAPI() { + List servers = "prod".equals(environment) + ? List.of( + new Server() + .url("https://guide.gc-si.dev" + contextPath) + .description("GC 도메인")) + : List.of( + new Server() + .url("http://localhost:" + serverPort + contextPath) + .description("로컬 개발 서버"), + new Server() + .url("http://211.208.115.83:" + serverPort + contextPath) + .description("중계 서버"), + new Server() + .url("https://guide.gc-si.dev" + contextPath) + .description("GC 도메인")); + + return new OpenAPI() + .info(defaultApiInfo()) + .servers(servers); + } + + private Info defaultApiInfo() { + return new Info() + .title("SNP Collector REST API") + .description(""" + ## SNP Collector 시스템 REST API 문서 + + 해양 데이터 수집 배치 시스템의 REST API 문서입니다. + + ### 제공 API + - **Batch Management API**: 배치 Job 실행, 이력 조회, 스케줄 관리 + + ### 주요 기능 + - 배치 Job 실행 및 중지 + - Job 실행 이력 조회 + - 스케줄 관리 (Quartz) + + ### 버전 정보 + - API Version: v1.0.0 + - Spring Boot: 3.2.1 + - Spring Batch: 5.1.0 + """) + .version("v1.0.0") + .contact(new Contact() + .name("SNP Collector Team") + .email("support@snp-collector.com") + .url("https://github.com/snp-collector")) + .license(new License() + .name("Apache 2.0") + .url("https://www.apache.org/licenses/LICENSE-2.0")); + } +} diff --git a/src/main/java/com/snp/batch/global/controller/BatchController.java b/src/main/java/com/snp/batch/global/controller/BatchController.java new file mode 100644 index 0000000..859bd4c --- /dev/null +++ b/src/main/java/com/snp/batch/global/controller/BatchController.java @@ -0,0 +1,791 @@ +package com.snp.batch.global.controller; + +import com.snp.batch.global.dto.*; +import com.snp.batch.global.model.BatchCollectionPeriod; +import com.snp.batch.global.model.BatchRecollectionHistory; +import com.snp.batch.global.model.JobDisplayNameEntity; +import com.snp.batch.global.repository.JobDisplayNameRepository; +import com.snp.batch.service.BatchFailedRecordService; +import com.snp.batch.service.BatchService; +import com.snp.batch.service.RecollectionHistoryService; +import com.snp.batch.service.ScheduleService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.Explode; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.enums.ParameterStyle; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; +import java.io.PrintWriter; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +@Slf4j +@RestController +@RequestMapping("/api/batch") +@RequiredArgsConstructor +@Tag(name = "Batch Management API", description = "배치 작업 실행 및 스케줄 관리 API") +public class BatchController { + + private final BatchService batchService; + private final ScheduleService scheduleService; + private final RecollectionHistoryService recollectionHistoryService; + private final BatchFailedRecordService batchFailedRecordService; + private final JobDisplayNameRepository jobDisplayNameRepository; + + @Operation(summary = "배치 작업 실행", description = "지정된 배치 작업을 즉시 실행합니다. 쿼리 파라미터로 Job Parameters 전달 가능") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "작업 실행 성공"), + @ApiResponse(responseCode = "500", description = "작업 실행 실패") + }) + @PostMapping("/jobs/{jobName}/execute") + public ResponseEntity> executeJob( + @Parameter(description = "실행할 배치 작업 이름", required = true, example = "sampleProductImportJob") + @PathVariable String jobName, + @Parameter(description = "Job Parameters (동적 파라미터)", required = false, example = "?param1=value1¶m2=value2") + @RequestParam(required = false) Map params) { + log.info("Received request to execute job: {} with params: {}", jobName, params); + try { + Long executionId = batchService.executeJob(jobName, params); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "Job started successfully", + "executionId", executionId + )); + } catch (Exception e) { + log.error("Error executing job: {}", jobName, e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, + "message", "Failed to start job: " + e.getMessage() + )); + } + } + + @Operation(summary = "배치 작업 실행", description = "지정된 배치 작업을 즉시 실행합니다. 쿼리 파라미터로 Job Parameters 전달 가능") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "작업 실행 성공"), + @ApiResponse(responseCode = "500", description = "작업 실행 실패") + }) + @PostMapping("/jobs/{jobName}/executeJobTest") + public ResponseEntity> executeJobTest( + @Parameter( description = "실행할 배치 작업 이름", required = true,example = "sampleProductImportJob") + @PathVariable String jobName, + @ParameterObject JobLaunchRequest request + ) { + Map params = new HashMap<>(); + if (request.getStartDate() != null) params.put("startDate", request.getStartDate()); + if (request.getStopDate() != null) params.put("stopDate", request.getStopDate()); + + log.info("Executing job: {} with params: {}", jobName, params); + + try { + Long executionId = batchService.executeJob(jobName, params); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "Job started successfully", + "executionId", executionId + )); + } catch (Exception e) { + log.error("Error executing job: {}", jobName, e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, + "message", "Failed to start job: " + e.getMessage() + )); + } + } + + @Operation(summary = "배치 작업 목록 조회", description = "등록된 모든 배치 작업 목록을 조회합니다") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공") + }) + @GetMapping("/jobs") + public ResponseEntity> listJobs() { + log.debug("Received request to list all jobs"); + List jobs = batchService.listAllJobs(); + return ResponseEntity.ok(jobs); + } + + @Operation(summary = "배치 작업 실행 이력 조회", description = "특정 배치 작업의 실행 이력을 조회합니다") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공") + }) + @GetMapping("/jobs/{jobName}/executions") + public ResponseEntity> getJobExecutions( + @Parameter(description = "배치 작업 이름", required = true, example = "sampleProductImportJob") + @PathVariable String jobName) { + log.info("Received request to get executions for job: {}", jobName); + List executions = batchService.getJobExecutions(jobName); + return ResponseEntity.ok(executions); + } + + @Operation(summary = "최근 전체 실행 이력 조회", description = "Job 구분 없이 최근 실행 이력을 조회합니다") + @GetMapping("/executions/recent") + public ResponseEntity> getRecentExecutions( + @Parameter(description = "조회 건수", example = "50") + @RequestParam(defaultValue = "50") int limit) { + log.debug("Received request to get recent executions: limit={}", limit); + List executions = batchService.getRecentExecutions(limit); + return ResponseEntity.ok(executions); + } + + @GetMapping("/executions/{executionId}") + public ResponseEntity getExecutionDetails(@PathVariable Long executionId) { + log.info("Received request to get execution details for: {}", executionId); + try { + JobExecutionDto execution = batchService.getExecutionDetails(executionId); + return ResponseEntity.ok(execution); + } catch (Exception e) { + log.error("Error getting execution details: {}", executionId, e); + return ResponseEntity.notFound().build(); + } + } + + @GetMapping("/executions/{executionId}/detail") + public ResponseEntity getExecutionDetailWithSteps(@PathVariable Long executionId) { + log.info("Received request to get detailed execution for: {}", executionId); + try { + com.snp.batch.global.dto.JobExecutionDetailDto detail = batchService.getExecutionDetailWithSteps(executionId); + return ResponseEntity.ok(detail); + } catch (Exception e) { + log.error("Error getting detailed execution: {}", executionId, e); + return ResponseEntity.notFound().build(); + } + } + + @PostMapping("/executions/{executionId}/stop") + public ResponseEntity> stopExecution(@PathVariable Long executionId) { + log.info("Received request to stop execution: {}", executionId); + try { + batchService.stopExecution(executionId); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "Execution stop requested" + )); + } catch (Exception e) { + log.error("Error stopping execution: {}", executionId, e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, + "message", "Failed to stop execution: " + e.getMessage() + )); + } + } + + @Operation(summary = "스케줄 목록 조회", description = "등록된 모든 스케줄을 조회합니다") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공") + }) + @GetMapping("/schedules") + public ResponseEntity> getSchedules() { + log.info("Received request to get all schedules"); + List schedules = scheduleService.getAllSchedules(); + return ResponseEntity.ok(Map.of( + "schedules", schedules, + "count", schedules.size() + )); + } + + @GetMapping("/schedules/{jobName}") + public ResponseEntity getSchedule(@PathVariable String jobName) { + log.debug("Received request to get schedule for job: {}", jobName); + try { + ScheduleResponse schedule = scheduleService.getScheduleByJobName(jobName); + return ResponseEntity.ok(schedule); + } catch (IllegalArgumentException e) { + // 스케줄이 없는 경우 - 정상적인 시나리오 (UI에서 존재 여부 확인용) + log.debug("Schedule not found for job: {} (정상 - 존재 확인)", jobName); + return ResponseEntity.notFound().build(); + } catch (Exception e) { + log.error("Error getting schedule for job: {}", jobName, e); + return ResponseEntity.notFound().build(); + } + } + + @Operation(summary = "스케줄 생성", description = "새로운 배치 작업 스케줄을 등록합니다") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "생성 성공"), + @ApiResponse(responseCode = "500", description = "생성 실패") + }) + @PostMapping("/schedules") + public ResponseEntity> createSchedule( + @Parameter(description = "스케줄 생성 요청 데이터", required = true) + @RequestBody ScheduleRequest request) { + log.info("Received request to create schedule for job: {}", request.getJobName()); + try { + ScheduleResponse schedule = scheduleService.createSchedule(request); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "Schedule created successfully", + "data", schedule + )); + } catch (Exception e) { + log.error("Error creating schedule for job: {}", request.getJobName(), e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, + "message", "Failed to create schedule: " + e.getMessage() + )); + } + } + + @PostMapping("/schedules/{jobName}/update") + public ResponseEntity> updateSchedule( + @PathVariable String jobName, + @RequestBody Map request) { + log.info("Received request to update schedule for job: {}", jobName); + try { + String cronExpression = request.get("cronExpression"); + String description = request.get("description"); + ScheduleResponse schedule = scheduleService.updateSchedule(jobName, cronExpression, description); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "Schedule updated successfully", + "data", schedule + )); + } catch (Exception e) { + log.error("Error updating schedule for job: {}", jobName, e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, + "message", "Failed to update schedule: " + e.getMessage() + )); + } + } + + @Operation(summary = "스케줄 삭제", description = "배치 작업 스케줄을 삭제합니다") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "삭제 성공"), + @ApiResponse(responseCode = "500", description = "삭제 실패") + }) + @PostMapping("/schedules/{jobName}/delete") + public ResponseEntity> deleteSchedule( + @Parameter(description = "배치 작업 이름", required = true) + @PathVariable String jobName) { + log.info("Received request to delete schedule for job: {}", jobName); + try { + scheduleService.deleteSchedule(jobName); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "Schedule deleted successfully" + )); + } catch (Exception e) { + log.error("Error deleting schedule for job: {}", jobName, e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, + "message", "Failed to delete schedule: " + e.getMessage() + )); + } + } + + @PostMapping("/schedules/{jobName}/toggle") + public ResponseEntity> toggleSchedule( + @PathVariable String jobName, + @RequestBody Map request) { + log.info("Received request to toggle schedule for job: {}", jobName); + try { + Boolean active = request.get("active"); + ScheduleResponse schedule = scheduleService.toggleScheduleActive(jobName, active); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "Schedule toggled successfully", + "data", schedule + )); + } catch (Exception e) { + log.error("Error toggling schedule for job: {}", jobName, e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, + "message", "Failed to toggle schedule: " + e.getMessage() + )); + } + } + + @GetMapping("/timeline") + public ResponseEntity getTimeline( + @RequestParam String view, + @RequestParam String date) { + log.debug("Received request to get timeline: view={}, date={}", view, date); + try { + com.snp.batch.global.dto.TimelineResponse timeline = batchService.getTimeline(view, date); + return ResponseEntity.ok(timeline); + } catch (Exception e) { + log.error("Error getting timeline", e); + return ResponseEntity.internalServerError().build(); + } + } + + @GetMapping("/dashboard") + public ResponseEntity getDashboard() { + log.debug("Received request to get dashboard data"); + try { + com.snp.batch.global.dto.DashboardResponse dashboard = batchService.getDashboardData(); + return ResponseEntity.ok(dashboard); + } catch (Exception e) { + log.error("Error getting dashboard data", e); + return ResponseEntity.internalServerError().build(); + } + } + + @GetMapping("/timeline/period-executions") + public ResponseEntity> getPeriodExecutions( + @RequestParam String jobName, + @RequestParam String view, + @RequestParam String periodKey) { + log.info("Received request to get period executions: jobName={}, view={}, periodKey={}", jobName, view, periodKey); + try { + List executions = batchService.getPeriodExecutions(jobName, view, periodKey); + return ResponseEntity.ok(executions); + } catch (Exception e) { + log.error("Error getting period executions", e); + return ResponseEntity.internalServerError().build(); + } + } + + // ── Step API 로그 페이징 조회 ───────────────────────────── + + @Operation(summary = "Step API 호출 로그 페이징 조회", description = "Step 실행의 개별 API 호출 로그를 페이징 + 상태 필터로 조회합니다") + @GetMapping("/steps/{stepExecutionId}/api-logs") + public ResponseEntity getStepApiLogs( + @Parameter(description = "Step 실행 ID", required = true) @PathVariable Long stepExecutionId, + @Parameter(description = "페이지 번호 (0부터)") @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "50") int size, + @Parameter(description = "상태 필터 (ALL, SUCCESS, ERROR)") @RequestParam(defaultValue = "ALL") String status) { + log.debug("Get step API logs: stepExecutionId={}, page={}, size={}, status={}", stepExecutionId, page, size, status); + JobExecutionDetailDto.ApiLogPageResponse response = batchService.getStepApiLogs( + stepExecutionId, status, PageRequest.of(page, size)); + return ResponseEntity.ok(response); + } + + // ── F1: 강제 종료(Abandon) API ───────────────────────────── + + @Operation(summary = "오래된 실행 중 목록 조회", description = "지정된 시간(분) 이상 STARTED/STARTING 상태인 실행 목록을 조회합니다") + @GetMapping("/executions/stale") + public ResponseEntity> getStaleExecutions( + @Parameter(description = "임계 시간(분)", example = "60") + @RequestParam(defaultValue = "60") int thresholdMinutes) { + log.info("Received request to get stale executions: thresholdMinutes={}", thresholdMinutes); + List executions = batchService.getStaleExecutions(thresholdMinutes); + return ResponseEntity.ok(executions); + } + + @Operation(summary = "실행 강제 종료", description = "특정 실행을 ABANDONED 상태로 강제 변경합니다") + @PostMapping("/executions/{executionId}/abandon") + public ResponseEntity> abandonExecution(@PathVariable Long executionId) { + log.info("Received request to abandon execution: {}", executionId); + try { + batchService.abandonExecution(executionId); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "Execution abandoned successfully" + )); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "message", e.getMessage() + )); + } catch (Exception e) { + log.error("Error abandoning execution: {}", executionId, e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, + "message", "Failed to abandon execution: " + e.getMessage() + )); + } + } + + @Operation(summary = "오래된 실행 전체 강제 종료", description = "지정된 시간(분) 이상 실행 중인 모든 Job을 ABANDONED로 변경합니다") + @PostMapping("/executions/stale/abandon-all") + public ResponseEntity> abandonAllStaleExecutions( + @Parameter(description = "임계 시간(분)", example = "60") + @RequestParam(defaultValue = "60") int thresholdMinutes) { + log.info("Received request to abandon all stale executions: thresholdMinutes={}", thresholdMinutes); + try { + int count = batchService.abandonAllStaleExecutions(thresholdMinutes); + return ResponseEntity.ok(Map.of( + "success", true, + "message", count + "건의 실행이 강제 종료되었습니다", + "abandonedCount", count + )); + } catch (Exception e) { + log.error("Error abandoning all stale executions", e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, + "message", "Failed to abandon stale executions: " + e.getMessage() + )); + } + } + + // ── F4: 실행 이력 검색 API ───────────────────────────────── + + @Operation(summary = "실행 이력 검색", description = "조건별 실행 이력 검색 (페이지네이션 지원)") + @GetMapping("/executions/search") + public ResponseEntity searchExecutions( + @Parameter(description = "Job 이름 (콤마 구분, 복수 가능)") @RequestParam(required = false) String jobNames, + @Parameter(description = "상태 (필터)", example = "COMPLETED") @RequestParam(required = false) String status, + @Parameter(description = "시작일 (yyyy-MM-dd'T'HH:mm:ss)") @RequestParam(required = false) String startDate, + @Parameter(description = "종료일 (yyyy-MM-dd'T'HH:mm:ss)") @RequestParam(required = false) String endDate, + @Parameter(description = "페이지 번호 (0부터)") @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "50") int size) { + log.debug("Search executions: jobNames={}, status={}, startDate={}, endDate={}, page={}, size={}", + jobNames, status, startDate, endDate, page, size); + + List jobNameList = (jobNames != null && !jobNames.isBlank()) + ? java.util.Arrays.stream(jobNames.split(",")) + .map(String::trim).filter(s -> !s.isEmpty()).toList() + : null; + + LocalDateTime start = startDate != null ? LocalDateTime.parse(startDate) : null; + LocalDateTime end = endDate != null ? LocalDateTime.parse(endDate) : null; + + ExecutionSearchResponse response = batchService.searchExecutions(jobNameList, status, start, end, page, size); + return ResponseEntity.ok(response); + } + + // ── F7: Job 상세 목록 API ────────────────────────────────── + + @Operation(summary = "Job 상세 목록 조회", description = "모든 Job의 최근 실행 상태 및 스케줄 정보를 조회합니다") + @GetMapping("/jobs/detail") + public ResponseEntity> getJobsDetail() { + log.debug("Received request to get jobs with detail"); + List jobs = batchService.getJobsWithDetail(); + return ResponseEntity.ok(jobs); + } + + // ── F8: 실행 통계 API ────────────────────────────────────── + + @Operation(summary = "전체 실행 통계", description = "전체 배치 작업의 일별 실행 통계를 조회합니다") + @GetMapping("/statistics") + public ResponseEntity getStatistics( + @Parameter(description = "조회 기간(일)", example = "30") + @RequestParam(defaultValue = "30") int days) { + log.debug("Received request to get statistics: days={}", days); + ExecutionStatisticsDto stats = batchService.getStatistics(days); + return ResponseEntity.ok(stats); + } + + @Operation(summary = "Job별 실행 통계", description = "특정 배치 작업의 일별 실행 통계를 조회합니다") + @GetMapping("/statistics/{jobName}") + public ResponseEntity getJobStatistics( + @Parameter(description = "Job 이름", required = true) @PathVariable String jobName, + @Parameter(description = "조회 기간(일)", example = "30") + @RequestParam(defaultValue = "30") int days) { + log.debug("Received request to get statistics for job: {}, days={}", jobName, days); + ExecutionStatisticsDto stats = batchService.getJobStatistics(jobName, days); + return ResponseEntity.ok(stats); + } + + // ── 재수집 이력 관리 API ───────────────────────────────────── + + @Operation(summary = "재수집 이력 목록 조회", description = "필터 조건으로 재수집 이력을 페이징 조회합니다") + @GetMapping("/recollection-histories") + public ResponseEntity> getRecollectionHistories( + @Parameter(description = "API Key") @RequestParam(required = false) String apiKey, + @Parameter(description = "Job 이름") @RequestParam(required = false) String jobName, + @Parameter(description = "실행 상태") @RequestParam(required = false) String status, + @Parameter(description = "시작일 (yyyy-MM-dd'T'HH:mm:ss)") @RequestParam(required = false) String fromDate, + @Parameter(description = "종료일 (yyyy-MM-dd'T'HH:mm:ss)") @RequestParam(required = false) String toDate, + @Parameter(description = "페이지 번호 (0부터)") @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "20") int size) { + log.debug("Search recollection histories: apiKey={}, jobName={}, status={}, page={}, size={}", + apiKey, jobName, status, page, size); + + LocalDateTime from = fromDate != null ? LocalDateTime.parse(fromDate) : null; + LocalDateTime to = toDate != null ? LocalDateTime.parse(toDate) : null; + + Page histories = recollectionHistoryService + .getHistories(apiKey, jobName, status, from, to, PageRequest.of(page, size)); + + // 목록의 jobExecutionId들로 실패건수 한번에 조회 + List jobExecutionIds = histories.getContent().stream() + .map(BatchRecollectionHistory::getJobExecutionId) + .filter(Objects::nonNull) + .toList(); + Map failedRecordCounts = recollectionHistoryService + .getFailedRecordCounts(jobExecutionIds); + + Map response = new HashMap<>(); + response.put("content", histories.getContent()); + response.put("totalElements", histories.getTotalElements()); + response.put("totalPages", histories.getTotalPages()); + response.put("number", histories.getNumber()); + response.put("size", histories.getSize()); + response.put("failedRecordCounts", failedRecordCounts); + return ResponseEntity.ok(response); + } + + @Operation(summary = "재수집 이력 상세 조회", description = "재수집 이력의 상세 정보 (Step Execution + Collection Period + 중복 이력 + API 통계 포함)") + @GetMapping("/recollection-histories/{historyId}") + public ResponseEntity> getRecollectionHistoryDetail( + @Parameter(description = "이력 ID") @PathVariable Long historyId) { + log.debug("Get recollection history detail: historyId={}", historyId); + try { + Map detail = recollectionHistoryService.getHistoryDetailWithSteps(historyId); + return ResponseEntity.ok(detail); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } + + @Operation(summary = "재수집 통계 조회", description = "재수집 실행 통계 및 최근 10건 조회") + @GetMapping("/recollection-histories/stats") + public ResponseEntity> getRecollectionHistoryStats() { + log.debug("Get recollection history stats"); + Map stats = recollectionHistoryService.getHistoryStats(); + stats.put("recentHistories", recollectionHistoryService.getRecentHistories()); + return ResponseEntity.ok(stats); + } + + // ── 마지막 수집 성공일시 모니터링 API ────────────────────────── + + @Operation(summary = "마지막 수집 성공일시 목록 조회", + description = "모든 API의 마지막 수집 성공 일시를 조회합니다. 오래된 순으로 정렬됩니다.") + @GetMapping("/last-collections") + public ResponseEntity> getLastCollectionStatuses() { + log.debug("Received request to get last collection statuses"); + List statuses = batchService.getLastCollectionStatuses(); + return ResponseEntity.ok(statuses); + } + + // ── 수집 기간 관리 API ─────────────────────────────────────── + + @Operation(summary = "수집 기간 목록 조회", description = "모든 API의 수집 기간 설정을 조회합니다") + @GetMapping("/collection-periods") + public ResponseEntity> getCollectionPeriods() { + log.debug("Get all collection periods"); + return ResponseEntity.ok(recollectionHistoryService.getAllCollectionPeriods()); + } + + @Operation(summary = "수집 기간 수정", description = "특정 API의 수집 기간을 수정합니다") + @PostMapping("/collection-periods/{apiKey}/update") + public ResponseEntity> updateCollectionPeriod( + @Parameter(description = "API Key") @PathVariable String apiKey, + @RequestBody Map request) { + log.info("Update collection period: apiKey={}", apiKey); + try { + String rangeFromStr = request.get("rangeFromDate"); + String rangeToStr = request.get("rangeToDate"); + + if (rangeFromStr == null || rangeToStr == null) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "message", "rangeFromDate와 rangeToDate는 필수입니다")); + } + + LocalDateTime rangeFrom = LocalDateTime.parse(rangeFromStr); + LocalDateTime rangeTo = LocalDateTime.parse(rangeToStr); + + if (rangeTo.isBefore(rangeFrom)) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "message", "rangeToDate는 rangeFromDate보다 이후여야 합니다")); + } + + recollectionHistoryService.updateCollectionPeriod(apiKey, rangeFrom, rangeTo); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "수집 기간이 수정되었습니다")); + } catch (Exception e) { + log.error("Error updating collection period: apiKey={}", apiKey, e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, + "message", "수집 기간 수정 실패: " + e.getMessage())); + } + } + + @Operation(summary = "수집 기간 초기화", description = "특정 API의 수집 기간을 null로 초기화합니다") + @PostMapping("/collection-periods/{apiKey}/reset") + public ResponseEntity> resetCollectionPeriod( + @Parameter(description = "API Key") @PathVariable String apiKey) { + log.info("Reset collection period: apiKey={}", apiKey); + try { + recollectionHistoryService.resetCollectionPeriod(apiKey); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "수집 기간이 초기화되었습니다")); + } catch (Exception e) { + log.error("Error resetting collection period: apiKey={}", apiKey, e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, + "message", "수집 기간 초기화 실패: " + e.getMessage())); + } + } + + // ── 실패 레코드 관리 API ────────────────────────────────────── + + @Operation(summary = "실패 레코드 일괄 RESOLVED 처리", description = "특정 Job의 FAILED 상태 레코드를 일괄 RESOLVED 처리합니다") + @PostMapping("/failed-records/resolve") + public ResponseEntity> resolveFailedRecords( + @RequestBody Map request) { + @SuppressWarnings("unchecked") + List rawIds = (List) request.get("ids"); + if (rawIds == null || rawIds.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "message", "ids는 필수이며 비어있을 수 없습니다")); + } + List ids = rawIds.stream().map(Integer::longValue).toList(); + log.info("Resolve failed records: ids count={}", ids.size()); + try { + int resolved = batchFailedRecordService.resolveByIds(ids); + return ResponseEntity.ok(Map.of( + "success", true, + "resolvedCount", resolved, + "message", resolved + "건의 실패 레코드가 RESOLVED 처리되었습니다")); + } catch (Exception e) { + log.error("Error resolving failed records", e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, + "message", "실패 레코드 RESOLVED 처리 실패: " + e.getMessage())); + } + } + + @Operation(summary = "실패 레코드 재시도 횟수 초기화", description = "재시도 횟수를 초과한 FAILED 레코드의 retryCount를 0으로 초기화하여 자동 재수집 대상으로 복원합니다") + @PostMapping("/failed-records/reset-retry") + public ResponseEntity> resetRetryCount( + @RequestBody Map request) { + @SuppressWarnings("unchecked") + List rawIds = (List) request.get("ids"); + if (rawIds == null || rawIds.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "message", "ids는 필수이며 비어있을 수 없습니다")); + } + List ids = rawIds.stream().map(Integer::longValue).toList(); + log.info("Reset retry count: ids count={}", ids.size()); + try { + int reset = batchFailedRecordService.resetRetryCount(ids); + return ResponseEntity.ok(Map.of( + "success", true, + "resetCount", reset, + "message", reset + "건의 실패 레코드 재시도 횟수가 초기화되었습니다")); + } catch (Exception e) { + log.error("Error resetting retry count", e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, + "message", "재시도 횟수 초기화 실패: " + e.getMessage())); + } + } + + // ── 재수집 이력 CSV 내보내기 API ────────────────────────────── + + @Operation(summary = "재수집 이력 CSV 내보내기", description = "필터 조건으로 재수집 이력을 CSV 파일로 내보냅니다 (최대 10,000건)") + @GetMapping("/recollection-histories/export") + public void exportRecollectionHistories( + @Parameter(description = "API Key") @RequestParam(required = false) String apiKey, + @Parameter(description = "Job 이름") @RequestParam(required = false) String jobName, + @Parameter(description = "실행 상태") @RequestParam(required = false) String status, + @Parameter(description = "시작일 (yyyy-MM-dd'T'HH:mm:ss)") @RequestParam(required = false) String fromDate, + @Parameter(description = "종료일 (yyyy-MM-dd'T'HH:mm:ss)") @RequestParam(required = false) String toDate, + HttpServletResponse response) throws IOException { + log.info("Export recollection histories: apiKey={}, jobName={}, status={}", apiKey, jobName, status); + + LocalDateTime from = fromDate != null ? LocalDateTime.parse(fromDate) : null; + LocalDateTime to = toDate != null ? LocalDateTime.parse(toDate) : null; + + List histories = recollectionHistoryService + .getHistoriesForExport(apiKey, jobName, status, from, to); + + response.setContentType("text/csv; charset=UTF-8"); + response.setHeader("Content-Disposition", "attachment; filename=recollection-histories.csv"); + // BOM for Excel UTF-8 + response.getOutputStream().write(new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF}); + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + PrintWriter writer = response.getWriter(); + writer.println("이력ID,API Key,작업명,Job실행ID,수집시작일,수집종료일,상태,실행시작,실행종료,소요시간(ms),읽기,쓰기,스킵,API호출,실행자,사유,실패사유,중복여부,생성일"); + + for (BatchRecollectionHistory h : histories) { + writer.println(String.join(",", + safeStr(h.getHistoryId()), + safeStr(h.getApiKey()), + safeStr(h.getJobName()), + safeStr(h.getJobExecutionId()), + h.getRangeFromDate() != null ? h.getRangeFromDate().format(formatter) : "", + h.getRangeToDate() != null ? h.getRangeToDate().format(formatter) : "", + safeStr(h.getExecutionStatus()), + h.getExecutionStartTime() != null ? h.getExecutionStartTime().format(formatter) : "", + h.getExecutionEndTime() != null ? h.getExecutionEndTime().format(formatter) : "", + safeStr(h.getDurationMs()), + safeStr(h.getReadCount()), + safeStr(h.getWriteCount()), + safeStr(h.getSkipCount()), + safeStr(h.getApiCallCount()), + escapeCsvField(h.getExecutor()), + escapeCsvField(h.getRecollectionReason()), + escapeCsvField(h.getFailureReason()), + h.getHasOverlap() != null ? (h.getHasOverlap() ? "Y" : "N") : "", + h.getCreatedAt() != null ? h.getCreatedAt().format(formatter) : "" + )); + } + writer.flush(); + } + + private String safeStr(Object value) { + return value != null ? value.toString() : ""; + } + + private String escapeCsvField(String value) { + if (value == null) { + return ""; + } + if (value.contains(",") || value.contains("\"") || value.contains("\n")) { + return "\"" + value.replace("\"", "\"\"") + "\""; + } + return value; + } + + // ── Job 한글 표시명 관리 API ────────────────────────────────── + + @Operation(summary = "Job 표시명 전체 조회", description = "등록된 모든 Job의 한글 표시명을 조회합니다") + @GetMapping("/display-names") + public ResponseEntity> getDisplayNames() { + log.debug("Received request to get all display names"); + return ResponseEntity.ok(jobDisplayNameRepository.findAll()); + } + + @Operation(summary = "Job 표시명 수정", description = "특정 Job의 한글 표시명을 수정합니다") + @PutMapping("/display-names/{jobName}") + public ResponseEntity> updateDisplayName( + @Parameter(description = "배치 작업 이름", required = true) @PathVariable String jobName, + @RequestBody Map request) { + log.info("Update display name: jobName={}", jobName); + try { + String displayName = request.get("displayName"); + if (displayName == null || displayName.isBlank()) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "message", "displayName은 필수입니다")); + } + + JobDisplayNameEntity entity = jobDisplayNameRepository.findByJobName(jobName) + .orElseGet(() -> JobDisplayNameEntity.builder().jobName(jobName).build()); + entity.setDisplayName(displayName); + jobDisplayNameRepository.save(entity); + + batchService.refreshDisplayNameCache(); + + return ResponseEntity.ok(Map.of( + "success", true, + "message", "표시명이 수정되었습니다", + "data", Map.of("jobName", jobName, "displayName", displayName))); + } catch (Exception e) { + log.error("Error updating display name: jobName={}", jobName, e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, + "message", "표시명 수정 실패: " + e.getMessage())); + } + } +} diff --git a/src/main/java/com/snp/batch/global/controller/WebViewController.java b/src/main/java/com/snp/batch/global/controller/WebViewController.java new file mode 100644 index 0000000..0046598 --- /dev/null +++ b/src/main/java/com/snp/batch/global/controller/WebViewController.java @@ -0,0 +1,23 @@ +package com.snp.batch.global.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +/** + * SPA(React) fallback 라우터 + * + * React Router가 클라이언트 사이드 라우팅을 처리하므로, + * 모든 프론트 경로를 index.html로 포워딩한다. + */ +@Controller +public class WebViewController { + + @GetMapping({"/", "/dashboard", "/jobs", "/executions", "/executions/{id:\\d+}", + "/recollects", "/recollects/{id:\\d+}", + "/schedules", "/schedule-timeline", "/monitoring", + "/dashboard/**", "/jobs/**", "/executions/**", "/recollects/**", + "/schedules/**", "/schedule-timeline/**", "/monitoring/**"}) + public String forward() { + return "forward:/index.html"; + } +} diff --git a/src/main/java/com/snp/batch/global/dto/DashboardResponse.java b/src/main/java/com/snp/batch/global/dto/DashboardResponse.java new file mode 100644 index 0000000..6916a5c --- /dev/null +++ b/src/main/java/com/snp/batch/global/dto/DashboardResponse.java @@ -0,0 +1,78 @@ +package com.snp.batch.global.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DashboardResponse { + private Stats stats; + private List runningJobs; + private List recentExecutions; + private List recentFailures; + private int staleExecutionCount; + private FailureStats failureStats; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class Stats { + private int totalSchedules; + private int activeSchedules; + private int inactiveSchedules; + private int totalJobs; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class RunningJob { + private String jobName; + private Long executionId; + private String status; + private LocalDateTime startTime; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class RecentExecution { + private Long executionId; + private String jobName; + private String status; + private LocalDateTime startTime; + private LocalDateTime endTime; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class RecentFailure { + private Long executionId; + private String jobName; + private String status; + private LocalDateTime startTime; + private LocalDateTime endTime; + private String exitMessage; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class FailureStats { + private int last24h; + private int last7d; + } +} diff --git a/src/main/java/com/snp/batch/global/dto/ExecutionSearchResponse.java b/src/main/java/com/snp/batch/global/dto/ExecutionSearchResponse.java new file mode 100644 index 0000000..aed63f3 --- /dev/null +++ b/src/main/java/com/snp/batch/global/dto/ExecutionSearchResponse.java @@ -0,0 +1,21 @@ +package com.snp.batch.global.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ExecutionSearchResponse { + + private List executions; + private int totalCount; + private int page; + private int size; + private int totalPages; +} diff --git a/src/main/java/com/snp/batch/global/dto/ExecutionStatisticsDto.java b/src/main/java/com/snp/batch/global/dto/ExecutionStatisticsDto.java new file mode 100644 index 0000000..6dc7b5f --- /dev/null +++ b/src/main/java/com/snp/batch/global/dto/ExecutionStatisticsDto.java @@ -0,0 +1,33 @@ +package com.snp.batch.global.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ExecutionStatisticsDto { + + private List dailyStats; + private int totalExecutions; + private int totalSuccess; + private int totalFailed; + private double avgDurationMs; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class DailyStat { + private String date; + private int successCount; + private int failedCount; + private int otherCount; + private double avgDurationMs; + } +} diff --git a/src/main/java/com/snp/batch/global/dto/JobDetailDto.java b/src/main/java/com/snp/batch/global/dto/JobDetailDto.java new file mode 100644 index 0000000..63f96ad --- /dev/null +++ b/src/main/java/com/snp/batch/global/dto/JobDetailDto.java @@ -0,0 +1,31 @@ +package com.snp.batch.global.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class JobDetailDto { + + private String jobName; + private String displayName; + private LastExecution lastExecution; + private String scheduleCron; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class LastExecution { + private Long executionId; + private String status; + private LocalDateTime startTime; + private LocalDateTime endTime; + } +} diff --git a/src/main/java/com/snp/batch/global/dto/JobExecutionDetailDto.java b/src/main/java/com/snp/batch/global/dto/JobExecutionDetailDto.java new file mode 100644 index 0000000..7441a35 --- /dev/null +++ b/src/main/java/com/snp/batch/global/dto/JobExecutionDetailDto.java @@ -0,0 +1,160 @@ +package com.snp.batch.global.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * Job 실행 상세 정보 DTO + * JobExecution + StepExecution 정보 포함 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class JobExecutionDetailDto { + + // Job Execution 기본 정보 + private Long executionId; + private String jobName; + private String status; + private LocalDateTime startTime; + private LocalDateTime endTime; + private String exitCode; + private String exitMessage; + + // Job Parameters + private Map jobParameters; + + // Job Instance 정보 + private Long jobInstanceId; + + // 실행 통계 + private Long duration; // 실행 시간 (ms) + private Integer readCount; + private Integer writeCount; + private Integer skipCount; + private Integer filterCount; + + // Step 실행 정보 + private List stepExecutions; + + /** + * Step 실행 정보 DTO + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class StepExecutionDto { + private Long stepExecutionId; + private String stepName; + private String status; + private LocalDateTime startTime; + private LocalDateTime endTime; + private Integer readCount; + private Integer writeCount; + private Integer commitCount; + private Integer rollbackCount; + private Integer readSkipCount; + private Integer processSkipCount; + private Integer writeSkipCount; + private Integer filterCount; + private String exitCode; + private String exitMessage; + private Long duration; // 실행 시간 (ms) + private ApiCallInfo apiCallInfo; // API 호출 정보 - StepExecutionContext 기반 (옵셔널) + private StepApiLogSummary apiLogSummary; // API 호출 로그 요약 - batch_api_log 기반 (옵셔널) + private List failedRecords; // 실패 레코드 (옵셔널) + } + + /** + * API 호출 정보 DTO (StepExecutionContext 기반) + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ApiCallInfo { + private String apiUrl; // API URL + private String method; // HTTP Method (GET, POST, etc.) + private Map parameters; // API 파라미터 + private Integer totalCalls; // 전체 API 호출 횟수 + private Integer completedCalls; // 완료된 API 호출 횟수 + private String lastCallTime; // 마지막 호출 시간 + } + + /** + * Step별 API 로그 집계 요약 (batch_api_log 테이블 기반) + * 개별 로그 목록은 별도 API(/api/batch/steps/{id}/api-logs)로 페이징 조회 + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class StepApiLogSummary { + private Long totalCalls; // 총 호출수 + private Long successCount; // 성공(2xx) 수 + private Long errorCount; // 에러(4xx/5xx) 수 + private Double avgResponseMs; // 평균 응답시간 + private Long maxResponseMs; // 최대 응답시간 + private Long minResponseMs; // 최소 응답시간 + private Long totalResponseMs; // 총 응답시간 + private Long totalRecordCount; // 총 반환 건수 + } + + /** + * 실패 레코드 DTO + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class FailedRecordDto { + private Long id; + private String jobName; + private String recordKey; + private String errorMessage; + private Integer retryCount; + private String status; + private LocalDateTime createdAt; + } + + /** + * API 호출 로그 페이징 응답 + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ApiLogPageResponse { + private List content; + private int page; + private int size; + private long totalElements; + private int totalPages; + } + + /** + * 개별 API 호출 로그 DTO (batch_api_log 1건) + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ApiLogEntryDto { + private Long logId; + private String requestUri; + private String httpMethod; + private Integer statusCode; + private Long responseTimeMs; + private Long responseCount; + private String errorMessage; + private LocalDateTime createdAt; + } +} diff --git a/src/main/java/com/snp/batch/global/dto/JobExecutionDto.java b/src/main/java/com/snp/batch/global/dto/JobExecutionDto.java new file mode 100644 index 0000000..2bc6370 --- /dev/null +++ b/src/main/java/com/snp/batch/global/dto/JobExecutionDto.java @@ -0,0 +1,24 @@ +package com.snp.batch.global.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class JobExecutionDto { + + private Long executionId; + private String jobName; + private String status; + private LocalDateTime startTime; + private LocalDateTime endTime; + private String exitCode; + private String exitMessage; + private Long failedRecordCount; +} diff --git a/src/main/java/com/snp/batch/global/dto/JobLaunchRequest.java b/src/main/java/com/snp/batch/global/dto/JobLaunchRequest.java new file mode 100644 index 0000000..0c18a36 --- /dev/null +++ b/src/main/java/com/snp/batch/global/dto/JobLaunchRequest.java @@ -0,0 +1,16 @@ +package com.snp.batch.global.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; +import org.springframework.stereotype.Service; + +@Getter +@Setter +public class JobLaunchRequest { + @Schema(description = "조회 시작일 (ISO 8601)", example = "2023-12-01T00:00:00Z") + private String startDate; + + @Schema(description = "조회 종료일 (ISO 8601)", example = "2023-12-02T00:00:00Z") + private String stopDate; +} diff --git a/src/main/java/com/snp/batch/global/dto/LastCollectionStatusResponse.java b/src/main/java/com/snp/batch/global/dto/LastCollectionStatusResponse.java new file mode 100644 index 0000000..18b3bbc --- /dev/null +++ b/src/main/java/com/snp/batch/global/dto/LastCollectionStatusResponse.java @@ -0,0 +1,21 @@ +package com.snp.batch.global.dto; + +import java.time.LocalDateTime; + +/** + * 마지막 수집 성공일시 모니터링 응답 DTO + * + * @param apiKey API 식별 키 (예: "EVENT_IMPORT_API") + * @param apiDesc API 설명 (사용자 표시용, 예: "해양사건 수집") + * @param lastSuccessDate 마지막 수집 성공 일시 + * @param updatedAt 레코드 최종 수정 일시 + * @param elapsedMinutes 현재 시각 기준 경과 시간(분) + */ +public record LastCollectionStatusResponse( + String apiKey, + String apiDesc, + LocalDateTime lastSuccessDate, + LocalDateTime updatedAt, + long elapsedMinutes +) { +} diff --git a/src/main/java/com/snp/batch/global/dto/ScheduleRequest.java b/src/main/java/com/snp/batch/global/dto/ScheduleRequest.java new file mode 100644 index 0000000..aee9a5f --- /dev/null +++ b/src/main/java/com/snp/batch/global/dto/ScheduleRequest.java @@ -0,0 +1,46 @@ +package com.snp.batch.global.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 스케줄 등록/수정 요청 DTO + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ScheduleRequest { + + /** + * 배치 작업 이름 + * 예: "jsonToPostgresJob", "shipDataImportJob" + */ + private String jobName; + + /** + * Cron 표현식 + * 예: "0 0 2 * * ?" (매일 새벽 2시) + * "0 0 * * * ?" (매 시간) + * "0 0/30 * * * ?" (30분마다) + */ + private String cronExpression; + + /** + * 스케줄 설명 (선택) + */ + private String description; + + /** + * 활성화 여부 (선택, 기본값 true) + */ + @Builder.Default + private Boolean active = true; + + /** + * 생성자/수정자 정보 (선택) + */ + private String updatedBy; +} diff --git a/src/main/java/com/snp/batch/global/dto/ScheduleResponse.java b/src/main/java/com/snp/batch/global/dto/ScheduleResponse.java new file mode 100644 index 0000000..5436b05 --- /dev/null +++ b/src/main/java/com/snp/batch/global/dto/ScheduleResponse.java @@ -0,0 +1,80 @@ +package com.snp.batch.global.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.Date; + +/** + * 스케줄 조회 응답 DTO + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ScheduleResponse { + + /** + * 스케줄 ID + */ + private Long id; + + /** + * 배치 작업 이름 + */ + private String jobName; + + /** + * Cron 표현식 + */ + private String cronExpression; + + /** + * 스케줄 설명 + */ + private String description; + + /** + * 활성화 여부 + */ + private Boolean active; + + /** + * 다음 실행 예정 시간 (Quartz에서 계산) + */ + private Date nextFireTime; + + /** + * 이전 실행 시간 (Quartz에서 조회) + */ + private Date previousFireTime; + + /** + * Quartz Trigger 상태 + * NORMAL, PAUSED, COMPLETE, ERROR, BLOCKED, NONE + */ + private String triggerState; + + /** + * 생성 일시 + */ + private LocalDateTime createdAt; + + /** + * 수정 일시 + */ + private LocalDateTime updatedAt; + + /** + * 생성자 + */ + private String createdBy; + + /** + * 수정자 + */ + private String updatedBy; +} diff --git a/src/main/java/com/snp/batch/global/dto/TimelineResponse.java b/src/main/java/com/snp/batch/global/dto/TimelineResponse.java new file mode 100644 index 0000000..157e860 --- /dev/null +++ b/src/main/java/com/snp/batch/global/dto/TimelineResponse.java @@ -0,0 +1,48 @@ +package com.snp.batch.global.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TimelineResponse { + private String periodLabel; + private List periods; + private List schedules; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class PeriodInfo { + private String key; + private String label; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ScheduleTimeline { + private String jobName; + private Map executions; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ExecutionInfo { + private Long executionId; + private String status; + private String startTime; + private String endTime; + } +} diff --git a/src/main/java/com/snp/batch/global/model/BatchApiLog.java b/src/main/java/com/snp/batch/global/model/BatchApiLog.java new file mode 100644 index 0000000..2b124e3 --- /dev/null +++ b/src/main/java/com/snp/batch/global/model/BatchApiLog.java @@ -0,0 +1,46 @@ +package com.snp.batch.global.model; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "batch_api_log", schema = "std_snp_data") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class BatchApiLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) // PostgreSQL BIGSERIAL과 매핑 + private Long logId; + + @Column(name = "api_request_location") // job_name에서 변경 + private String apiRequestLocation; + + @Column(columnDefinition = "TEXT", nullable = false) + private String requestUri; + + @Column(nullable = false, length = 10) + private String httpMethod; + + private Integer statusCode; + + private Long responseTimeMs; + + @Column(name = "response_count") + private Long responseCount; + + @Column(columnDefinition = "TEXT") + private String errorMessage; + + @CreationTimestamp // 엔티티가 생성될 때 자동으로 시간 설정 + @Column(updatable = false) + private LocalDateTime createdAt; + + private Long jobExecutionId; // 추가 + private Long stepExecutionId; // 추가 +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/global/model/BatchCollectionPeriod.java b/src/main/java/com/snp/batch/global/model/BatchCollectionPeriod.java new file mode 100644 index 0000000..39dcf84 --- /dev/null +++ b/src/main/java/com/snp/batch/global/model/BatchCollectionPeriod.java @@ -0,0 +1,53 @@ +package com.snp.batch.global.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@Table(name = "BATCH_COLLECTION_PERIOD") +@EntityListeners(AuditingEntityListener.class) +public class BatchCollectionPeriod { + + @Id + @Column(name = "API_KEY", length = 50) + private String apiKey; + + @Column(name = "API_KEY_NAME", length = 100) + private String apiKeyName; + + @Column(name = "JOB_NAME", length = 100) + private String jobName; + + @Column(name = "ORDER_SEQ") + private Integer orderSeq; + + @Column(name = "RANGE_FROM_DATE") + private LocalDateTime rangeFromDate; + + @Column(name = "RANGE_TO_DATE") + private LocalDateTime rangeToDate; + + @CreatedDate + @Column(name = "CREATED_AT", updatable = false, nullable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "UPDATED_AT", nullable = false) + private LocalDateTime updatedAt; + + public BatchCollectionPeriod(String apiKey, LocalDateTime rangeFromDate, LocalDateTime rangeToDate) { + this.apiKey = apiKey; + this.rangeFromDate = rangeFromDate; + this.rangeToDate = rangeToDate; + } +} diff --git a/src/main/java/com/snp/batch/global/model/BatchFailedRecord.java b/src/main/java/com/snp/batch/global/model/BatchFailedRecord.java new file mode 100644 index 0000000..de5610c --- /dev/null +++ b/src/main/java/com/snp/batch/global/model/BatchFailedRecord.java @@ -0,0 +1,46 @@ +package com.snp.batch.global.model; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "batch_failed_record", schema = "std_snp_data") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class BatchFailedRecord { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String jobName; + + private Long jobExecutionId; + + private Long stepExecutionId; + + @Column(nullable = false) + private String recordKey; + + @Column(columnDefinition = "TEXT") + private String errorMessage; + + @Column(nullable = false) + private Integer retryCount; + + @Column(nullable = false, length = 20) + @Builder.Default + private String status = "FAILED"; + + @CreationTimestamp + @Column(updatable = false) + private LocalDateTime createdAt; + + private LocalDateTime resolvedAt; +} diff --git a/src/main/java/com/snp/batch/global/model/BatchLastExecution.java b/src/main/java/com/snp/batch/global/model/BatchLastExecution.java new file mode 100644 index 0000000..43160b0 --- /dev/null +++ b/src/main/java/com/snp/batch/global/model/BatchLastExecution.java @@ -0,0 +1,49 @@ +package com.snp.batch.global.model; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.*; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@Table(name = "BATCH_LAST_EXECUTION") +@EntityListeners(AuditingEntityListener.class) +public class BatchLastExecution { + @Id + @Column(name = "API_KEY", length = 50) + private String apiKey; + + @Column(name = "API_DESC", length = 100) + private String apiDesc; + + @Column(name = "LAST_SUCCESS_DATE", nullable = false) + private LocalDateTime lastSuccessDate; + + @Column(name = "RANGE_FROM_DATE", nullable = true) + private LocalDateTime rangeFromDate; + + @Column(name = "RANGE_TO_DATE", nullable = true) + private LocalDateTime rangeToDate; + + @CreatedDate + @Column(name = "CREATED_AT", updatable = false, nullable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "UPDATED_AT", nullable = false) + private LocalDateTime updatedAt; + + public BatchLastExecution(String apiKey, LocalDateTime lastSuccessDate) { + this.apiKey = apiKey; + this.lastSuccessDate = lastSuccessDate; + } +} diff --git a/src/main/java/com/snp/batch/global/model/BatchRecollectionHistory.java b/src/main/java/com/snp/batch/global/model/BatchRecollectionHistory.java new file mode 100644 index 0000000..e27e276 --- /dev/null +++ b/src/main/java/com/snp/batch/global/model/BatchRecollectionHistory.java @@ -0,0 +1,93 @@ +package com.snp.batch.global.model; + +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Table(name = "BATCH_RECOLLECTION_HISTORY") +@EntityListeners(AuditingEntityListener.class) +public class BatchRecollectionHistory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "HISTORY_ID") + private Long historyId; + + @Column(name = "API_KEY", length = 50, nullable = false) + private String apiKey; + + @Column(name = "API_KEY_NAME", length = 100) + private String apiKeyName; + + @Column(name = "JOB_NAME", length = 100, nullable = false) + private String jobName; + + @Column(name = "JOB_EXECUTION_ID") + private Long jobExecutionId; + + @Column(name = "RANGE_FROM_DATE") + private LocalDateTime rangeFromDate; + + @Column(name = "RANGE_TO_DATE") + private LocalDateTime rangeToDate; + + @Column(name = "EXECUTION_STATUS", length = 20, nullable = false) + private String executionStatus; + + @Column(name = "EXECUTION_START_TIME") + private LocalDateTime executionStartTime; + + @Column(name = "EXECUTION_END_TIME") + private LocalDateTime executionEndTime; + + @Column(name = "DURATION_MS") + private Long durationMs; + + @Column(name = "READ_COUNT") + private Long readCount; + + @Column(name = "WRITE_COUNT") + private Long writeCount; + + @Column(name = "SKIP_COUNT") + private Long skipCount; + + @Column(name = "API_CALL_COUNT") + private Integer apiCallCount; + + @Column(name = "TOTAL_RESPONSE_TIME_MS") + private Long totalResponseTimeMs; + + @Column(name = "EXECUTOR", length = 50) + private String executor; + + @Column(name = "RECOLLECTION_REASON", columnDefinition = "TEXT") + private String recollectionReason; + + @Column(name = "FAILURE_REASON", columnDefinition = "TEXT") + private String failureReason; + + @Column(name = "HAS_OVERLAP") + private Boolean hasOverlap; + + @Column(name = "OVERLAPPING_HISTORY_IDS", length = 500) + private String overlappingHistoryIds; + + @CreatedDate + @Column(name = "CREATED_AT", updatable = false, nullable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "UPDATED_AT", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/snp/batch/global/model/JobDisplayNameEntity.java b/src/main/java/com/snp/batch/global/model/JobDisplayNameEntity.java new file mode 100644 index 0000000..7e77d12 --- /dev/null +++ b/src/main/java/com/snp/batch/global/model/JobDisplayNameEntity.java @@ -0,0 +1,47 @@ +package com.snp.batch.global.model; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * 배치 작업 한글 표시명을 관리하는 엔티티 + * 모든 화면(작업 목록, 재수집 이력 등)에서 공통으로 사용 + */ +@Entity +@Table(name = "job_display_name", indexes = { + @Index(name = "idx_job_display_name_job_name", columnList = "job_name", unique = true), + @Index(name = "idx_job_display_name_api_key", columnList = "api_key", unique = true) +}) +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class JobDisplayNameEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * 배치 작업 Bean 이름 + */ + @Column(name = "job_name", unique = true, nullable = false, length = 100) + private String jobName; + + /** + * 한글 표시명 + */ + @Column(name = "display_name", nullable = false, length = 200) + private String displayName; + + /** + * 외부 API 키 (batch_collection_period.api_key 연동용, nullable) + */ + @Column(name = "api_key", unique = true, length = 50) + private String apiKey; +} diff --git a/src/main/java/com/snp/batch/global/model/JobScheduleEntity.java b/src/main/java/com/snp/batch/global/model/JobScheduleEntity.java new file mode 100644 index 0000000..3d02637 --- /dev/null +++ b/src/main/java/com/snp/batch/global/model/JobScheduleEntity.java @@ -0,0 +1,110 @@ +package com.snp.batch.global.model; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 배치 작업 스케줄 정보를 저장하는 엔티티 + * Quartz 스케줄러와 연동하여 DB에 영속화 + * + * JPA를 사용하므로 @PrePersist, @PreUpdate로 감사 필드 자동 설정 + */ +@Entity +@Table(name = "job_schedule", indexes = { + @Index(name = "idx_job_name", columnList = "job_name", unique = true), + @Index(name = "idx_active", columnList = "active") +}) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class JobScheduleEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * 배치 작업 이름 (BatchConfig에 등록된 Job Bean 이름) + * 예: "jsonToPostgresJob", "shipDataImportJob" + */ + @Column(name = "job_name", unique = true, nullable = false, length = 100) + private String jobName; + + /** + * Cron 표현식 + * 예: "0 0 2 * * ?" (매일 새벽 2시) + */ + @Column(name = "cron_expression", nullable = false, length = 100) + private String cronExpression; + + /** + * 스케줄 설명 + */ + @Column(name = "description", length = 500) + private String description; + + /** + * 활성화 여부 + * true: 스케줄 활성, false: 일시 중지 + */ + @Column(name = "active", nullable = false) + @Builder.Default + private Boolean active = true; + + /** + * 생성 일시 (감사 필드) + */ + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** + * 수정 일시 (감사 필드) + */ + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + /** + * 생성자 (감사 필드) + */ + @Column(name = "created_by", length = 100) + private String createdBy; + + /** + * 수정자 (감사 필드) + */ + @Column(name = "updated_by", length = 100) + private String updatedBy; + + /** + * 엔티티 저장 전 자동 호출 (INSERT 시) + */ + @PrePersist + protected void onCreate() { + LocalDateTime now = LocalDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + if (this.createdBy == null) { + this.createdBy = "SYSTEM"; + } + if (this.updatedBy == null) { + this.updatedBy = "SYSTEM"; + } + } + + /** + * 엔티티 업데이트 전 자동 호출 (UPDATE 시) + */ + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + if (this.updatedBy == null) { + this.updatedBy = "SYSTEM"; + } + } +} diff --git a/src/main/java/com/snp/batch/global/partition/PartitionConfig.java b/src/main/java/com/snp/batch/global/partition/PartitionConfig.java new file mode 100644 index 0000000..7c4f6c5 --- /dev/null +++ b/src/main/java/com/snp/batch/global/partition/PartitionConfig.java @@ -0,0 +1,133 @@ +package com.snp.batch.global.partition; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * 파티션 관리 설정 (application.yml 기반) + * + * 설정 예시: + * app.batch.partition: + * daily-tables: + * - schema: std_snp_data + * table-name: ais_target + * partition-column: message_timestamp + * periods-ahead: 3 + * monthly-tables: + * - schema: std_snp_data + * table-name: some_table + * partition-column: created_at + * periods-ahead: 2 + * retention: + * daily-default-days: 14 + * monthly-default-months: 1 + * custom: + * - table-name: ais_target + * retention-days: 30 + */ +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "app.batch.partition") +public class PartitionConfig { + + /** + * 일별 파티션 테이블 목록 (파티션 네이밍: {table}_YYMMDD) + */ + private List dailyTables = new ArrayList<>(); + + /** + * 월별 파티션 테이블 목록 (파티션 네이밍: {table}_YYYY_MM) + */ + private List monthlyTables = new ArrayList<>(); + + /** + * 보관기간 설정 + */ + private RetentionConfig retention = new RetentionConfig(); + + /** + * 파티션 테이블 설정 + */ + @Getter + @Setter + public static class PartitionTableConfig { + private String schema = "std_snp_data"; + private String tableName; + private String partitionColumn; + private int periodsAhead = 3; // 미리 생성할 기간 수 (daily: 일, monthly: 월) + + public String getFullTableName() { + return schema + "." + tableName; + } + } + + /** + * 보관기간 설정 + */ + @Getter + @Setter + public static class RetentionConfig { + /** + * 일별 파티션 기본 보관기간 (일) + */ + private int dailyDefaultDays = 14; + + /** + * 월별 파티션 기본 보관기간 (개월) + */ + private int monthlyDefaultMonths = 1; + + /** + * 개별 테이블 보관기간 설정 + */ + private List custom = new ArrayList<>(); + } + + /** + * 개별 테이블 보관기간 설정 + */ + @Getter + @Setter + public static class CustomRetention { + private String tableName; + private Integer retentionDays; // 일 단위 보관기간 (일별 파티션용) + private Integer retentionMonths; // 월 단위 보관기간 (월별 파티션용) + } + + /** + * 일별 파티션 테이블의 보관기간 조회 (일 단위) + */ + public int getDailyRetentionDays(String tableName) { + return getCustomRetention(tableName) + .map(c -> c.getRetentionDays() != null ? c.getRetentionDays() : retention.getDailyDefaultDays()) + .orElse(retention.getDailyDefaultDays()); + } + + /** + * 월별 파티션 테이블의 보관기간 조회 (월 단위) + */ + public int getMonthlyRetentionMonths(String tableName) { + return getCustomRetention(tableName) + .map(c -> c.getRetentionMonths() != null ? c.getRetentionMonths() : retention.getMonthlyDefaultMonths()) + .orElse(retention.getMonthlyDefaultMonths()); + } + + /** + * 개별 테이블 보관기간 설정 조회 + */ + private Optional getCustomRetention(String tableName) { + if (retention.getCustom() == null) { + return Optional.empty(); + } + return retention.getCustom().stream() + .filter(c -> tableName.equals(c.getTableName())) + .findFirst(); + } +} diff --git a/src/main/java/com/snp/batch/global/partition/PartitionManagerJobConfig.java b/src/main/java/com/snp/batch/global/partition/PartitionManagerJobConfig.java new file mode 100644 index 0000000..ca132c8 --- /dev/null +++ b/src/main/java/com/snp/batch/global/partition/PartitionManagerJobConfig.java @@ -0,0 +1,68 @@ +package com.snp.batch.global.partition; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobExecutionListener; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +/** + * 파티션 관리 Job Config + * + * 스케줄: 매일 00:10 (0 10 0 * * ?) + * + * 동작: + * - Daily 파티션: 매일 실행 + * - Monthly 파티션: 매월 말일에만 실행 (Job 내부에서 말일 감지) + */ +@Slf4j +@Configuration +public class PartitionManagerJobConfig { + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final PartitionManagerTasklet partitionManagerTasklet; + + public PartitionManagerJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + PartitionManagerTasklet partitionManagerTasklet) { + this.jobRepository = jobRepository; + this.transactionManager = transactionManager; + this.partitionManagerTasklet = partitionManagerTasklet; + } + + @Bean(name = "partitionManagerStep") + public Step partitionManagerStep() { + return new StepBuilder("partitionManagerStep", jobRepository) + .tasklet(partitionManagerTasklet, transactionManager) + .build(); + } + + @Bean(name = "partitionManagerJob") + public Job partitionManagerJob() { + log.info("Job 생성: partitionManagerJob"); + + return new JobBuilder("partitionManagerJob", jobRepository) + .listener(new JobExecutionListener() { + @Override + public void beforeJob(JobExecution jobExecution) { + log.info("[partitionManagerJob] 파티션 관리 Job 시작"); + } + + @Override + public void afterJob(JobExecution jobExecution) { + log.info("[partitionManagerJob] 파티션 관리 Job 완료 - 상태: {}", + jobExecution.getStatus()); + } + }) + .start(partitionManagerStep()) + .build(); + } +} diff --git a/src/main/java/com/snp/batch/global/partition/PartitionManagerTasklet.java b/src/main/java/com/snp/batch/global/partition/PartitionManagerTasklet.java new file mode 100644 index 0000000..a6918ed --- /dev/null +++ b/src/main/java/com/snp/batch/global/partition/PartitionManagerTasklet.java @@ -0,0 +1,416 @@ +package com.snp.batch.global.partition; + +import com.snp.batch.global.partition.PartitionConfig.PartitionTableConfig; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +/** + * 파티션 관리 Tasklet + * + * 스케줄: 매일 실행 + * - Daily 파티션: 매일 생성/삭제 (네이밍: {table}_YYMMDD) + * - Monthly 파티션: 매월 말일에만 생성/삭제 (네이밍: {table}_YYYY_MM) + * + * 보관기간: + * - 기본값: 일별 14일, 월별 1개월 + * - 개별 테이블별 보관기간 설정 가능 (application.yml) + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PartitionManagerTasklet implements Tasklet { + + private final JdbcTemplate jdbcTemplate; + private final PartitionConfig partitionConfig; + + private static final DateTimeFormatter DAILY_PARTITION_FORMAT = DateTimeFormatter.ofPattern("yyMMdd"); + private static final DateTimeFormatter MONTHLY_PARTITION_FORMAT = DateTimeFormatter.ofPattern("yyyy_MM"); + + private static final String PARTITION_EXISTS_SQL = """ + SELECT EXISTS ( + SELECT 1 FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = ? + AND c.relname = ? + AND c.relkind = 'r' + ) + """; + + private static final String FIND_PARTITIONS_SQL = """ + SELECT c.relname + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + JOIN pg_inherits i ON i.inhrelid = c.oid + WHERE n.nspname = ? + AND c.relname LIKE ? + AND c.relkind = 'r' + ORDER BY c.relname + """; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { + LocalDate today = LocalDate.now(); + boolean isLastDayOfMonth = isLastDayOfMonth(today); + + log.info("========================================"); + log.info("파티션 관리 Job 시작"); + log.info("실행 일자: {}", today); + log.info("월 말일 여부: {}", isLastDayOfMonth); + log.info("========================================"); + + // 1. Daily 파티션 생성 (매일) + createDailyPartitions(today); + + // 2. Daily 파티션 삭제 (보관기간 초과분) + deleteDailyPartitions(today); + + // 3. Monthly 파티션 생성 (매월 말일만) + if (isLastDayOfMonth) { + createMonthlyPartitions(today); + } else { + log.info("Monthly 파티션 생성: 말일이 아니므로 스킵"); + } + + // 4. Monthly 파티션 삭제 (매월 1일에만, 보관기간 초과분) + if (today.getDayOfMonth() == 1) { + deleteMonthlyPartitions(today); + } else { + log.info("Monthly 파티션 삭제: 1일이 아니므로 스킵"); + } + + log.info("========================================"); + log.info("파티션 관리 Job 완료"); + log.info("========================================"); + + return RepeatStatus.FINISHED; + } + + /** + * 매월 말일 여부 확인 + */ + private boolean isLastDayOfMonth(LocalDate date) { + return date.getDayOfMonth() == YearMonth.from(date).lengthOfMonth(); + } + + // ==================== Daily 파티션 생성 ==================== + + /** + * Daily 파티션 생성 + */ + private void createDailyPartitions(LocalDate today) { + List tables = partitionConfig.getDailyTables(); + + if (tables == null || tables.isEmpty()) { + log.info("Daily 파티션 생성: 대상 테이블 없음"); + return; + } + + log.info("Daily 파티션 생성 시작: {} 개 테이블", tables.size()); + + for (PartitionTableConfig table : tables) { + createDailyPartitionsForTable(table, today); + } + } + + /** + * 개별 테이블 Daily 파티션 생성 + */ + private void createDailyPartitionsForTable(PartitionTableConfig table, LocalDate today) { + List created = new ArrayList<>(); + List skipped = new ArrayList<>(); + + for (int i = 0; i <= table.getPeriodsAhead(); i++) { + LocalDate targetDate = today.plusDays(i); + String partitionName = getDailyPartitionName(table.getTableName(), targetDate); + + if (partitionExists(table.getSchema(), partitionName)) { + skipped.add(partitionName); + } else { + createDailyPartition(table, targetDate, partitionName); + created.add(partitionName); + } + } + + log.info("[{}] Daily 파티션 생성 - 생성: {}, 스킵: {}", + table.getTableName(), created.size(), skipped.size()); + if (!created.isEmpty()) { + log.info("[{}] 생성된 파티션: {}", table.getTableName(), created); + } + } + + // ==================== Daily 파티션 삭제 ==================== + + /** + * Daily 파티션 삭제 (보관기간 초과분) + */ + private void deleteDailyPartitions(LocalDate today) { + List tables = partitionConfig.getDailyTables(); + + if (tables == null || tables.isEmpty()) { + log.info("Daily 파티션 삭제: 대상 테이블 없음"); + return; + } + + log.info("Daily 파티션 삭제 시작: {} 개 테이블", tables.size()); + + for (PartitionTableConfig table : tables) { + int retentionDays = partitionConfig.getDailyRetentionDays(table.getTableName()); + deleteDailyPartitionsForTable(table, today, retentionDays); + } + } + + /** + * 개별 테이블 Daily 파티션 삭제 + */ + private void deleteDailyPartitionsForTable(PartitionTableConfig table, LocalDate today, int retentionDays) { + LocalDate cutoffDate = today.minusDays(retentionDays); + String likePattern = table.getTableName() + "_%"; + + List partitions = jdbcTemplate.queryForList( + FIND_PARTITIONS_SQL, String.class, table.getSchema(), likePattern); + + List deleted = new ArrayList<>(); + + for (String partitionName : partitions) { + // 파티션 이름에서 날짜 추출 (table_YYMMDD) + LocalDate partitionDate = parseDailyPartitionDate(table.getTableName(), partitionName); + if (partitionDate != null && partitionDate.isBefore(cutoffDate)) { + dropPartition(table.getSchema(), partitionName); + deleted.add(partitionName); + } + } + + if (!deleted.isEmpty()) { + log.info("[{}] Daily 파티션 삭제 - 보관기간: {}일, 삭제: {} 개", + table.getTableName(), retentionDays, deleted.size()); + log.info("[{}] 삭제된 파티션: {}", table.getTableName(), deleted); + } else { + log.info("[{}] Daily 파티션 삭제 - 보관기간: {}일, 삭제할 파티션 없음", + table.getTableName(), retentionDays); + } + } + + // ==================== Monthly 파티션 생성 ==================== + + /** + * Monthly 파티션 생성 + */ + private void createMonthlyPartitions(LocalDate today) { + List tables = partitionConfig.getMonthlyTables(); + + if (tables == null || tables.isEmpty()) { + log.info("Monthly 파티션 생성: 대상 테이블 없음"); + return; + } + + log.info("Monthly 파티션 생성 시작: {} 개 테이블", tables.size()); + + for (PartitionTableConfig table : tables) { + createMonthlyPartitionsForTable(table, today); + } + } + + /** + * 개별 테이블 Monthly 파티션 생성 + */ + private void createMonthlyPartitionsForTable(PartitionTableConfig table, LocalDate today) { + List created = new ArrayList<>(); + List skipped = new ArrayList<>(); + + for (int i = 0; i <= table.getPeriodsAhead(); i++) { + LocalDate targetDate = today.plusMonths(i).withDayOfMonth(1); + String partitionName = getMonthlyPartitionName(table.getTableName(), targetDate); + + if (partitionExists(table.getSchema(), partitionName)) { + skipped.add(partitionName); + } else { + createMonthlyPartition(table, targetDate, partitionName); + created.add(partitionName); + } + } + + log.info("[{}] Monthly 파티션 생성 - 생성: {}, 스킵: {}", + table.getTableName(), created.size(), skipped.size()); + if (!created.isEmpty()) { + log.info("[{}] 생성된 파티션: {}", table.getTableName(), created); + } + } + + // ==================== Monthly 파티션 삭제 ==================== + + /** + * Monthly 파티션 삭제 (보관기간 초과분) + */ + private void deleteMonthlyPartitions(LocalDate today) { + List tables = partitionConfig.getMonthlyTables(); + + if (tables == null || tables.isEmpty()) { + log.info("Monthly 파티션 삭제: 대상 테이블 없음"); + return; + } + + log.info("Monthly 파티션 삭제 시작: {} 개 테이블", tables.size()); + + for (PartitionTableConfig table : tables) { + int retentionMonths = partitionConfig.getMonthlyRetentionMonths(table.getTableName()); + deleteMonthlyPartitionsForTable(table, today, retentionMonths); + } + } + + /** + * 개별 테이블 Monthly 파티션 삭제 + */ + private void deleteMonthlyPartitionsForTable(PartitionTableConfig table, LocalDate today, int retentionMonths) { + LocalDate cutoffDate = today.minusMonths(retentionMonths).withDayOfMonth(1); + String likePattern = table.getTableName() + "_%"; + + List partitions = jdbcTemplate.queryForList( + FIND_PARTITIONS_SQL, String.class, table.getSchema(), likePattern); + + List deleted = new ArrayList<>(); + + for (String partitionName : partitions) { + // 파티션 이름에서 날짜 추출 (table_YYYY_MM) + LocalDate partitionDate = parseMonthlyPartitionDate(table.getTableName(), partitionName); + if (partitionDate != null && partitionDate.isBefore(cutoffDate)) { + dropPartition(table.getSchema(), partitionName); + deleted.add(partitionName); + } + } + + if (!deleted.isEmpty()) { + log.info("[{}] Monthly 파티션 삭제 - 보관기간: {}개월, 삭제: {} 개", + table.getTableName(), retentionMonths, deleted.size()); + log.info("[{}] 삭제된 파티션: {}", table.getTableName(), deleted); + } else { + log.info("[{}] Monthly 파티션 삭제 - 보관기간: {}개월, 삭제할 파티션 없음", + table.getTableName(), retentionMonths); + } + } + + // ==================== 파티션 이름 생성 ==================== + + /** + * Daily 파티션 이름 생성 (table_YYMMDD) + */ + private String getDailyPartitionName(String tableName, LocalDate date) { + return tableName + "_" + date.format(DAILY_PARTITION_FORMAT); + } + + /** + * Monthly 파티션 이름 생성 (table_YYYY_MM) + */ + private String getMonthlyPartitionName(String tableName, LocalDate date) { + return tableName + "_" + date.format(MONTHLY_PARTITION_FORMAT); + } + + // ==================== 파티션 이름에서 날짜 추출 ==================== + + /** + * Daily 파티션 이름에서 날짜 추출 (table_YYMMDD -> LocalDate) + */ + private LocalDate parseDailyPartitionDate(String tableName, String partitionName) { + try { + String prefix = tableName + "_"; + if (!partitionName.startsWith(prefix)) { + return null; + } + String dateStr = partitionName.substring(prefix.length()); + // YYMMDD 형식 (6자리) + if (dateStr.length() == 6 && dateStr.matches("\\d{6}")) { + return LocalDate.parse(dateStr, DAILY_PARTITION_FORMAT); + } + return null; + } catch (Exception e) { + log.trace("파티션 날짜 파싱 실패: {}", partitionName); + return null; + } + } + + /** + * Monthly 파티션 이름에서 날짜 추출 (table_YYYY_MM -> LocalDate) + */ + private LocalDate parseMonthlyPartitionDate(String tableName, String partitionName) { + try { + String prefix = tableName + "_"; + if (!partitionName.startsWith(prefix)) { + return null; + } + String dateStr = partitionName.substring(prefix.length()); + // YYYY_MM 형식 (7자리) + if (dateStr.length() == 7 && dateStr.matches("\\d{4}_\\d{2}")) { + return LocalDate.parse(dateStr + "_01", DateTimeFormatter.ofPattern("yyyy_MM_dd")); + } + return null; + } catch (Exception e) { + log.trace("파티션 날짜 파싱 실패: {}", partitionName); + return null; + } + } + + // ==================== DB 작업 ==================== + + /** + * 파티션 존재 여부 확인 + */ + private boolean partitionExists(String schema, String partitionName) { + Boolean exists = jdbcTemplate.queryForObject(PARTITION_EXISTS_SQL, Boolean.class, schema, partitionName); + return Boolean.TRUE.equals(exists); + } + + /** + * Daily 파티션 생성 + */ + private void createDailyPartition(PartitionTableConfig table, LocalDate targetDate, String partitionName) { + LocalDate endDate = targetDate.plusDays(1); + + String sql = String.format(""" + CREATE TABLE %s.%s PARTITION OF %s + FOR VALUES FROM ('%s 00:00:00+00') TO ('%s 00:00:00+00') + """, + table.getSchema(), partitionName, table.getFullTableName(), + targetDate, endDate); + + jdbcTemplate.execute(sql); + log.debug("Daily 파티션 생성: {}", partitionName); + } + + /** + * Monthly 파티션 생성 + */ + private void createMonthlyPartition(PartitionTableConfig table, LocalDate targetDate, String partitionName) { + LocalDate startDate = targetDate.withDayOfMonth(1); + LocalDate endDate = startDate.plusMonths(1); + + String sql = String.format(""" + CREATE TABLE %s.%s PARTITION OF %s + FOR VALUES FROM ('%s 00:00:00+00') TO ('%s 00:00:00+00') + """, + table.getSchema(), partitionName, table.getFullTableName(), + startDate, endDate); + + jdbcTemplate.execute(sql); + log.debug("Monthly 파티션 생성: {}", partitionName); + } + + /** + * 파티션 삭제 + */ + private void dropPartition(String schema, String partitionName) { + String sql = String.format("DROP TABLE IF EXISTS %s.%s", schema, partitionName); + jdbcTemplate.execute(sql); + log.debug("파티션 삭제: {}", partitionName); + } +} diff --git a/src/main/java/com/snp/batch/global/projection/DateRangeProjection.java b/src/main/java/com/snp/batch/global/projection/DateRangeProjection.java new file mode 100644 index 0000000..a4f5412 --- /dev/null +++ b/src/main/java/com/snp/batch/global/projection/DateRangeProjection.java @@ -0,0 +1,9 @@ +package com.snp.batch.global.projection; + +import java.time.LocalDateTime; + +public interface DateRangeProjection { + LocalDateTime getLastSuccessDate(); + LocalDateTime getRangeFromDate(); + LocalDateTime getRangeToDate(); +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/global/repository/BatchApiLogRepository.java b/src/main/java/com/snp/batch/global/repository/BatchApiLogRepository.java new file mode 100644 index 0000000..96514ba --- /dev/null +++ b/src/main/java/com/snp/batch/global/repository/BatchApiLogRepository.java @@ -0,0 +1,77 @@ +package com.snp.batch.global.repository; + +import com.snp.batch.global.model.BatchApiLog; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface BatchApiLogRepository extends JpaRepository { + + @Query(""" + SELECT COUNT(l), + COALESCE(SUM(l.responseTimeMs), 0), + COALESCE(AVG(l.responseTimeMs), 0), + COALESCE(MAX(l.responseTimeMs), 0), + COALESCE(MIN(l.responseTimeMs), 0) + FROM BatchApiLog l + WHERE l.jobExecutionId = :jobExecutionId + """) + List getApiStatsByJobExecutionId(@Param("jobExecutionId") Long jobExecutionId); + + /** + * Step별 API 호출 통계 집계 + */ + @Query(""" + SELECT COUNT(l), + SUM(CASE WHEN l.statusCode >= 200 AND l.statusCode < 300 THEN 1 ELSE 0 END), + SUM(CASE WHEN l.statusCode >= 400 OR l.errorMessage IS NOT NULL THEN 1 ELSE 0 END), + COALESCE(AVG(l.responseTimeMs), 0), + COALESCE(MAX(l.responseTimeMs), 0), + COALESCE(MIN(l.responseTimeMs), 0), + COALESCE(SUM(l.responseTimeMs), 0), + COALESCE(SUM(l.responseCount), 0) + FROM BatchApiLog l + WHERE l.stepExecutionId = :stepExecutionId + """) + List getApiStatsByStepExecutionId(@Param("stepExecutionId") Long stepExecutionId); + + /** + * Step별 개별 API 호출 로그 목록 + */ + List findByStepExecutionIdOrderByCreatedAtAsc(Long stepExecutionId); + + /** + * Step별 개별 API 호출 로그 - 페이징 (전체) + */ + Page findByStepExecutionIdOrderByCreatedAtAsc(Long stepExecutionId, Pageable pageable); + + /** + * Step별 성공 로그 - 페이징 (statusCode 200~299) + */ + @Query(""" + SELECT l FROM BatchApiLog l + WHERE l.stepExecutionId = :stepExecutionId + AND l.statusCode >= 200 AND l.statusCode < 300 + ORDER BY l.createdAt ASC + """) + Page findSuccessByStepExecutionId( + @Param("stepExecutionId") Long stepExecutionId, Pageable pageable); + + /** + * Step별 에러 로그 - 페이징 (statusCode >= 400 OR errorMessage 존재) + */ + @Query(""" + SELECT l FROM BatchApiLog l + WHERE l.stepExecutionId = :stepExecutionId + AND (l.statusCode >= 400 OR l.errorMessage IS NOT NULL) + ORDER BY l.createdAt ASC + """) + Page findErrorByStepExecutionId( + @Param("stepExecutionId") Long stepExecutionId, Pageable pageable); +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/global/repository/BatchCollectionPeriodRepository.java b/src/main/java/com/snp/batch/global/repository/BatchCollectionPeriodRepository.java new file mode 100644 index 0000000..b3b5ba8 --- /dev/null +++ b/src/main/java/com/snp/batch/global/repository/BatchCollectionPeriodRepository.java @@ -0,0 +1,16 @@ +package com.snp.batch.global.repository; + +import com.snp.batch.global.model.BatchCollectionPeriod; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface BatchCollectionPeriodRepository extends JpaRepository { + + List findAllByOrderByOrderSeqAsc(); + + Optional findByJobName(String jobName); +} diff --git a/src/main/java/com/snp/batch/global/repository/BatchFailedRecordRepository.java b/src/main/java/com/snp/batch/global/repository/BatchFailedRecordRepository.java new file mode 100644 index 0000000..d5c7a0c --- /dev/null +++ b/src/main/java/com/snp/batch/global/repository/BatchFailedRecordRepository.java @@ -0,0 +1,160 @@ +package com.snp.batch.global.repository; + +import com.snp.batch.global.model.BatchFailedRecord; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public interface BatchFailedRecordRepository extends JpaRepository { + + /** + * 특정 Job의 상태별 실패 건 조회 + */ + List findByJobNameAndStatus(String jobName, String status); + + /** + * 실행별 실패 레코드 조회 + */ + List findByJobExecutionId(Long jobExecutionId); + + /** + * Step별 실패 레코드 조회 + */ + List findByStepExecutionId(Long stepExecutionId); + + /** + * 여러 Step에 대한 실패 레코드 일괄 조회 (N+1 방지) + */ + List findByStepExecutionIdIn(List stepExecutionIds); + + /** + * 실행별 실패 건수 + */ + long countByJobExecutionId(Long jobExecutionId); + + /** + * 여러 jobExecutionId에 대해 FAILED 상태 건수를 한번에 조회 (N+1 방지) + */ + @Query("SELECT r.jobExecutionId, COUNT(r) FROM BatchFailedRecord r " + + "WHERE r.jobExecutionId IN :jobExecutionIds AND r.status = 'FAILED' " + + "GROUP BY r.jobExecutionId") + List countFailedByJobExecutionIds(@Param("jobExecutionIds") List jobExecutionIds); + + /** + * 특정 Step 실행의 실패 레코드를 RESOLVED로 벌크 업데이트 + */ + @Modifying + @Query("UPDATE BatchFailedRecord r SET r.status = 'RESOLVED', r.resolvedAt = :resolvedAt " + + "WHERE r.jobName = :jobName AND r.stepExecutionId = :stepExecutionId " + + "AND r.recordKey IN :recordKeys AND r.status = 'FAILED'") + int resolveByStepExecutionIdAndRecordKeys( + @Param("jobName") String jobName, + @Param("stepExecutionId") Long stepExecutionId, + @Param("recordKeys") List recordKeys, + @Param("resolvedAt") LocalDateTime resolvedAt); + + /** + * 특정 Job 실행의 실패 레코드를 RESOLVED로 벌크 업데이트 + */ + @Modifying + @Query("UPDATE BatchFailedRecord r SET r.status = 'RESOLVED', r.resolvedAt = :resolvedAt " + + "WHERE r.jobName = :jobName AND r.jobExecutionId = :jobExecutionId " + + "AND r.recordKey IN :recordKeys AND r.status = 'FAILED'") + int resolveByJobExecutionIdAndRecordKeys( + @Param("jobName") String jobName, + @Param("jobExecutionId") Long jobExecutionId, + @Param("recordKeys") List recordKeys, + @Param("resolvedAt") LocalDateTime resolvedAt); + + /** + * ID 목록으로 FAILED 상태 레코드를 일괄 RESOLVED 처리 + */ + @Modifying + @Query("UPDATE BatchFailedRecord r SET r.status = 'RESOLVED', r.resolvedAt = :resolvedAt " + + "WHERE r.id IN :ids AND r.status = 'FAILED'") + int resolveByIds(@Param("ids") List ids, @Param("resolvedAt") LocalDateTime resolvedAt); + + /** + * 최대 재시도 횟수를 초과한 recordKey 목록 조회. + * 자동 재수집 무한 루프 방지에 사용. + */ + @Query("SELECT DISTINCT r.recordKey FROM BatchFailedRecord r " + + "WHERE r.jobName = :jobName AND r.recordKey IN :recordKeys " + + "AND r.status = 'FAILED' AND r.retryCount >= :maxRetryCount") + List findExceededRetryKeys(@Param("jobName") String jobName, + @Param("recordKeys") List recordKeys, + @Param("maxRetryCount") int maxRetryCount); + + /** + * 특정 Step 실행의 미해결(FAILED) 실패 레코드 키 목록 조회. + * 자동 재수집 시 JobParameter 대신 DB에서 직접 조회하여 VARCHAR(2500) 제한을 우회. + */ + @Query("SELECT r.recordKey FROM BatchFailedRecord r " + + "WHERE r.jobName = :jobName AND r.stepExecutionId = :stepExecutionId " + + "AND r.status = 'FAILED'") + List findFailedRecordKeysByStepExecutionId( + @Param("jobName") String jobName, + @Param("stepExecutionId") Long stepExecutionId); + + /** + * 특정 Job 실행의 미해결(FAILED) 실패 레코드 키 목록 조회. + * 자동 재수집 시 JobParameter 대신 DB에서 직접 조회하여 VARCHAR(2500) 제한을 우회. + */ + @Query("SELECT r.recordKey FROM BatchFailedRecord r " + + "WHERE r.jobName = :jobName AND r.jobExecutionId = :jobExecutionId " + + "AND r.status = 'FAILED'") + List findFailedRecordKeysByJobExecutionId( + @Param("jobName") String jobName, + @Param("jobExecutionId") Long jobExecutionId); + + /** + * 동일 Job에서 이미 FAILED 상태인 recordKey 목록 조회 (중복 방지용) + */ + @Query("SELECT r.recordKey FROM BatchFailedRecord r " + + "WHERE r.jobName = :jobName AND r.recordKey IN :recordKeys AND r.status = 'FAILED'") + List findExistingFailedKeys(@Param("jobName") String jobName, + @Param("recordKeys") List recordKeys); + + /** + * 기존 FAILED 레코드의 실행 정보를 벌크 업데이트 (retryCount 유지). + * 동일 키가 재실패할 때 최신 실행 정보로 갱신. + */ + @Modifying + @Query("UPDATE BatchFailedRecord r SET r.jobExecutionId = :jobExecutionId, " + + "r.stepExecutionId = :stepExecutionId, r.errorMessage = :errorMessage " + + "WHERE r.jobName = :jobName AND r.recordKey IN :recordKeys AND r.status = 'FAILED'") + int updateFailedRecordExecutions(@Param("jobName") String jobName, + @Param("recordKeys") List recordKeys, + @Param("jobExecutionId") Long jobExecutionId, + @Param("stepExecutionId") Long stepExecutionId, + @Param("errorMessage") String errorMessage); + + /** + * 기존 FAILED 레코드의 실행 정보를 벌크 업데이트하면서 retryCount를 1 증가. + * 재수집(RECOLLECT) 모드에서 재실패한 레코드에 사용. + */ + @Modifying + @Query("UPDATE BatchFailedRecord r SET r.jobExecutionId = :jobExecutionId, " + + "r.stepExecutionId = :stepExecutionId, r.errorMessage = :errorMessage, " + + "r.retryCount = r.retryCount + 1 " + + "WHERE r.jobName = :jobName AND r.recordKey IN :recordKeys AND r.status = 'FAILED'") + int incrementRetryAndUpdateFailedRecords(@Param("jobName") String jobName, + @Param("recordKeys") List recordKeys, + @Param("jobExecutionId") Long jobExecutionId, + @Param("stepExecutionId") Long stepExecutionId, + @Param("errorMessage") String errorMessage); + + /** + * FAILED 상태 레코드의 retryCount를 0으로 초기화 (재시도 초과 건 재활성화) + */ + @Modifying + @Query("UPDATE BatchFailedRecord r SET r.retryCount = 0 " + + "WHERE r.id IN :ids AND r.status = 'FAILED'") + int resetRetryCount(@Param("ids") List ids); +} diff --git a/src/main/java/com/snp/batch/global/repository/BatchLastExecutionRepository.java b/src/main/java/com/snp/batch/global/repository/BatchLastExecutionRepository.java new file mode 100644 index 0000000..d751f2d --- /dev/null +++ b/src/main/java/com/snp/batch/global/repository/BatchLastExecutionRepository.java @@ -0,0 +1,47 @@ +package com.snp.batch.global.repository; + +import com.snp.batch.global.model.BatchLastExecution; +import com.snp.batch.global.projection.DateRangeProjection; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.util.Optional; + +@Repository +public interface BatchLastExecutionRepository extends JpaRepository { + // 1. findLastSuccessDate 함수 구현 + /** + * API 키를 기준으로 마지막 성공 일자를 조회합니다. + * @param apiKey 조회할 API 키 (예: "SHIP_UPDATE_API") + * @return 마지막 성공 일자 (LocalDate)를 포함하는 Optional + */ + @Query("SELECT b.lastSuccessDate FROM BatchLastExecution b WHERE b.apiKey = :apiKey") + Optional findLastSuccessDate(@Param("apiKey") String apiKey); + + // 2. findDateRangeByApiKey 함수 구현 + /** + * API 키를 기준으로 범위 설정 날짜를 조회합니다. + * @param apiKey 조회할 API 키 (예: "PSC_IMPORT_API") + * @return 마지막 성공 일자 (LocalDate)를 포함하는 Optional + */ + @Query("SELECT b.lastSuccessDate AS lastSuccessDate, b.rangeFromDate AS rangeFromDate, b.rangeToDate AS rangeToDate FROM BatchLastExecution b WHERE b.apiKey = :apiKey") + Optional findDateRangeByApiKey(@Param("apiKey") String apiKey); + + // 3. updateLastSuccessDate 함수 구현 (직접 UPDATE 쿼리 사용) + /** + * 특정 API 키의 마지막 성공 일자를 업데이트합니다. + * + * @param apiKey 업데이트할 API 키 + * @param successDate 업데이트할 성공 일자 + * @return 업데이트된 레코드 수 + */ + @Modifying + @Query("UPDATE BatchLastExecution b SET b.lastSuccessDate = :successDate WHERE b.apiKey = :apiKey") + int updateLastSuccessDate(@Param("apiKey") String apiKey, @Param("successDate") LocalDateTime successDate); +} diff --git a/src/main/java/com/snp/batch/global/repository/BatchRecollectionHistoryRepository.java b/src/main/java/com/snp/batch/global/repository/BatchRecollectionHistoryRepository.java new file mode 100644 index 0000000..97f1a68 --- /dev/null +++ b/src/main/java/com/snp/batch/global/repository/BatchRecollectionHistoryRepository.java @@ -0,0 +1,42 @@ +package com.snp.batch.global.repository; + +import com.snp.batch.global.model.BatchRecollectionHistory; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +public interface BatchRecollectionHistoryRepository + extends JpaRepository, + JpaSpecificationExecutor { + + Optional findByJobExecutionId(Long jobExecutionId); + + List findTop10ByOrderByCreatedAtDesc(); + + @Query(""" + SELECT h FROM BatchRecollectionHistory h + WHERE h.apiKey = :apiKey + AND h.historyId != :excludeId + AND h.rangeFromDate IS NOT NULL + AND h.rangeToDate IS NOT NULL + AND h.rangeFromDate < :toDate + AND h.rangeToDate > :fromDate + ORDER BY h.createdAt DESC + """) + List findOverlappingHistories( + @Param("apiKey") String apiKey, + @Param("fromDate") LocalDateTime fromDate, + @Param("toDate") LocalDateTime toDate, + @Param("excludeId") Long excludeId); + + long countByExecutionStatus(String executionStatus); + + long countByHasOverlapTrue(); +} diff --git a/src/main/java/com/snp/batch/global/repository/JobDisplayNameRepository.java b/src/main/java/com/snp/batch/global/repository/JobDisplayNameRepository.java new file mode 100644 index 0000000..edb451c --- /dev/null +++ b/src/main/java/com/snp/batch/global/repository/JobDisplayNameRepository.java @@ -0,0 +1,13 @@ +package com.snp.batch.global.repository; + +import com.snp.batch.global.model.JobDisplayNameEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface JobDisplayNameRepository extends JpaRepository { + + Optional findByJobName(String jobName); + + Optional findByApiKey(String apiKey); +} diff --git a/src/main/java/com/snp/batch/global/repository/JobScheduleRepository.java b/src/main/java/com/snp/batch/global/repository/JobScheduleRepository.java new file mode 100644 index 0000000..c51fad2 --- /dev/null +++ b/src/main/java/com/snp/batch/global/repository/JobScheduleRepository.java @@ -0,0 +1,43 @@ +package com.snp.batch.global.repository; + +import com.snp.batch.global.model.JobScheduleEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * JobScheduleEntity Repository + * JPA Repository 방식으로 자동 구현 + */ +@Repository +public interface JobScheduleRepository extends JpaRepository { + + /** + * Job 이름으로 스케줄 조회 + */ + Optional findByJobName(String jobName); + + /** + * Job 이름 존재 여부 확인 + */ + boolean existsByJobName(String jobName); + + /** + * 활성화된 스케줄 목록 조회 + */ + List findByActive(Boolean active); + + /** + * 활성화된 모든 스케줄 조회 + */ + default List findAllActive() { + return findByActive(true); + } + + /** + * Job 이름으로 스케줄 삭제 + */ + void deleteByJobName(String jobName); +} diff --git a/src/main/java/com/snp/batch/global/repository/TimelineRepository.java b/src/main/java/com/snp/batch/global/repository/TimelineRepository.java new file mode 100644 index 0000000..f03a9a0 --- /dev/null +++ b/src/main/java/com/snp/batch/global/repository/TimelineRepository.java @@ -0,0 +1,392 @@ +package com.snp.batch.global.repository; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 타임라인 조회를 위한 경량 Repository + * Step Context 등 불필요한 데이터를 조회하지 않고 필요한 정보만 가져옴 + */ +@Repository +public class TimelineRepository { + + private final JdbcTemplate jdbcTemplate; + private final String tablePrefix; + + public TimelineRepository( + JdbcTemplate jdbcTemplate, + @Value("${spring.batch.jdbc.table-prefix:BATCH_}") String tablePrefix) { + this.jdbcTemplate = jdbcTemplate; + this.tablePrefix = tablePrefix; + } + + private String getJobExecutionTable() { + return tablePrefix + "JOB_EXECUTION"; + } + + private String getJobInstanceTable() { + return tablePrefix + "JOB_INSTANCE"; + } + + private String getStepExecutionTable() { + return tablePrefix + "STEP_EXECUTION"; + } + + /** + * 특정 Job의 특정 범위 내 실행 이력 조회 (경량) + * Step Context를 조회하지 않아 성능이 매우 빠름 + */ + public List> findExecutionsByJobNameAndDateRange( + String jobName, + LocalDateTime startTime, + LocalDateTime endTime) { + + String sql = String.format(""" + SELECT + je.JOB_EXECUTION_ID as executionId, + je.STATUS as status, + je.START_TIME as startTime, + je.END_TIME as endTime + FROM %s je + INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID + WHERE ji.JOB_NAME = ? + AND je.START_TIME >= ? + AND je.START_TIME < ? + ORDER BY je.START_TIME DESC + """, getJobExecutionTable(), getJobInstanceTable()); + + return jdbcTemplate.queryForList(sql, jobName, startTime, endTime); + } + + /** + * 모든 Job의 특정 범위 내 실행 이력 조회 (한 번의 쿼리) + */ + public List> findAllExecutionsByDateRange( + LocalDateTime startTime, + LocalDateTime endTime) { + + String sql = String.format(""" + SELECT + ji.JOB_NAME as jobName, + je.JOB_EXECUTION_ID as executionId, + je.STATUS as status, + je.START_TIME as startTime, + je.END_TIME as endTime + FROM %s je + INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID + WHERE je.START_TIME >= ? + AND je.START_TIME < ? + ORDER BY ji.JOB_NAME, je.START_TIME DESC + """, getJobExecutionTable(), getJobInstanceTable()); + + return jdbcTemplate.queryForList(sql, startTime, endTime); + } + + /** + * 현재 실행 중인 Job 조회 (STARTED, STARTING 상태) + */ + public List> findRunningExecutions() { + String sql = String.format(""" + SELECT + ji.JOB_NAME as jobName, + je.JOB_EXECUTION_ID as executionId, + je.STATUS as status, + je.START_TIME as startTime + FROM %s je + INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID + WHERE je.STATUS IN ('STARTED', 'STARTING') + ORDER BY je.START_TIME DESC + """, getJobExecutionTable(), getJobInstanceTable()); + + return jdbcTemplate.queryForList(sql); + } + + /** + * 최근 실행 이력 조회 (상위 N개) + */ + public List> findRecentExecutions(int limit) { + String sql = String.format(""" + SELECT + ji.JOB_NAME as jobName, + je.JOB_EXECUTION_ID as executionId, + je.STATUS as status, + je.START_TIME as startTime, + je.END_TIME as endTime, + je.EXIT_CODE as exitCode, + je.EXIT_MESSAGE as exitMessage + FROM %s je + INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID + ORDER BY je.START_TIME DESC + LIMIT ? + """, getJobExecutionTable(), getJobInstanceTable()); + + return jdbcTemplate.queryForList(sql, limit); + } + + // ── F1: 강제 종료(Abandon) 관련 ────────────────────────────── + + /** + * 오래된 실행 중 Job 조회 (threshold 분 이상 STARTED/STARTING) + */ + public List> findStaleExecutions(int thresholdMinutes) { + String sql = String.format(""" + SELECT + ji.JOB_NAME as jobName, + je.JOB_EXECUTION_ID as executionId, + je.STATUS as status, + je.START_TIME as startTime + FROM %s je + INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID + WHERE je.STATUS IN ('STARTED', 'STARTING') + AND je.START_TIME < NOW() - INTERVAL '%d minutes' + ORDER BY je.START_TIME ASC + """, getJobExecutionTable(), getJobInstanceTable(), thresholdMinutes); + + return jdbcTemplate.queryForList(sql); + } + + /** + * Job Execution 상태를 ABANDONED로 변경 + */ + @Transactional + public int abandonJobExecution(long executionId) { + String sql = String.format(""" + UPDATE %s + SET STATUS = 'ABANDONED', + EXIT_CODE = 'ABANDONED', + END_TIME = NOW(), + EXIT_MESSAGE = 'Force abandoned by admin' + WHERE JOB_EXECUTION_ID = ? + AND STATUS IN ('STARTED', 'STARTING') + """, getJobExecutionTable()); + + return jdbcTemplate.update(sql, executionId); + } + + /** + * 해당 Job Execution의 Step Execution들도 ABANDONED로 변경 + */ + @Transactional + public int abandonStepExecutions(long jobExecutionId) { + String sql = String.format(""" + UPDATE %s + SET STATUS = 'ABANDONED', + EXIT_CODE = 'ABANDONED', + END_TIME = NOW(), + EXIT_MESSAGE = 'Force abandoned by admin' + WHERE JOB_EXECUTION_ID = ? + AND STATUS IN ('STARTED', 'STARTING') + """, getStepExecutionTable()); + + return jdbcTemplate.update(sql, jobExecutionId); + } + + /** + * 오래된 실행 중 건수 조회 + */ + public int countStaleExecutions(int thresholdMinutes) { + String sql = String.format(""" + SELECT COUNT(*) + FROM %s je + WHERE je.STATUS IN ('STARTED', 'STARTING') + AND je.START_TIME < NOW() - INTERVAL '%d minutes' + """, getJobExecutionTable(), thresholdMinutes); + + Integer count = jdbcTemplate.queryForObject(sql, Integer.class); + return count != null ? count : 0; + } + + // ── F4: 실행 이력 검색 (페이지네이션) ───────────────────────── + + /** + * 실행 이력 검색 (동적 조건 + 페이지네이션) + */ + public List> searchExecutions( + List jobNames, String status, + LocalDateTime startDate, LocalDateTime endDate, + int offset, int limit) { + + StringBuilder sql = new StringBuilder(String.format(""" + SELECT + ji.JOB_NAME as jobName, + je.JOB_EXECUTION_ID as executionId, + je.STATUS as status, + je.START_TIME as startTime, + je.END_TIME as endTime, + je.EXIT_CODE as exitCode, + je.EXIT_MESSAGE as exitMessage + FROM %s je + INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID + WHERE 1=1 + """, getJobExecutionTable(), getJobInstanceTable())); + + List params = new ArrayList<>(); + appendSearchConditions(sql, params, jobNames, status, startDate, endDate); + + sql.append(" ORDER BY je.START_TIME DESC LIMIT ? OFFSET ?"); + params.add(limit); + params.add(offset); + + return jdbcTemplate.queryForList(sql.toString(), params.toArray()); + } + + /** + * 실행 이력 검색 건수 + */ + public int countExecutions( + List jobNames, String status, + LocalDateTime startDate, LocalDateTime endDate) { + + StringBuilder sql = new StringBuilder(String.format(""" + SELECT COUNT(*) + FROM %s je + INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID + WHERE 1=1 + """, getJobExecutionTable(), getJobInstanceTable())); + + List params = new ArrayList<>(); + appendSearchConditions(sql, params, jobNames, status, startDate, endDate); + + Integer count = jdbcTemplate.queryForObject(sql.toString(), Integer.class, params.toArray()); + return count != null ? count : 0; + } + + private void appendSearchConditions( + StringBuilder sql, List params, + List jobNames, String status, + LocalDateTime startDate, LocalDateTime endDate) { + + if (jobNames != null && !jobNames.isEmpty()) { + String placeholders = jobNames.stream().map(n -> "?").collect(Collectors.joining(", ")); + sql.append(" AND ji.JOB_NAME IN (").append(placeholders).append(")"); + params.addAll(jobNames); + } + if (status != null && !status.isBlank()) { + sql.append(" AND je.STATUS = ?"); + params.add(status); + } + if (startDate != null) { + sql.append(" AND je.START_TIME >= ?"); + params.add(startDate); + } + if (endDate != null) { + sql.append(" AND je.START_TIME < ?"); + params.add(endDate); + } + } + + // ── F6: 대시보드 실패 통계 ────────────────────────────────── + + /** + * 최근 실패 이력 조회 + */ + public List> findRecentFailures(int hours) { + String sql = String.format(""" + SELECT + ji.JOB_NAME as jobName, + je.JOB_EXECUTION_ID as executionId, + je.STATUS as status, + je.START_TIME as startTime, + je.END_TIME as endTime, + je.EXIT_MESSAGE as exitMessage + FROM %s je + INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID + WHERE je.STATUS = 'FAILED' + AND je.START_TIME >= NOW() - INTERVAL '%d hours' + ORDER BY je.START_TIME DESC + LIMIT 10 + """, getJobExecutionTable(), getJobInstanceTable(), hours); + + return jdbcTemplate.queryForList(sql); + } + + /** + * 특정 시점 이후 실패 건수 + */ + public int countFailuresSince(LocalDateTime since) { + String sql = String.format(""" + SELECT COUNT(*) + FROM %s je + WHERE je.STATUS = 'FAILED' + AND je.START_TIME >= ? + """, getJobExecutionTable()); + + Integer count = jdbcTemplate.queryForObject(sql, Integer.class, since); + return count != null ? count : 0; + } + + // ── F7: Job별 최근 실행 정보 ──────────────────────────────── + + /** + * Job별 가장 최근 실행 정보 조회 (DISTINCT ON 활용) + */ + public List> findLastExecutionPerJob() { + String sql = String.format(""" + SELECT DISTINCT ON (ji.JOB_NAME) + ji.JOB_NAME as jobName, + je.JOB_EXECUTION_ID as executionId, + je.STATUS as status, + je.START_TIME as startTime, + je.END_TIME as endTime + FROM %s je + INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID + ORDER BY ji.JOB_NAME, je.START_TIME DESC + """, getJobExecutionTable(), getJobInstanceTable()); + + return jdbcTemplate.queryForList(sql); + } + + // ── F8: 실행 통계 ────────────────────────────────────────── + + /** + * 일별 실행 통계 (전체) + */ + public List> findDailyStatistics(int days) { + String sql = String.format(""" + SELECT + CAST(je.START_TIME AS DATE) as execDate, + SUM(CASE WHEN je.STATUS = 'COMPLETED' THEN 1 ELSE 0 END) as successCount, + SUM(CASE WHEN je.STATUS = 'FAILED' THEN 1 ELSE 0 END) as failedCount, + SUM(CASE WHEN je.STATUS NOT IN ('COMPLETED', 'FAILED') THEN 1 ELSE 0 END) as otherCount, + AVG(EXTRACT(EPOCH FROM (je.END_TIME - je.START_TIME)) * 1000) as avgDurationMs + FROM %s je + WHERE je.START_TIME >= NOW() - INTERVAL '%d days' + AND je.START_TIME IS NOT NULL + GROUP BY CAST(je.START_TIME AS DATE) + ORDER BY execDate + """, getJobExecutionTable(), days); + + return jdbcTemplate.queryForList(sql); + } + + /** + * 일별 실행 통계 (특정 Job) + */ + public List> findDailyStatisticsForJob(String jobName, int days) { + String sql = String.format(""" + SELECT + CAST(je.START_TIME AS DATE) as execDate, + SUM(CASE WHEN je.STATUS = 'COMPLETED' THEN 1 ELSE 0 END) as successCount, + SUM(CASE WHEN je.STATUS = 'FAILED' THEN 1 ELSE 0 END) as failedCount, + SUM(CASE WHEN je.STATUS NOT IN ('COMPLETED', 'FAILED') THEN 1 ELSE 0 END) as otherCount, + AVG(EXTRACT(EPOCH FROM (je.END_TIME - je.START_TIME)) * 1000) as avgDurationMs + FROM %s je + INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID + WHERE ji.JOB_NAME = ? + AND je.START_TIME >= NOW() - INTERVAL '%d days' + AND je.START_TIME IS NOT NULL + GROUP BY CAST(je.START_TIME AS DATE) + ORDER BY execDate + """, getJobExecutionTable(), getJobInstanceTable(), days); + + return jdbcTemplate.queryForList(sql, jobName); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/common/config/FlagCodeImportJobConfig.java b/src/main/java/com/snp/batch/jobs/batch/common/config/FlagCodeImportJobConfig.java new file mode 100644 index 0000000..87faea3 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/common/config/FlagCodeImportJobConfig.java @@ -0,0 +1,123 @@ +package com.snp.batch.jobs.batch.common.config; + +import com.snp.batch.common.batch.config.BaseJobConfig; +import com.snp.batch.jobs.batch.common.dto.FlagCodeDto; +import com.snp.batch.jobs.batch.common.entity.FlagCodeEntity; +import com.snp.batch.jobs.batch.common.processor.FlagCodeDataProcessor; +import com.snp.batch.jobs.batch.common.reader.FlagCodeDataReader; +import com.snp.batch.jobs.batch.common.repository.FlagCodeRepository; +import com.snp.batch.jobs.batch.common.writer.FlagCodeDataWriter; +import com.snp.batch.jobs.batch.facility.reader.PortDataReader; +import com.snp.batch.service.BatchApiLogService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.reactive.function.client.WebClient; + +@Slf4j +@Configuration +public class FlagCodeImportJobConfig extends BaseJobConfig { + + private final FlagCodeDataProcessor flagCodeDataProcessor; + private final FlagCodeDataReader flagCodeDataReader; + private final FlagCodeRepository flagCodeRepository; + private final WebClient maritimeApiWebClient; + private final BatchApiLogService batchApiLogService; + + @Value("${app.batch.ship-api.url}") + private String maritimeApiUrl; + + @Value("${app.batch.chunk-size:1000}") + private int chunkSize; + + /** + * 생성자 주입 + * maritimeApiWebClient: MaritimeApiWebClientConfig에서 등록한 Bean 주입 + */ + public FlagCodeImportJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + FlagCodeRepository flagCodeRepository, + FlagCodeDataProcessor flagCodeDataProcessor, + @Qualifier("maritimeApiWebClient") WebClient maritimeApiWebClient, + FlagCodeDataReader flagCodeDataReader, + BatchApiLogService batchApiLogService) { + super(jobRepository, transactionManager); + this.flagCodeRepository = flagCodeRepository; + this.maritimeApiWebClient = maritimeApiWebClient; + this.flagCodeDataProcessor = flagCodeDataProcessor; + this.flagCodeDataReader = flagCodeDataReader; + this.batchApiLogService = batchApiLogService; + } + + @Override + protected String getJobName() { + return "FlagCodeImportJob"; + } + + @Override + protected String getStepName() { + return "FlagCodeImportStep"; + } + + @Override + protected ItemReader createReader() { + return flagCodeDataReader; + } + @Bean + @StepScope + public FlagCodeDataReader flagCodeDataReader( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, // SpEL로 ID 추출 + @Value("#{stepExecution.id}") Long stepExecutionId + ) { + FlagCodeDataReader reader = new FlagCodeDataReader(maritimeApiWebClient, batchApiLogService, maritimeApiUrl); + reader.setExecutionIds(jobExecutionId, stepExecutionId); // ID 세팅 + return reader; + } + + @Override + protected ItemProcessor createProcessor() { + return flagCodeDataProcessor; + } + @Bean + @StepScope + public FlagCodeDataProcessor flagCodeDataProcessor( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId) { + return new FlagCodeDataProcessor(jobExecutionId); + } + @Override + protected ItemWriter createWriter() { + return new FlagCodeDataWriter(flagCodeRepository); + } + + @Override + protected int getChunkSize() { + return chunkSize; + } + + /** + * Job Bean 등록 + */ + @Bean(name = "FlagCodeImportJob") + public Job flagCodeImportJob() { + return job(); + } + + /** + * Step Bean 등록 + */ + @Bean(name = "FlagCodeImportStep") + public Step flagCodeImportStep() { + return step(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/common/config/Stat5CodeImportJobConfig.java b/src/main/java/com/snp/batch/jobs/batch/common/config/Stat5CodeImportJobConfig.java new file mode 100644 index 0000000..24daba1 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/common/config/Stat5CodeImportJobConfig.java @@ -0,0 +1,98 @@ +package com.snp.batch.jobs.batch.common.config; + +import com.snp.batch.common.batch.config.BaseJobConfig; +import com.snp.batch.jobs.batch.common.dto.Stat5CodeDto; +import com.snp.batch.jobs.batch.common.entity.Stat5CodeEntity; +import com.snp.batch.jobs.batch.common.processor.Stat5CodeDataProcessor; +import com.snp.batch.jobs.batch.common.reader.Stat5CodeDataReader; +import com.snp.batch.jobs.batch.common.repository.Stat5CodeRepository; +import com.snp.batch.jobs.batch.common.writer.Stat5CodeDataWriter; +import com.snp.batch.service.BatchApiLogService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.reactive.function.client.WebClient; + +@Slf4j +@Configuration +public class Stat5CodeImportJobConfig extends BaseJobConfig { + + private final Stat5CodeDataProcessor stat5CodeDataProcessor; + private final Stat5CodeDataReader stat5CodeDataReader; + private final Stat5CodeRepository stat5CodeRepository; + private final BatchApiLogService batchApiLogService; + private final WebClient maritimeAisApiWebClient; + @Value("${app.batch.ais-api.url}") + private String maritimeAisApiUrl; + public Stat5CodeImportJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + Stat5CodeRepository stat5CodeRepository, + @Qualifier("maritimeAisApiWebClient") WebClient maritimeAisApiWebClient, + Stat5CodeDataProcessor stat5CodeDataProcessor, + Stat5CodeDataReader stat5CodeDataReader, + BatchApiLogService batchApiLogService) { + super(jobRepository, transactionManager); + this.stat5CodeRepository = stat5CodeRepository; + this.maritimeAisApiWebClient = maritimeAisApiWebClient; + this.stat5CodeDataProcessor = stat5CodeDataProcessor; + this.stat5CodeDataReader = stat5CodeDataReader; + this.batchApiLogService = batchApiLogService; + } + + @Override + protected String getJobName() { return "Stat5CodeImportJob"; } + + @Override + protected String getStepName() { + return "Stat5CodeImportStep"; + } + + @Override + protected ItemReader createReader() { return stat5CodeDataReader; } + + @Bean + @StepScope + public Stat5CodeDataReader stat5CodeDataReader( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, // SpEL로 ID 추출 + @Value("#{stepExecution.id}") Long stepExecutionId + ) { + Stat5CodeDataReader reader = new Stat5CodeDataReader(maritimeAisApiWebClient, batchApiLogService, maritimeAisApiUrl); + reader.setExecutionIds(jobExecutionId, stepExecutionId); // ID 세팅 + return reader; + } + + @Override + protected ItemProcessor createProcessor() { return stat5CodeDataProcessor; } + @Bean + @StepScope + public Stat5CodeDataProcessor stat5CodeDataProcessor( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId) { + return new Stat5CodeDataProcessor(jobExecutionId); + } + @Override + protected ItemWriter createWriter() { return new Stat5CodeDataWriter(stat5CodeRepository); } + + @Bean(name = "Stat5CodeImportJob") + public Job stat5CodeImportJob() { + return job(); + } + + /** + * Step Bean 등록 + */ + @Bean(name = "Stat5CodeImportStep") + public Step stat5CodeImportStep() { + return step(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/common/dto/FlagCodeApiResponse.java b/src/main/java/com/snp/batch/jobs/batch/common/dto/FlagCodeApiResponse.java new file mode 100644 index 0000000..11ecc8f --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/common/dto/FlagCodeApiResponse.java @@ -0,0 +1,26 @@ +package com.snp.batch.jobs.batch.common.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class FlagCodeApiResponse { + + @JsonProperty("associatedName") + private String associatedName; + + @JsonProperty("associatedCount") + private Integer associatedCount; + + @JsonProperty("APSAssociatedFlagISODetails") + private List associatedFlagISODetails; + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/common/dto/FlagCodeDto.java b/src/main/java/com/snp/batch/jobs/batch/common/dto/FlagCodeDto.java new file mode 100644 index 0000000..f8469bb --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/common/dto/FlagCodeDto.java @@ -0,0 +1,40 @@ +package com.snp.batch.jobs.batch.common.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class FlagCodeDto { + + @JsonProperty("DataSetVersion") + private DataSetVersion dataSetVersion; + + @JsonProperty("Code") + private String code; + + @JsonProperty("Decode") + private String decode; + + @JsonProperty("ISO2") + private String iso2; + + @JsonProperty("ISO3") + private String iso3; + + @Data + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class DataSetVersion { + @JsonProperty("DataSetVersion") + private String version; + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/common/dto/Stat5CodeApiResponse.java b/src/main/java/com/snp/batch/jobs/batch/common/dto/Stat5CodeApiResponse.java new file mode 100644 index 0000000..9cb25d2 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/common/dto/Stat5CodeApiResponse.java @@ -0,0 +1,18 @@ +package com.snp.batch.jobs.batch.common.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class Stat5CodeApiResponse { + @JsonProperty("StatcodeArr") + private List statcodeArr; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/common/dto/Stat5CodeDto.java b/src/main/java/com/snp/batch/jobs/batch/common/dto/Stat5CodeDto.java new file mode 100644 index 0000000..e851e4b --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/common/dto/Stat5CodeDto.java @@ -0,0 +1,40 @@ +package com.snp.batch.jobs.batch.common.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class Stat5CodeDto { + @JsonProperty("Level1") + private String level1; + @JsonProperty("Level1Decode") + private String Level1Decode; + @JsonProperty("Level2") + private String Level2; + @JsonProperty("Level2Decode") + private String Level2Decode; + @JsonProperty("Level3") + private String Level3; + @JsonProperty("Level3Decode") + private String Level3Decode; + @JsonProperty("Level4") + private String Level4; + @JsonProperty("Level4Decode") + private String Level4Decode; + @JsonProperty("Level5") + private String Level5; + @JsonProperty("Level5Decode") + private String Level5Decode; + @JsonProperty("Description") + private String Description; + @JsonProperty("Release") + private Integer Release; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/common/entity/FlagCodeEntity.java b/src/main/java/com/snp/batch/jobs/batch/common/entity/FlagCodeEntity.java new file mode 100644 index 0000000..e08384e --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/common/entity/FlagCodeEntity.java @@ -0,0 +1,26 @@ +package com.snp.batch.jobs.batch.common.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class FlagCodeEntity extends BaseEntity { + + private String dataSetVersion; + + private String code; + + private String decode; + + private String iso2; + + private String iso3; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/common/entity/Stat5CodeEntity.java b/src/main/java/com/snp/batch/jobs/batch/common/entity/Stat5CodeEntity.java new file mode 100644 index 0000000..e527b4d --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/common/entity/Stat5CodeEntity.java @@ -0,0 +1,40 @@ +package com.snp.batch.jobs.batch.common.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class Stat5CodeEntity extends BaseEntity { + + private String level1; + + private String Level1Decode; + + private String Level2; + + private String Level2Decode; + + private String Level3; + + private String Level3Decode; + + private String Level4; + + private String Level4Decode; + + private String Level5; + + private String Level5Decode; + + private String Description; + + private String Release; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/common/processor/FlagCodeDataProcessor.java b/src/main/java/com/snp/batch/jobs/batch/common/processor/FlagCodeDataProcessor.java new file mode 100644 index 0000000..1d3d21e --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/common/processor/FlagCodeDataProcessor.java @@ -0,0 +1,35 @@ +package com.snp.batch.jobs.batch.common.processor; + +import com.snp.batch.common.batch.processor.BaseProcessor; +import com.snp.batch.jobs.batch.common.dto.FlagCodeDto; +import com.snp.batch.jobs.batch.common.entity.FlagCodeEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; + +@Slf4j +public class FlagCodeDataProcessor extends BaseProcessor { + + private final Long jobExecutionId; + + public FlagCodeDataProcessor(@Value("#{stepExecution.jobExecution.id}") Long jobExecutionId) { + this.jobExecutionId = jobExecutionId; + } + + @Override + protected FlagCodeEntity processItem(FlagCodeDto dto) throws Exception { + + FlagCodeEntity entity = FlagCodeEntity.builder() + .dataSetVersion(dto.getDataSetVersion().getVersion()) + .code(dto.getCode()) + .decode(dto.getDecode()) + .iso2(dto.getIso2()) + .iso3(dto.getIso3()) + .jobExecutionId(jobExecutionId) + .createdBy("SYSTEM") + .build(); + + log.debug("국가코드 데이터 처리 완료: FlagCode={}", dto.getCode()); + + return entity; + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/common/processor/Stat5CodeDataProcessor.java b/src/main/java/com/snp/batch/jobs/batch/common/processor/Stat5CodeDataProcessor.java new file mode 100644 index 0000000..4f58b96 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/common/processor/Stat5CodeDataProcessor.java @@ -0,0 +1,40 @@ +package com.snp.batch.jobs.batch.common.processor; + +import com.snp.batch.common.batch.processor.BaseProcessor; +import com.snp.batch.jobs.batch.common.dto.Stat5CodeDto; +import com.snp.batch.jobs.batch.common.entity.Stat5CodeEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; + + +@Slf4j +public class Stat5CodeDataProcessor extends BaseProcessor { + private final Long jobExecutionId; + + public Stat5CodeDataProcessor(@Value("#{stepExecution.jobExecution.id}") Long jobExecutionId) { + this.jobExecutionId = jobExecutionId; + } + + @Override + protected Stat5CodeEntity processItem(Stat5CodeDto dto) throws Exception { + Stat5CodeEntity entity = Stat5CodeEntity.builder() + .level1(dto.getLevel1()) + .Level1Decode(dto.getLevel1Decode()) + .Level2(dto.getLevel2()) + .Level2Decode(dto.getLevel2Decode()) + .Level3(dto.getLevel3()) + .Level3Decode(dto.getLevel3Decode()) + .Level4(dto.getLevel4()) + .Level4Decode(dto.getLevel4Decode()) + .Level5(dto.getLevel5()) + .Level5Decode(dto.getLevel5Decode()) + .Description(dto.getDescription()) + .Release(Integer.toString(dto.getRelease())) + .jobExecutionId(jobExecutionId) + .createdBy("SYSTEM") + .build(); + + log.debug("Stat5Code 데이터 처리 완료: Stat5Code={}", dto.getLevel5()); + return entity; + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/common/reader/FlagCodeDataReader.java b/src/main/java/com/snp/batch/jobs/batch/common/reader/FlagCodeDataReader.java new file mode 100644 index 0000000..087f08a --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/common/reader/FlagCodeDataReader.java @@ -0,0 +1,58 @@ +package com.snp.batch.jobs.batch.common.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.batch.common.dto.FlagCodeApiResponse; +import com.snp.batch.jobs.batch.common.dto.FlagCodeDto; +import com.snp.batch.service.BatchApiLogService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@Slf4j +public class FlagCodeDataReader extends BaseApiReader { + private final BatchApiLogService batchApiLogService; + String maritimeApiUrl; + public FlagCodeDataReader(WebClient webClient, BatchApiLogService batchApiLogService, String maritimeApiUrl) { + super(webClient); // BaseApiReader에 WebClient 전달 + this.batchApiLogService = batchApiLogService; + this.maritimeApiUrl = maritimeApiUrl; + } + + // ======================================== + // 필수 구현 메서드 + // ======================================== + + @Override + protected String getReaderName() { + return "FlagCodeDataReader"; + } + @Override + protected String getApiPath() { + return "/MaritimeWCF/APSShipService.svc/RESTFul/GetAssociatedFlagISOByName"; + } + @Override + protected List fetchDataFromApi() { + try { + log.info("GetAssociatedFlagISOByName API 호출 시작"); + + // 공통 함수 호출 + List result = executeWrapperApiCall( + maritimeApiUrl, // 베이스 URL (필드 또는 설정값) + getApiPath(), // API 경로 + FlagCodeApiResponse.class, // 응답을 받을 래퍼 클래스 + FlagCodeApiResponse::getAssociatedFlagISODetails, // 리스트 추출 함수 (메서드 참조) + batchApiLogService // 로그 서비스 + ); + // 결과가 null일 경우 빈 리스트 반환 (안전장치) + return result != null ? result : Collections.emptyList(); + + } catch (Exception e) { + log.error("GetAssociatedFlagISOByName API 호출 실패", e); + log.error("에러 메시지: {}", e.getMessage()); + return new ArrayList<>(); + } + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/common/reader/Stat5CodeDataReader.java b/src/main/java/com/snp/batch/jobs/batch/common/reader/Stat5CodeDataReader.java new file mode 100644 index 0000000..06cd146 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/common/reader/Stat5CodeDataReader.java @@ -0,0 +1,52 @@ +package com.snp.batch.jobs.batch.common.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.batch.common.dto.Stat5CodeApiResponse; +import com.snp.batch.jobs.batch.common.dto.Stat5CodeDto; +import com.snp.batch.service.BatchApiLogService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@Slf4j +public class Stat5CodeDataReader extends BaseApiReader { + private final BatchApiLogService batchApiLogService; + String maritimeAisApiUrl; + public Stat5CodeDataReader(WebClient webClient, BatchApiLogService batchApiLogService, String maritimeAisApiUrl) { + super(webClient); // BaseApiReader에 WebClient 전달 + this.batchApiLogService = batchApiLogService; + this.maritimeAisApiUrl = maritimeAisApiUrl; + } + @Override + protected String getReaderName() { + return "Stat5CodeDataReader"; + } + @Override + protected String getApiPath() { + return "/AisSvc.svc/AIS/GetStatcodes"; + } + @Override + protected List fetchDataFromApi() { + try { + log.info("GetStatcodes API 호출 시작"); + + // 공통 함수 호출 + List result = executeWrapperApiCall( + maritimeAisApiUrl, // 베이스 URL (필드 또는 설정값) + getApiPath(), // API 경로 + Stat5CodeApiResponse.class, // 응답을 받을 래퍼 클래스 + Stat5CodeApiResponse::getStatcodeArr, // 리스트 추출 함수 (메서드 참조) + batchApiLogService // 로그 서비스 + ); + // 결과가 null일 경우 빈 리스트 반환 (안전장치) + return result != null ? result : Collections.emptyList(); + } catch (Exception e) { + log.error("GetAssociatedFlagISOByName API 호출 실패", e); + log.error("에러 메시지: {}", e.getMessage()); + return new ArrayList<>(); + } + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/common/repository/FlagCodeRepository.java b/src/main/java/com/snp/batch/jobs/batch/common/repository/FlagCodeRepository.java new file mode 100644 index 0000000..46ced31 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/common/repository/FlagCodeRepository.java @@ -0,0 +1,12 @@ +package com.snp.batch.jobs.batch.common.repository; + +import com.snp.batch.jobs.batch.common.entity.FlagCodeEntity; + +import java.util.List; + +public interface FlagCodeRepository { + + void saveAllFlagCode(List items); + + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/common/repository/FlagCodeRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/batch/common/repository/FlagCodeRepositoryImpl.java new file mode 100644 index 0000000..82d3081 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/common/repository/FlagCodeRepositoryImpl.java @@ -0,0 +1,104 @@ +package com.snp.batch.jobs.batch.common.repository; + +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.batch.common.entity.FlagCodeEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.Types; +import java.util.List; + +@Slf4j +@Repository("FlagCodeRepository") +public class FlagCodeRepositoryImpl extends BaseJdbcRepository implements FlagCodeRepository { + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + + @Value("${app.batch.target-schema.tables.code-002}") + private String tableName; + + public FlagCodeRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + protected String getTargetSchema() { + return targetSchema; + } + + @Override + protected String getEntityName() { + return "FlagCodeEntity"; + } + + @Override + protected String getSimpleTableName() { + return tableName; + } + + + @Override + protected String getInsertSql() { + return null; + } + + @Override + protected String getUpdateSql() { + return """ + INSERT INTO %s( + dataset_ver, ship_country_cd, cd_nm, iso_two_cd, iso_thr_cd, + job_execution_id, creatr_id + ) VALUES (?, ?, ?, ?, ?, ?, ?); + """.formatted(getTableName()); + } + + @Override + protected void setInsertParameters(PreparedStatement ps, FlagCodeEntity entity) throws Exception { + + } + + @Override + protected void setUpdateParameters(PreparedStatement ps, FlagCodeEntity entity) throws Exception { + int idx = 1; + ps.setString(idx++, entity.getDataSetVersion()); + ps.setString(idx++, entity.getCode()); + ps.setString(idx++, entity.getDecode()); + ps.setString(idx++, entity.getIso2()); + ps.setString(idx++, entity.getIso3()); + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + + @Override + protected RowMapper getRowMapper() { + return null; + } + + @Override + protected String extractId(FlagCodeEntity entity) { + return null; + } + + @Override + public void saveAllFlagCode(List items) { + if (items == null || items.isEmpty()) { + return; + } + jdbcTemplate.batchUpdate(getUpdateSql(), items, items.size(), + (ps, entity) -> { + try { + setUpdateParameters(ps, entity); + } catch (Exception e) { + log.error("배치 삽입 파라미터 설정 실패", e); + throw new RuntimeException(e); + } + }); + + log.info("{} 전체 저장 완료: {} 건", getEntityName(), items.size()); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/common/repository/Stat5CodeRepository.java b/src/main/java/com/snp/batch/jobs/batch/common/repository/Stat5CodeRepository.java new file mode 100644 index 0000000..ccfb173 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/common/repository/Stat5CodeRepository.java @@ -0,0 +1,9 @@ +package com.snp.batch.jobs.batch.common.repository; + +import com.snp.batch.jobs.batch.common.entity.Stat5CodeEntity; + +import java.util.List; + +public interface Stat5CodeRepository { + void saveAllStat5Code(List items); +} diff --git a/src/main/java/com/snp/batch/jobs/batch/common/repository/Stat5CodeRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/batch/common/repository/Stat5CodeRepositoryImpl.java new file mode 100644 index 0000000..06b1bbe --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/common/repository/Stat5CodeRepositoryImpl.java @@ -0,0 +1,111 @@ +package com.snp.batch.jobs.batch.common.repository; + +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.batch.common.entity.Stat5CodeEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.Types; +import java.util.List; + +@Slf4j +@Repository("Stat5CodeRepository") +public class Stat5CodeRepositoryImpl extends BaseJdbcRepository implements Stat5CodeRepository{ + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + + @Value("${app.batch.target-schema.tables.code-001}") + private String tableName; + + public Stat5CodeRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + protected String getTargetSchema() { + return targetSchema; + } + + @Override + protected String getEntityName() { + return "Stat5CodeEntity"; + } + + @Override + protected String getSimpleTableName() { + return tableName; + } + + @Override + protected RowMapper getRowMapper() { + return null; + } + + @Override + protected String extractId(Stat5CodeEntity entity) { + return null; + } + + @Override + protected String getInsertSql() { + return null; + } + + @Override + protected String getUpdateSql() { + return """ + INSERT INTO %s( + lv_one, lv_one_desc, lv_two, lv_two_desc, lv_thr, lv_thr_desc, lv_four, lv_four_desc, lv_five, lv_five_desc, dtl_desc, rls_iem, + job_execution_id, creatr_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + """.formatted(getTableName()); + } + + @Override + protected void setInsertParameters(PreparedStatement ps, Stat5CodeEntity entity) throws Exception { + + } + + @Override + protected void setUpdateParameters(PreparedStatement ps, Stat5CodeEntity entity) throws Exception { + int idx = 1; + ps.setString(idx++, entity.getLevel1()); + ps.setString(idx++, entity.getLevel1Decode()); + ps.setString(idx++, entity.getLevel2()); + ps.setString(idx++, entity.getLevel2Decode()); + ps.setString(idx++, entity.getLevel3()); + ps.setString(idx++, entity.getLevel3Decode()); + ps.setString(idx++, entity.getLevel4()); + ps.setString(idx++, entity.getLevel4Decode()); + ps.setString(idx++, entity.getLevel5()); + ps.setString(idx++, entity.getLevel5Decode()); + ps.setString(idx++, entity.getDescription()); + ps.setString(idx++, entity.getRelease()); + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + + @Override + public void saveAllStat5Code(List items) { + if (items == null || items.isEmpty()) { + return; + } + jdbcTemplate.batchUpdate(getUpdateSql(), items, items.size(), + (ps, entity) -> { + try { + setUpdateParameters(ps, entity); + } catch (Exception e) { + log.error("배치 삽입 파라미터 설정 실패", e); + throw new RuntimeException(e); + } + }); + + log.info("{} 전체 저장 완료: {} 건", getEntityName(), items.size()); + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/common/writer/FlagCodeDataWriter.java b/src/main/java/com/snp/batch/jobs/batch/common/writer/FlagCodeDataWriter.java new file mode 100644 index 0000000..6cd64a0 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/common/writer/FlagCodeDataWriter.java @@ -0,0 +1,25 @@ +package com.snp.batch.jobs.batch.common.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.batch.common.entity.FlagCodeEntity; +import com.snp.batch.jobs.batch.common.repository.FlagCodeRepository; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; + +@Slf4j +public class FlagCodeDataWriter extends BaseWriter { + + private final FlagCodeRepository flagCodeRepository; + + public FlagCodeDataWriter(FlagCodeRepository flagCodeRepository) { + super("FlagCodeEntity"); + this.flagCodeRepository = flagCodeRepository; + } + + @Override + protected void writeItems(List items) throws Exception { + flagCodeRepository.saveAllFlagCode(items); + log.info("FlagCode 저장 완료: {} 건", items.size()); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/common/writer/Stat5CodeDataWriter.java b/src/main/java/com/snp/batch/jobs/batch/common/writer/Stat5CodeDataWriter.java new file mode 100644 index 0000000..c78bc95 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/common/writer/Stat5CodeDataWriter.java @@ -0,0 +1,25 @@ +package com.snp.batch.jobs.batch.common.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.batch.common.entity.Stat5CodeEntity; +import com.snp.batch.jobs.batch.common.repository.Stat5CodeRepository; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; + +@Slf4j +public class Stat5CodeDataWriter extends BaseWriter { + + private final Stat5CodeRepository stat5CodeRepository; + + public Stat5CodeDataWriter(Stat5CodeRepository stat5CodeRepository) { + super("Stat5CodeEntity"); + this.stat5CodeRepository = stat5CodeRepository; + } + + @Override + protected void writeItems(List items) throws Exception { + stat5CodeRepository.saveAllStat5Code(items); + log.info("Stat5Code 저장 완료: {} 건", items.size()); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/compliance/config/CompanyComplianceImportRangeJobConfig.java b/src/main/java/com/snp/batch/jobs/batch/compliance/config/CompanyComplianceImportRangeJobConfig.java new file mode 100644 index 0000000..74e45e1 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/compliance/config/CompanyComplianceImportRangeJobConfig.java @@ -0,0 +1,223 @@ +package com.snp.batch.jobs.batch.compliance.config; + +import com.snp.batch.common.batch.config.BaseMultiStepJobConfig; +import com.snp.batch.common.batch.tasklet.LastExecutionUpdateTasklet; +import com.snp.batch.jobs.batch.compliance.dto.CompanyComplianceDto; +import com.snp.batch.jobs.batch.compliance.entity.CompanyComplianceEntity; +import com.snp.batch.jobs.batch.compliance.processor.CompanyComplianceDataProcessor; +import com.snp.batch.jobs.batch.compliance.reader.CompanyComplianceDataRangeReader; +import com.snp.batch.jobs.batch.compliance.writer.CompanyComplianceDataWriter; +import com.snp.batch.service.BatchApiLogService; +import com.snp.batch.service.BatchDateService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.job.flow.FlowExecutionStatus; +import org.springframework.batch.core.job.flow.JobExecutionDecider; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.reactive.function.client.WebClient; + +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Map; + +@Slf4j +@Configuration +public class CompanyComplianceImportRangeJobConfig extends BaseMultiStepJobConfig { + + private final JdbcTemplate jdbcTemplate; + private final WebClient maritimeServiceApiWebClient; + private final CompanyComplianceDataRangeReader companyComplianceDataRangeReader; + private final CompanyComplianceDataProcessor companyComplianceDataProcessor; + private final CompanyComplianceDataWriter companyComplianceDataWriter; + private final BatchDateService batchDateService; + private final BatchApiLogService batchApiLogService; + + @Value("${app.batch.webservice-api.url}") + private String maritimeServiceApiUrl; + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + @Value("${app.batch.last-execution-buffer-hours:24}") + private int lastExecutionBufferHours; + + protected String getApiKey() {return "COMPANY_COMPLIANCE_IMPORT_API";} + + @Override + protected int getChunkSize() { + return 5000; + } + public CompanyComplianceImportRangeJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + CompanyComplianceDataRangeReader companyComplianceDataRangeReader, + CompanyComplianceDataProcessor companyComplianceDataProcessor, + CompanyComplianceDataWriter companyComplianceDataWriter, + JdbcTemplate jdbcTemplate, + @Qualifier("maritimeServiceApiWebClient")WebClient maritimeServiceApiWebClient, + BatchDateService batchDateService, + BatchApiLogService batchApiLogService) { + super(jobRepository, transactionManager); + this.jdbcTemplate = jdbcTemplate; + this.companyComplianceDataRangeReader = companyComplianceDataRangeReader; + this.maritimeServiceApiWebClient = maritimeServiceApiWebClient; + this.companyComplianceDataProcessor = companyComplianceDataProcessor; + this.companyComplianceDataWriter = companyComplianceDataWriter; + this.batchDateService = batchDateService; + this.batchApiLogService = batchApiLogService; + } + + @Override + protected String getJobName() { + return "CompanyComplianceImportRangeJob"; + } + @Override + protected String getStepName() { + return "CompanyComplianceImportRangeStep"; + } + + @Override + protected Job createJobFlow(JobBuilder jobBuilder) { + return jobBuilder + .start(companyComplianceImportRangeStep()) + .next(companyComplianceEmptyResponseDecider()) + .on("EMPTY_RESPONSE").end() + .from(companyComplianceEmptyResponseDecider()).on("*").to(companyComplianceLastExecutionUpdateStep()) + .end() + .build(); + } + + @Bean + public JobExecutionDecider companyComplianceEmptyResponseDecider() { + return (jobExecution, stepExecution) -> { + if (stepExecution != null && stepExecution.getReadCount() == 0) { + log.info("[CompanyComplianceImportRangeJob] Decider: EMPTY_RESPONSE - 응답 데이터 0건으로 LAST_EXECUTION 업데이트 스킵"); + return new FlowExecutionStatus("EMPTY_RESPONSE"); + } + log.info("[CompanyComplianceImportRangeJob] Decider: NORMAL - LAST_EXECUTION 업데이트 진행"); + return new FlowExecutionStatus("NORMAL"); + }; + } + + @Override + protected ItemReader createReader() { + return companyComplianceDataRangeReader; + } + + @Bean + @StepScope + public CompanyComplianceDataRangeReader companyComplianceDataRangeReader( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, // SpEL로 ID 추출 + @Value("#{stepExecution.id}") Long stepExecutionId + ) { + CompanyComplianceDataRangeReader reader = new CompanyComplianceDataRangeReader(maritimeServiceApiWebClient, batchDateService, batchApiLogService, maritimeServiceApiUrl); + reader.setExecutionIds(jobExecutionId, stepExecutionId); // ID 세팅 + return reader; + } + @Override + protected ItemProcessor createProcessor() { + return companyComplianceDataProcessor; + } + @Bean + @StepScope + public CompanyComplianceDataProcessor companyComplianceDataProcessor( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId) { + return new CompanyComplianceDataProcessor(jobExecutionId); + } + @Override + protected ItemWriter createWriter() { + return companyComplianceDataWriter; + } + + @Bean(name = "CompanyComplianceImportRangeJob") + public Job companyComplianceImportRangeJob() { + return job(); + } + + @Bean(name = "CompanyComplianceImportRangeStep") + public Step companyComplianceImportRangeStep() { + return step(); + } + + /** + * 2단계: Compliance History Value Change 관리 + */ + @Bean + public Tasklet companyComplianceHistoryValueChangeManageTasklet() { + return (contribution, chunkContext) -> { + log.info(">>>>> Company Compliance History Value Change Manage 프로시저 호출 시작"); + + // 1. 입력 포맷(UTC 'Z' 포함) 및 프로시저용 타겟 포맷 정의 + DateTimeFormatter inputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSX"); + DateTimeFormatter targetFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"); + + Map params = batchDateService.getDateRangeWithTimezoneParams(getApiKey()); + + String rawFromDate = params.get("fromDate"); + String rawToDate = params.get("toDate"); + + // 2. UTC 문자열 -> OffsetDateTime -> Asia/Seoul 변환 -> LocalDateTime 추출 + String startDt = convertToKstString(rawFromDate, inputFormatter, targetFormatter); + String endDt = convertToKstString(rawToDate, inputFormatter, targetFormatter); + + log.info("Company Compliance History Value Change Manage 프로시저 변수 (KST 변환): 시작일: {}, 종료일: {}", startDt, endDt); + + // 3. 프로시저 호출 (안전한 파라미터 바인딩 권장) + String procedureCall = String.format("CALL %s.company_compliance_history_value_change_manage(CAST(? AS TIMESTAMP), CAST(? AS TIMESTAMP))", targetSchema); + jdbcTemplate.update(procedureCall, startDt, endDt); + + log.info(">>>>> Company Compliance History Value Change Manage 프로시저 호출 완료"); + return RepeatStatus.FINISHED; + }; + } + /** + * UTC 문자열을 한국 시간(KST) 문자열로 변환하는 헬퍼 메소드 + */ + private String convertToKstString(String rawDate, DateTimeFormatter input, DateTimeFormatter target) { + if (rawDate == null) return null; + + // 1. 문자열을 OffsetDateTime으로 파싱 (Z를 인식하여 UTC 시간으로 인지함) + return OffsetDateTime.parse(rawDate, input) + // 2. 시간대를 서울(+09:00)로 변경 (값이 9시간 더해짐) + .atZoneSameInstant(ZoneId.of("Asia/Seoul")) + // 3. 프로시저 형식에 맞게 포맷팅 + .format(target); + } + @Bean(name = "CompanyComplianceHistoryValueChangeManageStep") + public Step companyComplianceHistoryValueChangeManageStep() { + return new StepBuilder("CompanyComplianceHistoryValueChangeManageStep", jobRepository) + .tasklet(companyComplianceHistoryValueChangeManageTasklet(), transactionManager) + .build(); + } + + /** + * 3단계: 모든 스텝 성공 시 배치 실행 로그(날짜) 업데이트 + */ + @Bean + public Tasklet companyComplianceLastExecutionUpdateTasklet() { + return new LastExecutionUpdateTasklet(jdbcTemplate, targetSchema, getApiKey(), lastExecutionBufferHours); + } + @Bean(name = "CompanyComplianceLastExecutionUpdateStep") + public Step companyComplianceLastExecutionUpdateStep() { + return new StepBuilder("CompanyComplianceLastExecutionUpdateStep", jobRepository) + .tasklet(companyComplianceLastExecutionUpdateTasklet(), transactionManager) + .build(); + } + + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/compliance/config/ComplianceImportRangeJobConfig.java b/src/main/java/com/snp/batch/jobs/batch/compliance/config/ComplianceImportRangeJobConfig.java new file mode 100644 index 0000000..463c767 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/compliance/config/ComplianceImportRangeJobConfig.java @@ -0,0 +1,223 @@ +package com.snp.batch.jobs.batch.compliance.config; + +import com.snp.batch.common.batch.config.BaseMultiStepJobConfig; +import com.snp.batch.common.batch.tasklet.LastExecutionUpdateTasklet; +import com.snp.batch.jobs.batch.compliance.dto.ComplianceDto; +import com.snp.batch.jobs.batch.compliance.entity.ComplianceEntity; +import com.snp.batch.jobs.batch.compliance.processor.ComplianceDataProcessor; +import com.snp.batch.jobs.batch.compliance.reader.ComplianceDataRangeReader; +import com.snp.batch.jobs.batch.compliance.writer.ComplianceDataWriter; +import com.snp.batch.service.BatchApiLogService; +import com.snp.batch.service.BatchDateService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.job.flow.FlowExecutionStatus; +import org.springframework.batch.core.job.flow.JobExecutionDecider; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.reactive.function.client.WebClient; + +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Map; + +@Slf4j +@Configuration +public class ComplianceImportRangeJobConfig extends BaseMultiStepJobConfig { + private final JdbcTemplate jdbcTemplate; + private final WebClient maritimeServiceApiWebClient; + private final ComplianceDataProcessor complianceDataProcessor; + private final ComplianceDataWriter complianceDataWriter; + private final ComplianceDataRangeReader complianceDataRangeReader; + private final BatchDateService batchDateService; + private final BatchApiLogService batchApiLogService; + + @Value("${app.batch.webservice-api.url}") + private String maritimeServiceApiUrl; + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + @Value("${app.batch.last-execution-buffer-hours:24}") + private int lastExecutionBufferHours; + + protected String getApiKey() {return "COMPLIANCE_IMPORT_API";} + + @Override + protected int getChunkSize() { + return 5000; + } + public ComplianceImportRangeJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + ComplianceDataProcessor complianceDataProcessor, + ComplianceDataWriter complianceDataWriter, + JdbcTemplate jdbcTemplate, + @Qualifier("maritimeServiceApiWebClient")WebClient maritimeServiceApiWebClient, + ComplianceDataRangeReader complianceDataRangeReader, + BatchDateService batchDateService, + BatchApiLogService batchApiLogService) { + super(jobRepository, transactionManager); + this.jdbcTemplate = jdbcTemplate; + this.maritimeServiceApiWebClient = maritimeServiceApiWebClient; + this.complianceDataProcessor = complianceDataProcessor; + this.complianceDataWriter = complianceDataWriter; + this.complianceDataRangeReader = complianceDataRangeReader; + this.batchDateService = batchDateService; + this.batchApiLogService = batchApiLogService; + } + + @Override + protected String getJobName() { + return "ComplianceImportRangeJob"; + } + + @Override + protected String getStepName() { + return "ComplianceImportRangeStep"; + } + + @Override + protected Job createJobFlow(JobBuilder jobBuilder) { + return jobBuilder + .start(complianceImportRangeStep()) + .next(complianceEmptyResponseDecider()) + .on("EMPTY_RESPONSE").end() + .from(complianceEmptyResponseDecider()).on("*").to(complianceLastExecutionUpdateStep()) + .end() + .build(); + } + + @Bean + public JobExecutionDecider complianceEmptyResponseDecider() { + return (jobExecution, stepExecution) -> { + if (stepExecution != null && stepExecution.getReadCount() == 0) { + log.info("[ComplianceImportRangeJob] Decider: EMPTY_RESPONSE - 응답 데이터 0건으로 LAST_EXECUTION 업데이트 스킵"); + return new FlowExecutionStatus("EMPTY_RESPONSE"); + } + log.info("[ComplianceImportRangeJob] Decider: NORMAL - LAST_EXECUTION 업데이트 진행"); + return new FlowExecutionStatus("NORMAL"); + }; + } + + @Override + protected ItemReader createReader() { + return complianceDataRangeReader; + } + + @Bean + @StepScope + public ComplianceDataRangeReader complianceDataRangeReader( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, // SpEL로 ID 추출 + @Value("#{stepExecution.id}") Long stepExecutionId + ) { + ComplianceDataRangeReader reader = new ComplianceDataRangeReader(maritimeServiceApiWebClient, jdbcTemplate, batchDateService, batchApiLogService, maritimeServiceApiUrl); + reader.setExecutionIds(jobExecutionId, stepExecutionId); // ID 세팅 + return reader; + } + @Override + protected ItemProcessor createProcessor() { + return complianceDataProcessor; + } + + @Bean + @StepScope + public ComplianceDataProcessor complianceDataProcessor( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId) { + return new ComplianceDataProcessor(jobExecutionId); + } + + @Override + protected ItemWriter createWriter() { + return complianceDataWriter; + } + + @Bean(name = "ComplianceImportRangeJob") + public Job complianceImportRangeJob() { + return job(); + } + + @Bean(name = "ComplianceImportRangeStep") + public Step complianceImportRangeStep() { + return step(); + } + + /** + * 2단계: Compliance History Value Change 관리 + */ + @Bean + public Tasklet complianceHistoryValueChangeManageTasklet() { + return (contribution, chunkContext) -> { + log.info(">>>>> Compliance History Value Change Manage 프로시저 호출 시작"); + + // 1. 입력 포맷(UTC 'Z' 포함) 및 프로시저용 타겟 포맷 정의 + DateTimeFormatter inputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSX"); + DateTimeFormatter targetFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"); + + Map params = batchDateService.getDateRangeWithTimezoneParams(getApiKey()); + + String rawFromDate = params.get("fromDate"); + String rawToDate = params.get("toDate"); + + // 2. UTC 문자열 -> OffsetDateTime -> Asia/Seoul 변환 -> LocalDateTime 추출 + String startDt = convertToKstString(rawFromDate, inputFormatter, targetFormatter); + String endDt = convertToKstString(rawToDate, inputFormatter, targetFormatter); + + log.info("Compliance History Value Change Manage 프로시저 변수 (KST 변환): 시작일: {}, 종료일: {}", startDt, endDt); + + // 3. 프로시저 호출 (안전한 파라미터 바인딩 권장) + String procedureCall = String.format("CALL %s.compliance_history_value_change_manage(CAST(? AS TIMESTAMP), CAST(? AS TIMESTAMP))", targetSchema); + jdbcTemplate.update(procedureCall, startDt, endDt); + + log.info(">>>>> Compliance History Value Change Manage 프로시저 호출 완료"); + return RepeatStatus.FINISHED; + }; + } + /** + * UTC 문자열을 한국 시간(KST) 문자열로 변환하는 헬퍼 메소드 + */ + private String convertToKstString(String rawDate, DateTimeFormatter input, DateTimeFormatter target) { + if (rawDate == null) return null; + + // 1. 문자열을 OffsetDateTime으로 파싱 (Z를 인식하여 UTC 시간으로 인지함) + return OffsetDateTime.parse(rawDate, input) + // 2. 시간대를 서울(+09:00)로 변경 (값이 9시간 더해짐) + .atZoneSameInstant(ZoneId.of("Asia/Seoul")) + // 3. 프로시저 형식에 맞게 포맷팅 + .format(target); + } + @Bean(name = "ComplianceHistoryValueChangeManageStep") + public Step complianceHistoryValueChangeManageStep() { + return new StepBuilder("ComplianceHistoryValueChangeManageStep", jobRepository) + .tasklet(complianceHistoryValueChangeManageTasklet(), transactionManager) + .build(); + } + + /** + * 3단계: 모든 스텝 성공 시 배치 실행 로그(날짜) 업데이트 + */ + @Bean + public Tasklet complianceLastExecutionUpdateTasklet() { + return new LastExecutionUpdateTasklet(jdbcTemplate, targetSchema, getApiKey(), lastExecutionBufferHours); + } + @Bean(name = "ComplianceLastExecutionUpdateStep") + public Step complianceLastExecutionUpdateStep() { + return new StepBuilder("ComplianceLastExecutionUpdateStep", jobRepository) + .tasklet(complianceLastExecutionUpdateTasklet(), transactionManager) + .build(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/compliance/dto/CompanyComplianceDto.java b/src/main/java/com/snp/batch/jobs/batch/compliance/dto/CompanyComplianceDto.java new file mode 100644 index 0000000..1d47c00 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/compliance/dto/CompanyComplianceDto.java @@ -0,0 +1,61 @@ +package com.snp.batch.jobs.batch.compliance.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CompanyComplianceDto { + @JsonProperty("owcode") + private String owcode; + + @JsonProperty("lastUpdated") + private String lastUpdated; + + @JsonProperty("companyOverallComplianceStatus") + private Integer companyOverallComplianceStatus; + + @JsonProperty("companyOnAustralianSanctionList") + private Integer companyOnAustralianSanctionList; + + @JsonProperty("companyOnBESSanctionList") + private Integer companyOnBESSanctionList; + + @JsonProperty("companyOnCanadianSanctionList") + private Integer companyOnCanadianSanctionList; + + @JsonProperty("companyInOFACSanctionedCountry") + private Integer companyInOFACSanctionedCountry; + + @JsonProperty("companyInFATFJurisdiction") + private Integer companyInFATFJurisdiction; + + @JsonProperty("companyOnEUSanctionList") + private Integer companyOnEUSanctionList; + + @JsonProperty("companyOnOFACSanctionList") + private Integer companyOnOFACSanctionList; + + @JsonProperty("companyOnOFACNONSDNSanctionList") + private Integer companyOnOFACNONSDNSanctionList; + + @JsonProperty("companyOnOFACSSISanctionList") + private Integer companyOnOFACSSISanctionList; + + @JsonProperty("parentCompanyNonCompliance") + private Integer parentCompanyNonCompliance; + + @JsonProperty("companyOnSwissSanctionList") + private Integer companyOnSwissSanctionList; + + @JsonProperty("companyOnUAESanctionList") + private Integer companyOnUAESanctionList; + + @JsonProperty("companyOnUNSanctionList") + private Integer companyOnUNSanctionList; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/compliance/dto/ComplianceDto.java b/src/main/java/com/snp/batch/jobs/batch/compliance/dto/ComplianceDto.java new file mode 100644 index 0000000..683145b --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/compliance/dto/ComplianceDto.java @@ -0,0 +1,121 @@ +package com.snp.batch.jobs.batch.compliance.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ComplianceDto { + + @JsonProperty("shipEUSanctionList") + private Integer shipEUSanctionList; + + @JsonProperty("shipUNSanctionList") + private Integer shipUNSanctionList; + + @JsonProperty("lrimoShipNo") + private String lrimoShipNo; + + @JsonProperty("dateAmended") + private String dateAmended; // 수정일시 + + // 2. Compliance Status (Integer 타입은 0, 1, 2 등의 코드 및 null 값 처리) + @JsonProperty("legalOverall") + private Integer legalOverall; // 종합제재 + + @JsonProperty("shipBESSanctionList") + private Integer shipBESSanctionList; // 선박BES제재 + + @JsonProperty("shipDarkActivityIndicator") + private Integer shipDarkActivityIndicator; // 선박다크활동 + + @JsonProperty("shipDetailsNoLongerMaintained") + private Integer shipDetailsNoLongerMaintained; // 선박세부정보미유지 + + @JsonProperty("shipFlagDisputed") + private Integer shipFlagDisputed; // 선박국기논쟁 + + @JsonProperty("shipFlagSanctionedCountry") + private Integer shipFlagSanctionedCountry; // 선박국가제재 + + @JsonProperty("shipHistoricalFlagSanctionedCountry") + private Integer shipHistoricalFlagSanctionedCountry; // 선박국가제재이력 + + @JsonProperty("shipOFACNonSDNSanctionList") + private Integer shipOFACNonSDNSanctionList; // 선박OFAC비SDN제재 + + @JsonProperty("shipOFACSanctionList") + private Integer shipOFACSanctionList; // 선박OFAC제재 + + @JsonProperty("shipOFACAdvisoryList") + private Integer shipOFACAdvisoryList; // 선박OFAC주의 + + @JsonProperty("shipOwnerOFACSSIList") + private Integer shipOwnerOFACSSIList; // 선박소유자OFCS제재 + + @JsonProperty("shipOwnerAustralianSanctionList") + private Integer shipOwnerAustralianSanctionList; // 선박소유자AUS제재 + + @JsonProperty("shipOwnerBESSanctionList") + private Integer shipOwnerBESSanctionList; // 선박소유자BES제재 + + @JsonProperty("shipOwnerCanadianSanctionList") + private Integer shipOwnerCanadianSanctionList; // 선박소유자CAN제재 + + @JsonProperty("shipOwnerEUSanctionList") + private Integer shipOwnerEUSanctionList; // 선박소유자EU제재 + + @JsonProperty("shipOwnerFATFJurisdiction") + private Integer shipOwnerFATFJurisdiction; // 선박소유자FATF규제구역 + + @JsonProperty("shipOwnerHistoricalOFACSanctionedCountry") + private Integer shipOwnerHistoricalOFACSanctionedCountry; // 선박소유자OFAC제재이력 + + @JsonProperty("shipOwnerOFACSanctionList") + private Integer shipOwnerOFACSanctionList; // 선박소유자OFAC제재 + + @JsonProperty("shipOwnerOFACSanctionedCountry") + private Integer shipOwnerOFACSanctionedCountry; // 선박소유자OFAC제재국가 + + @JsonProperty("shipOwnerParentCompanyNonCompliance") + private Integer shipOwnerParentCompanyNonCompliance; // 선박소유자모회사비준수 + + @JsonProperty("shipOwnerParentFATFJurisdiction") + private Integer shipOwnerParentFATFJurisdiction; // 선박소유자모회사FATF규제구역 (JSON에 null 포함) + + @JsonProperty("shipOwnerParentOFACSanctionedCountry") + private Integer shipOwnerParentOFACSanctionedCountry; // 선박소유자모회사OFAC제재국가 (JSON에 null 포함) + + @JsonProperty("shipOwnerSwissSanctionList") + private Integer shipOwnerSwissSanctionList; // 선박소유자SWI제재 + + @JsonProperty("shipOwnerUAESanctionList") + private Integer shipOwnerUAESanctionList; // 선박소유자UAE제재 + + @JsonProperty("shipOwnerUNSanctionList") + private Integer shipOwnerUNSanctionList; // 선박소유자UN제재 + + @JsonProperty("shipSanctionedCountryPortCallLast12m") + private Integer shipSanctionedCountryPortCallLast12m; // 선박제재국가기항최종12M + + @JsonProperty("shipSanctionedCountryPortCallLast3m") + private Integer shipSanctionedCountryPortCallLast3m; // 선박제재국가기항최종3M + + @JsonProperty("shipSanctionedCountryPortCallLast6m") + private Integer shipSanctionedCountryPortCallLast6m; // 선박제재국가기항최종6M + + @JsonProperty("shipSecurityLegalDisputeEvent") + private Integer shipSecurityLegalDisputeEvent; // 선박보안법적분쟁이벤트 + + @JsonProperty("shipSTSPartnerNonComplianceLast12m") + private Integer shipSTSPartnerNonComplianceLast12m; // 선박STS파트너비준수12M + + @JsonProperty("shipSwissSanctionList") + private Integer shipSwissSanctionList; // 선박SWI제재 + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/compliance/entity/CompanyComplianceEntity.java b/src/main/java/com/snp/batch/jobs/batch/compliance/entity/CompanyComplianceEntity.java new file mode 100644 index 0000000..6515242 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/compliance/entity/CompanyComplianceEntity.java @@ -0,0 +1,47 @@ +package com.snp.batch.jobs.batch.compliance.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class CompanyComplianceEntity extends BaseEntity { + private String owcode; + + private String lastUpdated; + + private Integer companyOverallComplianceStatus; + + private Integer companyOnAustralianSanctionList; + + private Integer companyOnBESSanctionList; + + private Integer companyOnCanadianSanctionList; + + private Integer companyInOFACSanctionedCountry; + + private Integer companyInFATFJurisdiction; + + private Integer companyOnEUSanctionList; + + private Integer companyOnOFACSanctionList; + + private Integer companyOnOFACNONSDNSanctionList; + + private Integer companyOnOFACSSISanctionList; + + private Integer parentCompanyNonCompliance; + + private Integer companyOnSwissSanctionList; + + private Integer companyOnUAESanctionList; + + private Integer companyOnUNSanctionList; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/compliance/entity/ComplianceEntity.java b/src/main/java/com/snp/batch/jobs/batch/compliance/entity/ComplianceEntity.java new file mode 100644 index 0000000..c49ac02 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/compliance/entity/ComplianceEntity.java @@ -0,0 +1,89 @@ +package com.snp.batch.jobs.batch.compliance.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class ComplianceEntity extends BaseEntity { + + private String lrimoShipNo; // LR/IMO번호 + + private String dateAmended; // 수정일시 + + // 2. Compliance Status (모든 필드는 DTO와 동일한 Integer 타입) + private Integer legalOverall; // 종합제재 + + private Integer shipBESSanctionList; // 선박BES제재 + + private Integer shipDarkActivityIndicator; // 선박다크활동 + + private Integer shipDetailsNoLongerMaintained; // 선박세부정보미유지 + + private Integer shipEUSanctionList; // 선박EU제재 + + private Integer shipFlagDisputed; // 선박국기논쟁 + + private Integer shipFlagSanctionedCountry; // 선박국가제재 + + private Integer shipHistoricalFlagSanctionedCountry; // 선박국가제재이력 + + private Integer shipOFACNonSDNSanctionList; // 선박OFAC비SDN제재 + + private Integer shipOFACSanctionList; // 선박OFAC제재 + + private Integer shipOFACAdvisoryList; // 선박OFAC주의 + + private Integer shipOwnerOFACSSIList; // 선박소유자OFCS제재 + + private Integer shipOwnerAustralianSanctionList; // 선박소유자AUS제재 + + private Integer shipOwnerBESSanctionList; // 선박소유자BES제재 + + private Integer shipOwnerCanadianSanctionList; // 선박소유자CAN제재 + + private Integer shipOwnerEUSanctionList; // 선박소유자EU제재 + + private Integer shipOwnerFATFJurisdiction; // 선박소유자FATF규제구역 + + private Integer shipOwnerHistoricalOFACSanctionedCountry; // 선박소유자OFAC제재이력 + + private Integer shipOwnerOFACSanctionList; // 선박소유자OFAC제재 + + private Integer shipOwnerOFACSanctionedCountry; // 선박소유자OFAC제재국가 + + private Integer shipOwnerParentCompanyNonCompliance; // 선박소유자모회사비준수 + + private Integer shipOwnerParentFATFJurisdiction; // 선박소유자모회사FATF규제구역 + + private Integer shipOwnerParentOFACSanctionedCountry; // 선박소유자모회사OFAC제재국가 + + private Integer shipOwnerSwissSanctionList; // 선박소유자SWI제재 + + private Integer shipOwnerUAESanctionList; // 선박소유자UAE제재 + + private Integer shipOwnerUNSanctionList; // 선박소유자UN제재 + + private Integer shipSanctionedCountryPortCallLast12m; // 선박제재국가기항최종12M + + private Integer shipSanctionedCountryPortCallLast3m; // 선박제재국가기항최종3M + + private Integer shipSanctionedCountryPortCallLast6m; // 선박제재국가기항최종6M + + private Integer shipSecurityLegalDisputeEvent; // 선박보안법적분쟁이벤트 + + private Integer shipSTSPartnerNonComplianceLast12m; // 선박STS파트너비준수12M + + private Integer shipSwissSanctionList; // 선박SWI제재 + + private Integer shipUNSanctionList; // 선박UN제재 + + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/compliance/processor/CompanyComplianceDataProcessor.java b/src/main/java/com/snp/batch/jobs/batch/compliance/processor/CompanyComplianceDataProcessor.java new file mode 100644 index 0000000..7ce2d3b --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/compliance/processor/CompanyComplianceDataProcessor.java @@ -0,0 +1,42 @@ +package com.snp.batch.jobs.batch.compliance.processor; + +import com.snp.batch.common.batch.processor.BaseProcessor; +import com.snp.batch.jobs.batch.compliance.dto.CompanyComplianceDto; +import com.snp.batch.jobs.batch.compliance.entity.CompanyComplianceEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class CompanyComplianceDataProcessor extends BaseProcessor { + private final Long jobExecutionId; + public CompanyComplianceDataProcessor(@Value("#{stepExecution.jobExecution.id}") Long jobExecutionId) { + this.jobExecutionId = jobExecutionId; + } + @Override + protected CompanyComplianceEntity processItem(CompanyComplianceDto dto) throws Exception { + + CompanyComplianceEntity entity = CompanyComplianceEntity.builder() + .owcode(dto.getOwcode()) + .lastUpdated(dto.getLastUpdated()) + .companyOverallComplianceStatus(dto.getCompanyOverallComplianceStatus()) + .companyOnAustralianSanctionList(dto.getCompanyOnAustralianSanctionList()) + .companyOnBESSanctionList(dto.getCompanyOnBESSanctionList()) + .companyOnCanadianSanctionList(dto.getCompanyOnCanadianSanctionList()) + .companyInOFACSanctionedCountry(dto.getCompanyInOFACSanctionedCountry()) + .companyInFATFJurisdiction(dto.getCompanyInFATFJurisdiction()) + .companyOnEUSanctionList(dto.getCompanyOnEUSanctionList()) + .companyOnOFACSanctionList(dto.getCompanyOnOFACSanctionList()) + .companyOnOFACNONSDNSanctionList(dto.getCompanyOnOFACNONSDNSanctionList()) + .companyOnOFACSSISanctionList(dto.getCompanyOnOFACSSISanctionList()) + .parentCompanyNonCompliance(dto.getParentCompanyNonCompliance()) + .companyOnSwissSanctionList(dto.getCompanyOnSwissSanctionList()) + .companyOnUAESanctionList(dto.getCompanyOnUAESanctionList()) + .companyOnUNSanctionList(dto.getCompanyOnUNSanctionList()) + .jobExecutionId(jobExecutionId) + .createdBy("SYSTEM") + .build(); + return entity; + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/compliance/processor/ComplianceDataProcessor.java b/src/main/java/com/snp/batch/jobs/batch/compliance/processor/ComplianceDataProcessor.java new file mode 100644 index 0000000..a33c7f3 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/compliance/processor/ComplianceDataProcessor.java @@ -0,0 +1,64 @@ +package com.snp.batch.jobs.batch.compliance.processor; + +import com.snp.batch.common.batch.processor.BaseProcessor; +import com.snp.batch.jobs.batch.compliance.dto.ComplianceDto; +import com.snp.batch.jobs.batch.compliance.entity.ComplianceEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class ComplianceDataProcessor extends BaseProcessor { + private final Long jobExecutionId; + public ComplianceDataProcessor(@Value("#{stepExecution.jobExecution.id}") Long jobExecutionId) { + this.jobExecutionId = jobExecutionId; + } + @Override + protected ComplianceEntity processItem(ComplianceDto dto) throws Exception { + + ComplianceEntity entity = ComplianceEntity.builder() + // 1. Primary Keys + .lrimoShipNo(dto.getLrimoShipNo()) + .dateAmended(dto.getDateAmended()) + // 2. Compliance Status + .legalOverall(dto.getLegalOverall()) + .shipBESSanctionList(dto.getShipBESSanctionList()) + .shipDarkActivityIndicator(dto.getShipDarkActivityIndicator()) + .shipDetailsNoLongerMaintained(dto.getShipDetailsNoLongerMaintained()) + .shipEUSanctionList(dto.getShipEUSanctionList()) + .shipFlagDisputed(dto.getShipFlagDisputed()) + .shipFlagSanctionedCountry(dto.getShipFlagSanctionedCountry()) + .shipHistoricalFlagSanctionedCountry(dto.getShipHistoricalFlagSanctionedCountry()) + .shipOFACNonSDNSanctionList(dto.getShipOFACNonSDNSanctionList()) + .shipOFACSanctionList(dto.getShipOFACSanctionList()) + .shipOFACAdvisoryList(dto.getShipOFACAdvisoryList()) + .shipOwnerOFACSSIList(dto.getShipOwnerOFACSSIList()) + .shipOwnerAustralianSanctionList(dto.getShipOwnerAustralianSanctionList()) + .shipOwnerBESSanctionList(dto.getShipOwnerBESSanctionList()) + .shipOwnerCanadianSanctionList(dto.getShipOwnerCanadianSanctionList()) + .shipOwnerEUSanctionList(dto.getShipOwnerEUSanctionList()) + .shipOwnerFATFJurisdiction(dto.getShipOwnerFATFJurisdiction()) + .shipOwnerHistoricalOFACSanctionedCountry(dto.getShipOwnerHistoricalOFACSanctionedCountry()) + .shipOwnerOFACSanctionList(dto.getShipOwnerOFACSanctionList()) + .shipOwnerOFACSanctionedCountry(dto.getShipOwnerOFACSanctionedCountry()) + .shipOwnerParentCompanyNonCompliance(dto.getShipOwnerParentCompanyNonCompliance()) + .shipOwnerParentFATFJurisdiction(dto.getShipOwnerParentFATFJurisdiction()) + .shipOwnerParentOFACSanctionedCountry(dto.getShipOwnerParentOFACSanctionedCountry()) + .shipOwnerSwissSanctionList(dto.getShipOwnerSwissSanctionList()) + .shipOwnerUAESanctionList(dto.getShipOwnerUAESanctionList()) + .shipOwnerUNSanctionList(dto.getShipOwnerUNSanctionList()) + .shipSanctionedCountryPortCallLast12m(dto.getShipSanctionedCountryPortCallLast12m()) + .shipSanctionedCountryPortCallLast3m(dto.getShipSanctionedCountryPortCallLast3m()) + .shipSanctionedCountryPortCallLast6m(dto.getShipSanctionedCountryPortCallLast6m()) + .shipSecurityLegalDisputeEvent(dto.getShipSecurityLegalDisputeEvent()) + .shipSTSPartnerNonComplianceLast12m(dto.getShipSTSPartnerNonComplianceLast12m()) + .shipSwissSanctionList(dto.getShipSwissSanctionList()) + .shipUNSanctionList(dto.getShipUNSanctionList()) + .jobExecutionId(jobExecutionId) + .createdBy("SYSTEM") + .build(); + + return entity; + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/compliance/reader/CompanyComplianceDataRangeReader.java b/src/main/java/com/snp/batch/jobs/batch/compliance/reader/CompanyComplianceDataRangeReader.java new file mode 100644 index 0000000..5e76273 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/compliance/reader/CompanyComplianceDataRangeReader.java @@ -0,0 +1,108 @@ +package com.snp.batch.jobs.batch.compliance.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.batch.compliance.dto.CompanyComplianceDto; +import com.snp.batch.service.BatchApiLogService; +import com.snp.batch.service.BatchDateService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.List; +import java.util.Map; + +@Slf4j +public class CompanyComplianceDataRangeReader extends BaseApiReader { + private final BatchDateService batchDateService; // ✨ BatchDateService 필드 추가 + private final BatchApiLogService batchApiLogService; + String maritimeServiceApiUrl; + private List allData; + private int currentBatchIndex = 0; + private final int batchSize = 5000; + + public CompanyComplianceDataRangeReader(WebClient webClient, BatchDateService batchDateService, BatchApiLogService batchApiLogService, String maritimeServiceApiUrl) { + super(webClient); + this.batchDateService = batchDateService; + this.batchApiLogService = batchApiLogService; + this.maritimeServiceApiUrl = maritimeServiceApiUrl; + enableChunkMode(); + } + @Override + protected String getReaderName() { + return "CompanyComplianceDataRangeReader"; + } + @Override + protected String getApiPath() { + return "/RiskAndCompliance/UpdatedCompanyComplianceList"; + } + protected String getApiKey() { + return "COMPANY_COMPLIANCE_IMPORT_API"; + } + @Override + protected void resetCustomState() { + this.currentBatchIndex = 0; + this.allData = null; + } + + @Override + protected List fetchNextBatch() throws Exception{ + // 모든 배치 처리 완료 확인 + if (allData == null) { + allData = callApiWithBatch(); + + if (allData == null || allData.isEmpty()) { + log.warn("[{}] 조회된 데이터 없음 → 종료", getReaderName()); + return null; + } + + log.info("[{}] 총 {}건 데이터 조회됨. batchSize = {}", getReaderName(), allData.size(), batchSize); + } + + // 2) 이미 끝까지 읽었으면 종료 + if (currentBatchIndex >= allData.size()) { + log.info("[{}] 모든 배치 처리 완료", getReaderName()); + return null; + } + + // 3) 이번 배치의 end 계산 + int end = Math.min(currentBatchIndex + batchSize, allData.size()); + + // 4) 현재 batch 리스트 잘라서 반환 + List batch = allData.subList(currentBatchIndex, end); + + int batchNum = (currentBatchIndex / batchSize) + 1; + int totalBatches = (int) Math.ceil((double) allData.size() / batchSize); + + log.info("[{}] 배치 {}/{} 처리 중: {}건", getReaderName(), batchNum, totalBatches, batch.size()); + + // 다음 batch 인덱스 이동 + currentBatchIndex = end; + updateApiCallStats(totalBatches, batchNum); + + return batch; + } + + @Override + protected void afterFetch(List data){ + try{ + if (data == null) { + log.info("[{}] 배치 처리 성공", getReaderName()); + } + }catch (Exception e){ + log.info("[{}] 배치 처리 실패", getReaderName()); + log.info("[{}] API 호출 종료", getReaderName()); + } + } + private List callApiWithBatch() { + Map params = batchDateService.getDateRangeWithTimezoneParams(getApiKey()); + // 부모 클래스의 공통 모듈 호출 (단 한 줄로 처리 가능) + return executeListApiCall( + maritimeServiceApiUrl, + getApiPath(), + params, + new ParameterizedTypeReference>() {}, + batchApiLogService + ); + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/compliance/reader/ComplianceDataRangeReader.java b/src/main/java/com/snp/batch/jobs/batch/compliance/reader/ComplianceDataRangeReader.java new file mode 100644 index 0000000..ee41d5a --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/compliance/reader/ComplianceDataRangeReader.java @@ -0,0 +1,116 @@ +package com.snp.batch.jobs.batch.compliance.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.batch.compliance.dto.ComplianceDto; +import com.snp.batch.service.BatchApiLogService; +import com.snp.batch.service.BatchDateService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.List; +import java.util.Map; + +@Slf4j +public class ComplianceDataRangeReader extends BaseApiReader { + + private final JdbcTemplate jdbcTemplate; + private final BatchDateService batchDateService; // ✨ BatchDateService 필드 추가 + private final BatchApiLogService batchApiLogService; + String maritimeServiceApiUrl; + private List allData; + private int currentBatchIndex = 0; + private final int batchSize = 5000; + + public ComplianceDataRangeReader(WebClient webClient, JdbcTemplate jdbcTemplate, BatchDateService batchDateService, BatchApiLogService batchApiLogService, String maritimeServiceApiUrl) { + super(webClient); + this.jdbcTemplate = jdbcTemplate; + this.batchDateService = batchDateService; + this.batchApiLogService = batchApiLogService; + this.maritimeServiceApiUrl = maritimeServiceApiUrl; + enableChunkMode(); + } + + @Override + protected String getReaderName() { + return "ComplianceDataRangeReader"; + } + @Override + protected String getApiPath() { + return "/RiskAndCompliance/UpdatedComplianceList"; + } + + protected String getApiKey() { + return "COMPLIANCE_IMPORT_API"; + } + + @Override + protected void resetCustomState() { + this.currentBatchIndex = 0; + this.allData = null; + } + + @Override + protected List fetchNextBatch() throws Exception { + // 모든 배치 처리 완료 확인 + if (allData == null) { + allData = callApiWithBatch(); + + if (allData == null || allData.isEmpty()) { + log.warn("[{}] 조회된 데이터 없음 → 종료", getReaderName()); + return null; + } + + log.info("[{}] 총 {}건 데이터 조회됨. batchSize = {}", getReaderName(), allData.size(), batchSize); + } + + // 2) 이미 끝까지 읽었으면 종료 + if (currentBatchIndex >= allData.size()) { + log.info("[{}] 모든 배치 처리 완료", getReaderName()); + return null; + } + + // 3) 이번 배치의 end 계산 + int end = Math.min(currentBatchIndex + batchSize, allData.size()); + + // 4) 현재 batch 리스트 잘라서 반환 + List batch = allData.subList(currentBatchIndex, end); + + int batchNum = (currentBatchIndex / batchSize) + 1; + int totalBatches = (int) Math.ceil((double) allData.size() / batchSize); + + log.info("[{}] 배치 {}/{} 처리 중: {}건", getReaderName(), batchNum, totalBatches, batch.size()); + + // 다음 batch 인덱스 이동 + currentBatchIndex = end; + updateApiCallStats(totalBatches, batchNum); + + return batch; + } + + @Override + protected void afterFetch(List data) { + try{ + if (data == null) { + log.info("[{}] 배치 처리 성공", getReaderName()); + } + }catch (Exception e){ + log.info("[{}] 배치 처리 실패", getReaderName()); + log.info("[{}] API 호출 종료", getReaderName()); + } + } + + private List callApiWithBatch() { + Map params = batchDateService.getDateRangeWithTimezoneParams(getApiKey()); + // 부모 클래스의 공통 모듈 호출 (단 한 줄로 처리 가능) + return executeListApiCall( + maritimeServiceApiUrl, + getApiPath(), + params, + new ParameterizedTypeReference>() {}, + batchApiLogService + ); + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/compliance/repository/CompanyComplianceRepository.java b/src/main/java/com/snp/batch/jobs/batch/compliance/repository/CompanyComplianceRepository.java new file mode 100644 index 0000000..073ccb9 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/compliance/repository/CompanyComplianceRepository.java @@ -0,0 +1,10 @@ +package com.snp.batch.jobs.batch.compliance.repository; + +import com.snp.batch.jobs.batch.compliance.entity.CompanyComplianceEntity; + +import java.util.List; + +public interface CompanyComplianceRepository { + void saveCompanyComplianceAll(List items); +// void saveCompanyComplianceHistoryAll(List items); +} diff --git a/src/main/java/com/snp/batch/jobs/batch/compliance/repository/CompanyComplianceRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/batch/compliance/repository/CompanyComplianceRepositoryImpl.java new file mode 100644 index 0000000..05dddb8 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/compliance/repository/CompanyComplianceRepositoryImpl.java @@ -0,0 +1,119 @@ +package com.snp.batch.jobs.batch.compliance.repository; + +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.batch.compliance.entity.CompanyComplianceEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.Types; +import java.util.List; + +@Slf4j +@Repository("CompanyComplianceRepository") +public class CompanyComplianceRepositoryImpl extends BaseJdbcRepository implements CompanyComplianceRepository{ + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + + @Value("${app.batch.target-schema.tables.risk-compliance-003}") + private String tableName; + + public CompanyComplianceRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + protected String getTargetSchema() { + return targetSchema; + } + + @Override + protected String getSimpleTableName() { + return tableName; + } + + @Override + protected RowMapper getRowMapper() { + return null; + } + + @Override + protected Long extractId(CompanyComplianceEntity entity) { + return null; + } + + @Override + protected String getInsertSql() { + return null; + } + + @Override + protected String getUpdateSql() { + return """ + INSERT INTO %s( + company_cd, lst_mdfcn_dt, + company_snths_compliance_status, company_aus_sanction_list, company_bes_sanction_list, company_can_sanction_list, company_ofac_sanction_country, + company_fatf_cmptnc_country, company_eu_sanction_list, company_ofac_sanction_list, company_ofac_non_sdn_sanction_list, company_ofacssi_sanction_list, + company_swiss_sanction_list, company_uae_sanction_list, company_un_sanction_list, prnt_company_compliance_risk, + job_execution_id, creatr_id + )VALUES( + ?, ?::timestamp, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ? + ); + """.formatted(getTableName()); + } + + @Override + protected void setInsertParameters(PreparedStatement ps, CompanyComplianceEntity entity) throws Exception { + } + + @Override + protected void setUpdateParameters(PreparedStatement ps, CompanyComplianceEntity entity) throws Exception { + int idx = 1; + ps.setString(idx++, entity.getOwcode()); + ps.setString(idx++, entity.getLastUpdated()); + ps.setObject(idx++, entity.getCompanyOverallComplianceStatus(), Types.INTEGER); + ps.setObject(idx++, entity.getCompanyOnAustralianSanctionList(), Types.INTEGER); + ps.setObject(idx++, entity.getCompanyOnBESSanctionList(), Types.INTEGER); + ps.setObject(idx++, entity.getCompanyOnCanadianSanctionList(), Types.INTEGER); + ps.setObject(idx++, entity.getCompanyInOFACSanctionedCountry(), Types.INTEGER); + ps.setObject(idx++, entity.getCompanyInFATFJurisdiction(), Types.INTEGER); + ps.setObject(idx++, entity.getCompanyOnEUSanctionList(), Types.INTEGER); + ps.setObject(idx++, entity.getCompanyOnOFACSanctionList(), Types.INTEGER); + ps.setObject(idx++, entity.getCompanyOnOFACNONSDNSanctionList(), Types.INTEGER); + ps.setObject(idx++, entity.getCompanyOnOFACSSISanctionList(), Types.INTEGER); + ps.setObject(idx++, entity.getCompanyOnSwissSanctionList(), Types.INTEGER); + ps.setObject(idx++, entity.getCompanyOnUAESanctionList(), Types.INTEGER); + ps.setObject(idx++, entity.getCompanyOnUNSanctionList(), Types.INTEGER); + ps.setObject(idx++, entity.getParentCompanyNonCompliance(), Types.INTEGER); + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + + @Override + protected String getEntityName() { + return "CompanyComplianceEntity"; + } + + @Override + public void saveCompanyComplianceAll(List items) { + if (items == null || items.isEmpty()) { + return; + } + jdbcTemplate.batchUpdate(getUpdateSql(), items, items.size(), + (ps, entity) -> { + try { + setUpdateParameters(ps, entity); + } catch (Exception e) { + log.error("배치 수정 파라미터 설정 실패", e); + throw new RuntimeException(e); + } + }); + log.info("{} 전체 저장 완료: 수정={} 건", getEntityName(), items.size()); + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/compliance/repository/ComplianceRepository.java b/src/main/java/com/snp/batch/jobs/batch/compliance/repository/ComplianceRepository.java new file mode 100644 index 0000000..94e193d --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/compliance/repository/ComplianceRepository.java @@ -0,0 +1,10 @@ +package com.snp.batch.jobs.batch.compliance.repository; + +import com.snp.batch.jobs.batch.compliance.entity.ComplianceEntity; + +import java.util.List; + +public interface ComplianceRepository { + void saveComplianceAll(List items); +// void saveComplianceHistoryAll(List items); +} diff --git a/src/main/java/com/snp/batch/jobs/batch/compliance/repository/ComplianceRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/batch/compliance/repository/ComplianceRepositoryImpl.java new file mode 100644 index 0000000..346ff68 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/compliance/repository/ComplianceRepositoryImpl.java @@ -0,0 +1,146 @@ +package com.snp.batch.jobs.batch.compliance.repository; + +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.batch.compliance.entity.ComplianceEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.Types; +import java.util.List; + +@Slf4j +@Repository("ComplianceRepository") +public class ComplianceRepositoryImpl extends BaseJdbcRepository implements ComplianceRepository { + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + + @Value("${app.batch.target-schema.tables.risk-compliance-002}") + private String tableName; + + public ComplianceRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + protected String getTargetSchema() { + return targetSchema; + } + + @Override + protected String getSimpleTableName() { + return tableName; + } + + @Override + protected RowMapper getRowMapper() { + return null; + } + + @Override + protected Long extractId(ComplianceEntity entity) { + return null; + } + + @Override + protected String getInsertSql() { + return null; + } + + @Override + protected String getUpdateSql() { + return """ + INSERT INTO %s ( + imo_no, last_mdfcn_dt, lgl_snths_sanction, ship_bes_sanction_list, ship_dark_actv_ind, + ship_dtld_info_ntmntd, ship_eu_sanction_list, ship_flg_dspt, ship_flg_sanction_country, + ship_flg_sanction_country_hstry, ship_ofac_non_sdn_sanction_list, ship_ofac_sanction_list, + ship_ofac_cutn_list, ship_ownr_ofcs_sanction_list, ship_ownr_aus_sanction_list, ship_ownr_bes_sanction_list, + ship_ownr_can_sanction_list, ship_ownr_eu_sanction_list, ship_ownr_fatf_rgl_zone, + ship_ownr_ofac_sanction_hstry, ship_ownr_ofac_sanction_list, ship_ownr_ofac_sanction_country, + ship_ownr_prnt_company_ncmplnc, ship_ownr_prnt_company_fatf_rgl_zone, ship_ownr_prnt_company_ofac_sanction_country, + ship_ownr_swi_sanction_list, ship_ownr_uae_sanction_list, ship_ownr_un_sanction_list, + ship_sanction_country_prtcll_last_twelve_m, ship_sanction_country_prtcll_last_thr_m, ship_sanction_country_prtcll_last_six_m, + ship_scrty_lgl_dspt_event, ship_sts_prtnr_non_compliance_twelve_m, ship_swi_sanction_list, + ship_un_sanction_list, + job_execution_id, creatr_id + ) + VALUES ( + ?, ?::timestamptz, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ? + ); + """.formatted(getTableName()); + } + + @Override + protected void setInsertParameters(PreparedStatement ps, ComplianceEntity entity) throws Exception { + + } + + @Override + protected void setUpdateParameters(PreparedStatement ps, ComplianceEntity entity) throws Exception { + int idx = 1; + ps.setString(idx++, entity.getLrimoShipNo()); + ps.setString(idx++, entity.getDateAmended()); + ps.setObject(idx++, entity.getLegalOverall(), Types.INTEGER); + ps.setObject(idx++, entity.getShipBESSanctionList(), Types.INTEGER); + ps.setObject(idx++, entity.getShipDarkActivityIndicator(), Types.INTEGER); + ps.setObject(idx++, entity.getShipDetailsNoLongerMaintained(), Types.INTEGER); + ps.setObject(idx++, entity.getShipEUSanctionList(), Types.INTEGER); + ps.setObject(idx++, entity.getShipFlagDisputed(), Types.INTEGER); + ps.setObject(idx++, entity.getShipFlagSanctionedCountry(), Types.INTEGER); + ps.setObject(idx++, entity.getShipHistoricalFlagSanctionedCountry(), Types.INTEGER); + ps.setObject(idx++, entity.getShipOFACNonSDNSanctionList(), Types.INTEGER); + ps.setObject(idx++, entity.getShipOFACSanctionList(), Types.INTEGER); + ps.setObject(idx++, entity.getShipOFACAdvisoryList(), Types.INTEGER); + ps.setObject(idx++, entity.getShipOwnerOFACSSIList(), Types.INTEGER); + ps.setObject(idx++, entity.getShipOwnerAustralianSanctionList(), Types.INTEGER); + ps.setObject(idx++, entity.getShipOwnerBESSanctionList(), Types.INTEGER); + ps.setObject(idx++, entity.getShipOwnerCanadianSanctionList(), Types.INTEGER); + ps.setObject(idx++, entity.getShipOwnerEUSanctionList(), Types.INTEGER); + ps.setObject(idx++, entity.getShipOwnerFATFJurisdiction(), Types.INTEGER); + ps.setObject(idx++, entity.getShipOwnerHistoricalOFACSanctionedCountry(), Types.INTEGER); + ps.setObject(idx++, entity.getShipOwnerOFACSanctionList(), Types.INTEGER); + ps.setObject(idx++, entity.getShipOwnerOFACSanctionedCountry(), Types.INTEGER); + ps.setObject(idx++, entity.getShipOwnerParentCompanyNonCompliance(), Types.INTEGER); + ps.setObject(idx++, entity.getShipOwnerParentFATFJurisdiction(), Types.INTEGER); + ps.setObject(idx++, entity.getShipOwnerParentOFACSanctionedCountry(), Types.INTEGER); + ps.setObject(idx++, entity.getShipOwnerSwissSanctionList(), Types.INTEGER); + ps.setObject(idx++, entity.getShipOwnerUAESanctionList(), Types.INTEGER); + ps.setObject(idx++, entity.getShipOwnerUNSanctionList(), Types.INTEGER); + ps.setObject(idx++, entity.getShipSanctionedCountryPortCallLast12m(), Types.INTEGER); + ps.setObject(idx++, entity.getShipSanctionedCountryPortCallLast3m(), Types.INTEGER); + ps.setObject(idx++, entity.getShipSanctionedCountryPortCallLast6m(), Types.INTEGER); + ps.setObject(idx++, entity.getShipSecurityLegalDisputeEvent(), Types.INTEGER); + ps.setObject(idx++, entity.getShipSTSPartnerNonComplianceLast12m(), Types.INTEGER); + ps.setObject(idx++, entity.getShipSwissSanctionList(), Types.INTEGER); + ps.setObject(idx++, entity.getShipUNSanctionList(), Types.INTEGER); + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + + @Override + protected String getEntityName() { + return "ComplianceEntity"; + } + + @Override + public void saveComplianceAll(List items) { + if (items == null || items.isEmpty()) { + return; + } + jdbcTemplate.batchUpdate(getUpdateSql(), items, items.size(), + (ps, entity) -> { + try { + setUpdateParameters(ps, entity); + } catch (Exception e) { + log.error("배치 수정 파라미터 설정 실패", e); + throw new RuntimeException(e); + } + }); + log.info("{} 전체 저장 완료: 수정={} 건", getEntityName(), items.size()); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/compliance/writer/CompanyComplianceDataWriter.java b/src/main/java/com/snp/batch/jobs/batch/compliance/writer/CompanyComplianceDataWriter.java new file mode 100644 index 0000000..0048638 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/compliance/writer/CompanyComplianceDataWriter.java @@ -0,0 +1,25 @@ +package com.snp.batch.jobs.batch.compliance.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.batch.compliance.entity.CompanyComplianceEntity; +import com.snp.batch.jobs.batch.compliance.repository.CompanyComplianceRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Slf4j +@Component +public class CompanyComplianceDataWriter extends BaseWriter { + private final CompanyComplianceRepository complianceRepository; + public CompanyComplianceDataWriter(CompanyComplianceRepository complianceRepository) { + super("CompanyComplianceRepository"); + this.complianceRepository = complianceRepository; + } + + @Override + protected void writeItems(List items) throws Exception { + complianceRepository.saveCompanyComplianceAll(items); +// complianceRepository.saveCompanyComplianceHistoryAll(items); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/compliance/writer/ComplianceDataWriter.java b/src/main/java/com/snp/batch/jobs/batch/compliance/writer/ComplianceDataWriter.java new file mode 100644 index 0000000..ef842e3 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/compliance/writer/ComplianceDataWriter.java @@ -0,0 +1,24 @@ +package com.snp.batch.jobs.batch.compliance.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.batch.compliance.entity.ComplianceEntity; +import com.snp.batch.jobs.batch.compliance.repository.ComplianceRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Slf4j +@Component +public class ComplianceDataWriter extends BaseWriter { + private final ComplianceRepository complianceRepository; + public ComplianceDataWriter(ComplianceRepository complianceRepository) { + super("ComplianceRepository"); + this.complianceRepository = complianceRepository; + } + @Override + protected void writeItems(List items) throws Exception { + complianceRepository.saveComplianceAll(items); +// complianceRepository.saveComplianceHistoryAll(items); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/event/config/EventImportJobConfig.java b/src/main/java/com/snp/batch/jobs/batch/event/config/EventImportJobConfig.java new file mode 100644 index 0000000..9f93445 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/event/config/EventImportJobConfig.java @@ -0,0 +1,168 @@ +package com.snp.batch.jobs.batch.event.config; + +import com.snp.batch.common.batch.config.BaseMultiStepJobConfig; +import com.snp.batch.common.batch.tasklet.LastExecutionUpdateTasklet; +import com.snp.batch.jobs.batch.event.dto.EventDetailDto; +import com.snp.batch.jobs.batch.event.entity.EventDetailEntity; +import com.snp.batch.jobs.batch.event.processor.EventDataProcessor; +import com.snp.batch.jobs.batch.event.reader.EventDataReader; +import com.snp.batch.jobs.batch.event.writer.EventDataWriter; +import com.snp.batch.service.BatchApiLogService; +import com.snp.batch.service.BatchDateService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.job.flow.FlowExecutionStatus; +import org.springframework.batch.core.job.flow.JobExecutionDecider; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.reactive.function.client.WebClient; + +@Slf4j +@Configuration +public class EventImportJobConfig extends BaseMultiStepJobConfig { + private final EventDataProcessor eventDataProcessor; + private final EventDataWriter eventDataWriter; + private final EventDataReader eventDataReader; + private final JdbcTemplate jdbcTemplate; + private final WebClient maritimeApiWebClient; + private final BatchDateService batchDateService; + private final BatchApiLogService batchApiLogService; + + @Value("${app.batch.ship-api.url}") + private String maritimeApiUrl; + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + + @Value("${app.batch.last-execution-buffer-hours:24}") + private int lastExecutionBufferHours; + + protected String getApiKey() {return "EVENT_IMPORT_API";} + + @Override + protected int getChunkSize() { + return 1000; // API에서 5000개씩 가져오므로 chunk도 5000으로 설정 + } + public EventImportJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + EventDataProcessor eventDataProcessor, + EventDataWriter eventDataWriter, + EventDataReader eventDataReader, + JdbcTemplate jdbcTemplate, + @Qualifier("maritimeApiWebClient")WebClient maritimeApiWebClient, + BatchDateService batchDateService, + BatchApiLogService batchApiLogService) { + super(jobRepository, transactionManager); + this.jdbcTemplate = jdbcTemplate; + this.maritimeApiWebClient = maritimeApiWebClient; + this.eventDataProcessor = eventDataProcessor; + this.eventDataWriter = eventDataWriter; + this.eventDataReader = eventDataReader; + this.batchDateService = batchDateService; + this.batchApiLogService = batchApiLogService; + } + + @Override + protected String getJobName() { + return "EventImportJob"; + } + + @Override + protected String getStepName() { + return "EventImportStep"; + } + + @Override + protected Job createJobFlow(JobBuilder jobBuilder) { + return jobBuilder + .start(eventImportStep()) + .next(eventEmptyResponseDecider()) + .on("EMPTY_RESPONSE").end() + .from(eventEmptyResponseDecider()).on("*").to(eventLastExecutionUpdateStep()) + .end() + .build(); + } + + @Bean + public JobExecutionDecider eventEmptyResponseDecider() { + return (jobExecution, stepExecution) -> { + if (stepExecution != null && stepExecution.getReadCount() == 0) { + log.info("[EventImportJob] Decider: EMPTY_RESPONSE - 응답 데이터 0건으로 LAST_EXECUTION 업데이트 스킵"); + return new FlowExecutionStatus("EMPTY_RESPONSE"); + } + log.info("[EventImportJob] Decider: NORMAL - LAST_EXECUTION 업데이트 진행"); + return new FlowExecutionStatus("NORMAL"); + }; + } + + @Bean + @StepScope + public EventDataReader eventDataReader( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, // SpEL로 ID 추출 + @Value("#{stepExecution.id}") Long stepExecutionId + ) { + EventDataReader reader = new EventDataReader(maritimeApiWebClient, jdbcTemplate, batchDateService, batchApiLogService, maritimeApiUrl); + reader.setExecutionIds(jobExecutionId, stepExecutionId); // ID 세팅 + return reader; + } + + @Override + protected ItemReader createReader() { + return eventDataReader; + } + + @Override + protected ItemProcessor createProcessor() { + return eventDataProcessor; + } + + @Bean + @StepScope + public EventDataProcessor eventDataProcessor( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId + ){ + return new EventDataProcessor(jobExecutionId); + } + + @Override + protected ItemWriter createWriter() { return eventDataWriter; } + + @Bean(name = "EventImportJob") + public Job eventImportJob() { + return job(); + } + + @Bean(name = "EventImportStep") + public Step eventImportStep() { + return step(); + } + + /** + * 2단계: 모든 스텝 성공 시 배치 실행 로그(날짜) 업데이트 + */ + @Bean + public Tasklet eventLastExecutionUpdateTasklet() { + return new LastExecutionUpdateTasklet(jdbcTemplate, targetSchema, getApiKey(), lastExecutionBufferHours); + } + @Bean(name = "EventLastExecutionUpdateStep") + public Step eventLastExecutionUpdateStep() { + return new StepBuilder("EventLastExecutionUpdateStep", jobRepository) + .tasklet(eventLastExecutionUpdateTasklet(), transactionManager) + .build(); + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/event/dto/CargoDto.java b/src/main/java/com/snp/batch/jobs/batch/event/dto/CargoDto.java new file mode 100644 index 0000000..0d57792 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/event/dto/CargoDto.java @@ -0,0 +1,50 @@ +package com.snp.batch.jobs.batch.event.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.snp.batch.jobs.batch.event.entity.CargoEntity; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CargoDto { + @JsonProperty("EventID") + private Integer eventID; + @JsonProperty("Sequence") + private String sequence; + @JsonProperty("IHSLRorIMOShipNo") + private String ihslrOrImoShipNo; + @JsonProperty("Type") + private String type; + @JsonProperty("Quantity") + private Integer quantity; + @JsonProperty("UnitShort") + private String unitShort; + @JsonProperty("Unit") + private String unit; + @JsonProperty("Text") + private String text; + @JsonProperty("CargoDamage") + private String cargoDamage; + @JsonProperty("Dangerous") + private String dangerous; + + public CargoEntity toEntity() { + return CargoEntity.builder() + .eventID(this.eventID) + .sequence(this.sequence) + .ihslrOrImoShipNo(this.ihslrOrImoShipNo) + .type(this.type) + .unit(this.unit) + .quantity(this.quantity) + .unitShort(this.unitShort) + .text(this.text) + .cargoDamage(this.cargoDamage) + .dangerous(this.dangerous) + .build(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/event/dto/EventDetailDto.java b/src/main/java/com/snp/batch/jobs/batch/event/dto/EventDetailDto.java new file mode 100644 index 0000000..93b90df --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/event/dto/EventDetailDto.java @@ -0,0 +1,109 @@ +package com.snp.batch.jobs.batch.event.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EventDetailDto { + @JsonProperty("IncidentID") + private Integer incidentID; + @JsonProperty("EventID") + private Long eventID; + @JsonProperty("EventTypeID") + private Integer eventTypeID; + @JsonProperty("EventType") + private String eventType; + @JsonProperty("Significance") + private String significance; + @JsonProperty("Headline") + private String headline; + @JsonProperty("IHSLRorIMOShipNo") + private String ihslrOrImoShipNo; + @JsonProperty("VesselName") + private String vesselName; + @JsonProperty("VesselType") + private String vesselType; + @JsonProperty("VesselTypeDecode") + private String vesselTypeDecode; + @JsonProperty("VesselFlag") + private String vesselFlagCode; + @JsonProperty("Flag") + private String vesselFlagDecode; + @JsonProperty("CargoLoadingStatusCode") + private String cargoLoadingStatusCode; + @JsonProperty("VesselDWT") + private Integer vesselDWT; + @JsonProperty("VesselGT") + private Integer vesselGT; + @JsonProperty("LDTAtTime") + private Integer ldtAtTime; + @JsonProperty("DateOfBuild") + private Integer dateOfBuild; + @JsonProperty("RegisteredOwnerCodeAtTime") + private String registeredOwnerCodeAtTime; + @JsonProperty("RegisteredOwnerAtTime") + private String registeredOwnerAtTime; + @JsonProperty("RegisteredOwnerCoDAtTime") + private String registeredOwnerCountryCodeAtTime; + @JsonProperty("RegisteredOwnerCountryAtTime") + private String registeredOwnerCountryAtTime; + @JsonProperty("Weather") + private String weather; + @JsonProperty("EventTypeDetail") + private String eventTypeDetail; + @JsonProperty("EventTypeDetailID") + private Integer eventTypeDetailID; + @JsonProperty("CasualtyAction") + private String casualtyAction; + @JsonProperty("LocationName") + private String locationName; + @JsonProperty("TownName") + private String townName; + @JsonProperty("MarsdenGridReference") + private Integer marsdenGridReference; + @JsonProperty("EnvironmentLocation") + private String environmentLocation; + @JsonProperty("CasualtyZone") + private String casualtyZone; + @JsonProperty("CasualtyZoneCode") + private String casualtyZoneCode; + @JsonProperty("CountryCode") + private String countryCode; + @JsonProperty("AttemptedBoarding") + private String attemptedBoarding; + @JsonProperty("Description") + private String description; + @JsonProperty("Pollutant") + private String pollutant; + @JsonProperty("PollutantUnit") + private String pollutantUnit; + @JsonProperty("PollutantQuantity") + private Double pollutantQuantity; + @JsonProperty("PublishedDate") + private String publishedDate; + @JsonProperty("Component2") + private String component2; + @JsonProperty("FiredUpon") + private String firedUpon; + private String eventStartDate; + private String eventEndDate; + + @JsonProperty("Cargoes") + private List cargoes; + + @JsonProperty("HumanCasualties") + private List humanCasualties; + + @JsonProperty("Relationships") + private List relationships; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/event/dto/EventDetailResponse.java b/src/main/java/com/snp/batch/jobs/batch/event/dto/EventDetailResponse.java new file mode 100644 index 0000000..36fbd8a --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/event/dto/EventDetailResponse.java @@ -0,0 +1,18 @@ +package com.snp.batch.jobs.batch.event.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EventDetailResponse { + @JsonProperty("MaritimeEvent") + private EventDetailDto eventDetailDto; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/event/dto/EventDto.java b/src/main/java/com/snp/batch/jobs/batch/event/dto/EventDto.java new file mode 100644 index 0000000..06a46ac --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/event/dto/EventDto.java @@ -0,0 +1,51 @@ +package com.snp.batch.jobs.batch.event.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EventDto { + @JsonProperty("IncidentID") + private Long incidentId; + + @JsonProperty("EventID") + private Long eventId; + + @JsonProperty("StartDate") + private String startDate; + + @JsonProperty("EventType") + private String eventType; + + @JsonProperty("Significance") + private String significance; + + @JsonProperty("Headline") + private String headline; + + @JsonProperty("EndDate") + private String endDate; + + @JsonProperty("IHSLRorIMOShipNo") + private String ihslRorImoShipNo; + + @JsonProperty("VesselName") + private String vesselName; + + @JsonProperty("VesselType") + private String vesselType; + + @JsonProperty("LocationName") + private String locationName; + + @JsonProperty("PublishedDate") + private String publishedDate; + +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/event/dto/EventPeriod.java b/src/main/java/com/snp/batch/jobs/batch/event/dto/EventPeriod.java new file mode 100644 index 0000000..79e6b75 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/event/dto/EventPeriod.java @@ -0,0 +1,12 @@ +package com.snp.batch.jobs.batch.event.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@AllArgsConstructor +public class EventPeriod { + private String eventStartDate; + private String eventEndDate;} diff --git a/src/main/java/com/snp/batch/jobs/batch/event/dto/EventResponse.java b/src/main/java/com/snp/batch/jobs/batch/event/dto/EventResponse.java new file mode 100644 index 0000000..d701253 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/event/dto/EventResponse.java @@ -0,0 +1,21 @@ +package com.snp.batch.jobs.batch.event.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EventResponse { + @JsonProperty("EventCount") + private Integer eventCount; + + @JsonProperty("MaritimeEvents") + private List MaritimeEvents; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/event/dto/HumanCasualtyDto.java b/src/main/java/com/snp/batch/jobs/batch/event/dto/HumanCasualtyDto.java new file mode 100644 index 0000000..543db48 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/event/dto/HumanCasualtyDto.java @@ -0,0 +1,35 @@ +package com.snp.batch.jobs.batch.event.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.snp.batch.jobs.batch.event.entity.HumanCasualtyEntity; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class HumanCasualtyDto { + @JsonProperty("EventID") + private Integer eventID; + @JsonProperty("Scope") + private String scope; + @JsonProperty("Type") + private String type; + @JsonProperty("Qualifier") + private String qualifier; + @JsonProperty("Count") + private Integer count; + + public HumanCasualtyEntity toEntity() { + return HumanCasualtyEntity.builder() + .eventID(this.eventID) + .scope(this.scope) + .type(this.type) + .qualifier(this.qualifier) + .count(this.count) + .build(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/event/dto/RelationshipDto.java b/src/main/java/com/snp/batch/jobs/batch/event/dto/RelationshipDto.java new file mode 100644 index 0000000..45b57fa --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/event/dto/RelationshipDto.java @@ -0,0 +1,41 @@ +package com.snp.batch.jobs.batch.event.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.snp.batch.jobs.batch.event.entity.RelationshipEntity; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RelationshipDto { + @JsonProperty("IncidentID") + private String incidentID; + @JsonProperty("EventID") + private Integer eventID; + @JsonProperty("RelationshipType") + private String relationshipType; + @JsonProperty("RelationshipTypeCode") + private String relationshipTypeCode; + @JsonProperty("EventID2") + private Integer eventID2; + @JsonProperty("EventType") + private String eventType; + @JsonProperty("EventTypeCode") + private String eventTypeCode; + + public RelationshipEntity toEntity() { + return RelationshipEntity.builder() + .incidentID(this.incidentID) + .eventID(this.eventID) + .relationshipType(this.relationshipType) + .relationshipTypeCode(this.relationshipTypeCode) + .eventID2(this.eventID2) + .eventType(this.eventType) + .eventTypeCode(this.eventTypeCode) + .build(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/event/entity/CargoEntity.java b/src/main/java/com/snp/batch/jobs/batch/event/entity/CargoEntity.java new file mode 100644 index 0000000..ad3799e --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/event/entity/CargoEntity.java @@ -0,0 +1,26 @@ +package com.snp.batch.jobs.batch.event.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class CargoEntity extends BaseEntity { + private Integer eventID; + private String sequence; + private String ihslrOrImoShipNo; + private String type; + private Integer quantity; + private String unitShort; + private String unit; + private String text; + private String cargoDamage; + private String dangerous; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/event/entity/EventDetailEntity.java b/src/main/java/com/snp/batch/jobs/batch/event/entity/EventDetailEntity.java new file mode 100644 index 0000000..f159f9c --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/event/entity/EventDetailEntity.java @@ -0,0 +1,67 @@ +package com.snp.batch.jobs.batch.event.entity; + +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer; +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class EventDetailEntity extends BaseEntity { + private Integer incidentID; + private Long eventID; + private Integer eventTypeID; + private String eventType; + private String significance; + private String headline; + private String ihslrOrImoShipNo; + private String vesselName; + private String vesselType; + private String vesselTypeDecode; + private String vesselFlagCode; + private String vesselFlagDecode; + private String cargoLoadingStatusCode; + private Integer vesselDWT; + private Integer vesselGT; + private Integer ldtAtTime; + private Integer dateOfBuild; + private String registeredOwnerCodeAtTime; + private String registeredOwnerAtTime; + private String registeredOwnerCountryCodeAtTime; + private String registeredOwnerCountryAtTime; + private String weather; + private String eventTypeDetail; + private Integer eventTypeDetailID; + private String casualtyAction; + private String locationName; + private String townName; + private Integer marsdenGridReference; + private String environmentLocation; + private String casualtyZone; + private String casualtyZoneCode; + private String countryCode; + private String attemptedBoarding; + private String description; + private String pollutant; + private String pollutantUnit; + private Double pollutantQuantity; + private String publishedDate; + private String component2; + private String firedUpon; + + private String eventStartDate; + private String eventEndDate; + + private List cargoes; + private List humanCasualties; + private List relationships; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/event/entity/EventEntity.java b/src/main/java/com/snp/batch/jobs/batch/event/entity/EventEntity.java new file mode 100644 index 0000000..813ae9d --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/event/entity/EventEntity.java @@ -0,0 +1,30 @@ +package com.snp.batch.jobs.batch.event.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class EventEntity extends BaseEntity { + + private Long incidentId; + private Long eventId; + private String startDate; + private String eventType; + private String significance; + private String headline; + private String endDate; + private String ihslRorImoShipNo; + private String vesselName; + private String vesselType; + private String locationName; + private String publishedDate; + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/event/entity/HumanCasualtyEntity.java b/src/main/java/com/snp/batch/jobs/batch/event/entity/HumanCasualtyEntity.java new file mode 100644 index 0000000..c511a08 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/event/entity/HumanCasualtyEntity.java @@ -0,0 +1,21 @@ +package com.snp.batch.jobs.batch.event.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class HumanCasualtyEntity extends BaseEntity { + private Integer eventID; + private String scope; + private String type; + private String qualifier; + private Integer count; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/event/entity/RelationshipEntity.java b/src/main/java/com/snp/batch/jobs/batch/event/entity/RelationshipEntity.java new file mode 100644 index 0000000..014fa26 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/event/entity/RelationshipEntity.java @@ -0,0 +1,23 @@ +package com.snp.batch.jobs.batch.event.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class RelationshipEntity extends BaseEntity { + private String incidentID; + private Integer eventID; + private String relationshipType; + private String relationshipTypeCode; + private Integer eventID2; + private String eventType; + private String eventTypeCode; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/event/processor/EventDataProcessor.java b/src/main/java/com/snp/batch/jobs/batch/event/processor/EventDataProcessor.java new file mode 100644 index 0000000..f8876c5 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/event/processor/EventDataProcessor.java @@ -0,0 +1,95 @@ +package com.snp.batch.jobs.batch.event.processor; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.processor.BaseProcessor; +import com.snp.batch.jobs.batch.event.dto.CargoDto; +import com.snp.batch.jobs.batch.event.dto.EventDetailDto; +import com.snp.batch.jobs.batch.event.dto.HumanCasualtyDto; +import com.snp.batch.jobs.batch.event.dto.RelationshipDto; +import com.snp.batch.jobs.batch.event.entity.CargoEntity; +import com.snp.batch.jobs.batch.event.entity.EventDetailEntity; +import com.snp.batch.jobs.batch.event.entity.HumanCasualtyEntity; +import com.snp.batch.jobs.batch.event.entity.RelationshipEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.stream.Collectors; + +@Slf4j +@Component +public class EventDataProcessor extends BaseProcessor { + private static Long jobExecutionId; + public EventDataProcessor( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId + ) { + this.jobExecutionId = jobExecutionId; + } + @Override + protected EventDetailEntity processItem(EventDetailDto dto) throws Exception { + log.debug("Event 데이터 처리 시작: Event ID = {}", dto.getEventID()); + + EventDetailEntity entity = EventDetailEntity.builder() + .eventID(dto.getEventID()) + .incidentID(dto.getIncidentID()) + .eventTypeID(dto.getEventTypeID()) + .eventType(dto.getEventType()) + .significance(dto.getSignificance()) + .headline(dto.getHeadline()) + .ihslrOrImoShipNo(dto.getIhslrOrImoShipNo()) + .vesselName(dto.getVesselName()) + .vesselType(dto.getVesselType()) + .vesselTypeDecode(dto.getVesselTypeDecode()) + .vesselFlagCode(dto.getVesselFlagCode()) + .vesselFlagDecode(dto.getVesselFlagDecode()) + .cargoLoadingStatusCode(dto.getCargoLoadingStatusCode()) + .vesselDWT(dto.getVesselDWT()) + .vesselGT(dto.getVesselGT()) + .ldtAtTime(dto.getLdtAtTime()) + .dateOfBuild(dto.getDateOfBuild()) + .registeredOwnerCodeAtTime(dto.getRegisteredOwnerCodeAtTime()) + .registeredOwnerAtTime(dto.getRegisteredOwnerAtTime()) + .registeredOwnerCountryCodeAtTime(dto.getRegisteredOwnerCountryCodeAtTime()) + .registeredOwnerCountryAtTime(dto.getRegisteredOwnerCountryAtTime()) + .weather(dto.getWeather()) + .eventTypeDetail(dto.getEventTypeDetail()) + .eventTypeDetailID(dto.getEventTypeDetailID()) + .casualtyAction(dto.getCasualtyAction()) + .locationName(dto.getLocationName()) + .townName(dto.getTownName()) + .marsdenGridReference(dto.getMarsdenGridReference()) + .environmentLocation(dto.getEnvironmentLocation()) + .casualtyZone(dto.getCasualtyZone()) + .casualtyZoneCode(dto.getCasualtyZoneCode()) + .countryCode(dto.getCountryCode()) + .attemptedBoarding(dto.getAttemptedBoarding()) + .description(dto.getDescription()) + .pollutant(dto.getPollutant()) + .pollutantUnit(dto.getPollutantUnit()) + .pollutantQuantity(dto.getPollutantQuantity()) + .publishedDate(dto.getPublishedDate()) + .component2(dto.getComponent2()) + .firedUpon(dto.getFiredUpon()) + .eventStartDate(dto.getEventStartDate()) + .eventEndDate(dto.getEventEndDate()) + .jobExecutionId(jobExecutionId) + .createdBy("SYSTEM") + .cargoes(dto.getCargoes() != null ? + dto.getCargoes().stream() + .map(d -> (CargoEntity) d.toEntity().setBatchInfo(jobExecutionId, "SYSTEM")) + .collect(Collectors.toList()) : null) + .humanCasualties(dto.getHumanCasualties() != null ? + dto.getHumanCasualties().stream() + .map(d -> (HumanCasualtyEntity) d.toEntity().setBatchInfo(jobExecutionId, "SYSTEM")) + .collect(Collectors.toList()) : null) + .relationships(dto.getRelationships() != null ? + dto.getRelationships().stream() + .map(d -> (RelationshipEntity) d.toEntity().setBatchInfo(jobExecutionId, "SYSTEM")) + .collect(Collectors.toList()) : null) + .build(); + + log.debug("Event 데이터 처리 완료: Event ID = {}", dto.getEventID()); + + return entity; + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/event/reader/EventDataReader.java b/src/main/java/com/snp/batch/jobs/batch/event/reader/EventDataReader.java new file mode 100644 index 0000000..ca96c57 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/event/reader/EventDataReader.java @@ -0,0 +1,239 @@ +package com.snp.batch.jobs.batch.event.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.batch.event.dto.*; +import com.snp.batch.service.BatchApiLogService; +import com.snp.batch.service.BatchDateService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpStatusCode; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +public class EventDataReader extends BaseApiReader { + + private final BatchDateService batchDateService; // ✨ BatchDateService 필드 추가 + private final BatchApiLogService batchApiLogService; + private final String maritimeApiUrl; + private Map eventPeriodMap; + private final JdbcTemplate jdbcTemplate; + + public EventDataReader(WebClient webClient, JdbcTemplate jdbcTemplate, BatchDateService batchDateService, BatchApiLogService batchApiLogService, String maritimeApiUrl) { + super(webClient); + this.jdbcTemplate = jdbcTemplate; + this.batchDateService = batchDateService; + this.batchApiLogService = batchApiLogService; + this.maritimeApiUrl = maritimeApiUrl; + enableChunkMode(); // ✨ Chunk 모드 활성화 + } + + @Override + protected String getReaderName() { + return "EventDataReader"; + } + @Override + protected String getApiPath() { + return "/MaritimeWCF/MaritimeAndTradeEventsService.svc/RESTFul/GetEventListByEventChangeDateRange"; + } + protected String getEventDetailApiPath() { + return "/MaritimeWCF/MaritimeAndTradeEventsService.svc/RESTFul/GetEventDataByEventID"; + } + protected String getApiKey() { + return "EVENT_IMPORT_API"; + } + + // 배치 처리 상태 + private List eventIds; + // DB 해시값을 저장할 맵 + private int currentBatchIndex = 0; + private final int batchSize = 1; + + @Override + protected void resetCustomState() { + this.currentBatchIndex = 0; + this.eventIds = null; + this.eventPeriodMap = new HashMap<>(); + } + + @Override + protected void beforeFetch() { + // 1. 기간내 기록된 Event List 조회 (API 요청) + EventResponse response = callEventApiWithBatch(); + // 2-1. Event List 에서 EventID List 추출 + // 2-2. Event List 에서 Map> 추출 + eventIds = extractEventIdList(response); + log.info("EvnetId List 추출 완료 : {} 개", eventIds.size()); + + eventPeriodMap = response.getMaritimeEvents().stream() + .filter(e -> e.getEventId() != null) + .collect(Collectors.toMap( + EventDto::getEventId, + e -> new EventPeriod( + e.getStartDate(), + e.getEndDate() + ) + )); + + updateApiCallStats(eventIds.size(), 0); + } + + @Override + protected List fetchNextBatch() throws Exception { + // 3. EventID List 로 Event Detail 조회 (API요청) : 청크단위 실행 + // 모든 배치 처리 완료 확인 + if (eventIds == null || currentBatchIndex >= eventIds.size()) { + return null; // Job 종료 + } + + // 현재 배치의 시작/끝 인덱스 계산 + int startIndex = currentBatchIndex; + int endIndex = Math.min(currentBatchIndex + batchSize, eventIds.size()); + + // 현재 배치의 IMO 번호 추출 (100개) + List currentBatch = eventIds.subList(startIndex, endIndex); + + int currentBatchNumber = (currentBatchIndex / batchSize) + 1; + int totalBatches = (int) Math.ceil((double) eventIds.size() / batchSize); + + try { + // API 호출 + EventDetailResponse response = callEventDetailApiWithBatch(currentBatch.get(0)); + // 다음 배치로 인덱스 이동 + currentBatchIndex = endIndex; + + List eventDetailList = new ArrayList<>(); + + // 응답 처리 + if (response != null && response.getEventDetailDto() != null) { + + // TODO: getEventDetailDto에 Map> 데이터 세팅 + EventDetailDto detailDto = response.getEventDetailDto(); + Long eventId = detailDto.getEventID(); + EventPeriod period = eventPeriodMap.get(eventId); + + if (period != null) { + detailDto.setEventStartDate(period.getEventStartDate()); + detailDto.setEventEndDate(period.getEventEndDate()); + } + + eventDetailList.add(response.getEventDetailDto()); + log.info("[{}] 배치 {}/{} 완료: {} 건 조회", + getReaderName(), currentBatchNumber, totalBatches, eventDetailList.size()); + + // API 호출 통계 업데이트 + updateApiCallStats(totalBatches, currentBatchNumber); + + // API 과부하 방지 (다음 배치 전 1.0초 대기) + if (currentBatchIndex < eventIds.size()) { + Thread.sleep(1000); + } + + return eventDetailList; + + } else { + log.warn("[{}] 배치 {}/{} 응답 없음", + getReaderName(), currentBatchNumber, totalBatches); + + // API 호출 통계 업데이트 (실패도 카운트) + updateApiCallStats(totalBatches, currentBatchNumber); + + return Collections.emptyList(); + } + + } catch (Exception e) { + log.error("[{}] 배치 {}/{} 처리 중 오류: {}", + getReaderName(), currentBatchNumber, totalBatches, e.getMessage(), e); + + // 오류 발생 시에도 다음 배치로 이동 (부분 실패 허용) + currentBatchIndex = endIndex; + + // 빈 리스트 반환 (Job 계속 진행) + return Collections.emptyList(); + } + + + } + + @Override + protected void afterFetch(List data) { + int totalBatches = (int) Math.ceil((double) eventIds.size() / batchSize); + try { + if (data == null) { + log.info("[{}] 전체 {} 개 배치 처리 완료", getReaderName(), totalBatches); + log.info("[{}] 총 {} 개의 Event ID에 대한 API 호출 종료", + getReaderName(), eventIds.size()); + } + } catch (Exception e) { + log.info("[{}] 전체 {} 개 배치 처리 실패", getReaderName(), totalBatches); + log.info("[{}] 총 {} 개의 Event ID에 대한 API 호출 종료", + getReaderName(), eventIds.size()); + } + } + + private List extractEventIdList(EventResponse response) { + if (response.getMaritimeEvents() == null) { + return Collections.emptyList(); + } + return response.getMaritimeEvents().stream() + // ShipDto 객체에서 imoNumber 필드 (String 타입)를 추출 + .map(EventDto::getEventId) + // IMO 번호가 null이 아닌 경우만 필터링 (선택 사항이지만 안전성을 위해) + .filter(eventId -> eventId != null) + // 추출된 String imoNumber들을 List으로 수집 + .collect(Collectors.toList()); + } + + private EventResponse callEventApiWithBatch() { + Map params = batchDateService.getDateRangeWithoutTimeParams(getApiKey()); + return executeSingleApiCall( + maritimeApiUrl, + getApiPath(), + params, + new ParameterizedTypeReference() {}, + batchApiLogService, + res -> res.getMaritimeEvents() != null ? (long) res.getMaritimeEvents().size() : 0L // 람다 적용 + ); + } + + private EventDetailResponse callEventDetailApiWithBatch(Long eventId) { + String url = getEventDetailApiPath(); + return webClient.get() + .uri(url, uriBuilder -> uriBuilder + // 맵에서 파라미터 값을 동적으로 가져와 세팅 + .queryParam("eventID", eventId) + .build()) + .retrieve() + .onStatus(HttpStatusCode::isError, clientResponse -> + clientResponse.bodyToMono(String.class) // 에러 바디를 문자열로 읽음 + .flatMap(errorBody -> { + // 2. 로그에 상태 코드와 에러 메세지 출력 + log.error("[{}] API 호출 오류 발생!", getReaderName()); + log.error("[{}] ERROR CODE: {}, REASON: {}", + getReaderName(), + clientResponse.statusCode(), + errorBody); + + // 3. 상위로 예외 던지기 (배치 중단을 원할 경우) + return Mono.error(new RuntimeException( + String.format("API 호출 실패 (%s): %s", clientResponse.statusCode(), errorBody) + )); + }) + ) + .bodyToMono(EventDetailResponse.class) + .block(); + } + + private LocalDateTime parseToLocalDate(String value) { + if (value == null || value.isBlank()) { + return null; + } + return LocalDateTime.parse(value); + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/event/repository/EventRepository.java b/src/main/java/com/snp/batch/jobs/batch/event/repository/EventRepository.java new file mode 100644 index 0000000..7cf97e0 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/event/repository/EventRepository.java @@ -0,0 +1,15 @@ +package com.snp.batch.jobs.batch.event.repository; + +import com.snp.batch.jobs.batch.event.entity.CargoEntity; +import com.snp.batch.jobs.batch.event.entity.EventDetailEntity; +import com.snp.batch.jobs.batch.event.entity.HumanCasualtyEntity; +import com.snp.batch.jobs.batch.event.entity.RelationshipEntity; + +import java.util.List; + +public interface EventRepository { + void saveEventAll(List items); + void saveCargoAll(List items); + void saveHumanCasualtyAll(List items); + void saveRelationshipAll(List items); +} diff --git a/src/main/java/com/snp/batch/jobs/batch/event/repository/EventRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/batch/event/repository/EventRepositoryImpl.java new file mode 100644 index 0000000..e595e48 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/event/repository/EventRepositoryImpl.java @@ -0,0 +1,254 @@ +package com.snp.batch.jobs.batch.event.repository; + +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.batch.event.entity.CargoEntity; +import com.snp.batch.jobs.batch.event.entity.EventDetailEntity; +import com.snp.batch.jobs.batch.event.entity.HumanCasualtyEntity; +import com.snp.batch.jobs.batch.event.entity.RelationshipEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.Types; +import java.util.List; + +@Slf4j +@Repository("EventRepository") +public class EventRepositoryImpl extends BaseJdbcRepository implements EventRepository { + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + + @Value("${app.batch.target-schema.tables.event-001}") + private String tableName; + + public EventRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + protected String getTargetSchema() { + return targetSchema; + } + + @Override + protected String getSimpleTableName() { + return tableName; + } + + @Override + protected RowMapper getRowMapper() { + return null; + } + + @Override + protected Long extractId(EventDetailEntity entity) { + return null; + } + + @Override + protected String getInsertSql() { + return null; + } + + @Override + protected String getUpdateSql() { + return null; + } + + @Override + protected void setInsertParameters(PreparedStatement ps, EventDetailEntity entity) throws Exception { + + } + + @Override + protected String getEntityName() { + return "EventDetailEntity"; + } + + @Override + public void saveEventAll(List items) { + String entityName = "EventDetailEntity"; + String sql = EventSql.getEventDetailUpdateSql(); + + jdbcTemplate.batchUpdate(sql, items, items.size(), + (ps, entity) -> { + try { + setUpdateParameters(ps, (EventDetailEntity) entity); + } catch (Exception e) { + log.error("배치 수정 파라미터 설정 실패", e); + throw new RuntimeException(e); + } + }); + + log.info("{} 배치 삽입 완료: {} 건", entityName, items.size()); + } + + @Override + public void saveCargoAll(List items) { + String entityName = "CargoEntity"; + String sql = EventSql.getEventCargoSql(); + + jdbcTemplate.batchUpdate(sql, items, items.size(), + (ps, entity) -> { + try { + setCargoInsertParameters(ps, (CargoEntity) entity); + } catch (Exception e) { + log.error("배치 삽입 파라미터 설정 실패 - " + entityName, e); + throw new RuntimeException(e); + } + }); + + log.info("{} 배치 삽입 완료: {} 건", entityName, items.size()); + } + + @Override + public void saveHumanCasualtyAll(List items) { + String entityName = "HumanCasualtyEntity"; + String sql = EventSql.getEventHumanCasualtySql(); + + jdbcTemplate.batchUpdate(sql, items, items.size(), + (ps, entity) -> { + try { + setHumanCasualtyInsertParameters(ps, (HumanCasualtyEntity) entity); + } catch (Exception e) { + log.error("배치 삽입 파라미터 설정 실패 - " + entityName, e); + throw new RuntimeException(e); + } + }); + + log.info("{} 배치 삽입 완료: {} 건", entityName, items.size()); + } + + @Override + public void saveRelationshipAll(List items) { + String entityName = "RelationshipEntity"; + String sql = EventSql.getEventRelationshipSql(); + + jdbcTemplate.batchUpdate(sql, items, items.size(), + (ps, entity) -> { + try { + setRelationshipInsertParameters(ps, (RelationshipEntity) entity); + } catch (Exception e) { + log.error("배치 삽입 파라미터 설정 실패 - " + entityName, e); + throw new RuntimeException(e); + } + }); + + log.info("{} 배치 삽입 완료: {} 건", entityName, items.size()); + } + + @Override + protected void setUpdateParameters(PreparedStatement ps, EventDetailEntity entity) throws Exception { + int idx = 1; + ps.setObject(idx++, entity.getEventID()); // event_id + ps.setObject(idx++, entity.getIncidentID()); // incident_id (누락됨) + ps.setObject(idx++, entity.getIhslrOrImoShipNo()); // ihslrorimoshipno (누락됨) + ps.setObject(idx++, entity.getPublishedDate()); // published_date (누락됨) + ps.setObject(idx++, entity.getEventStartDate()); // event_start_date + ps.setObject(idx++, entity.getEventEndDate()); // event_end_date + ps.setString(idx++, entity.getAttemptedBoarding()); // attempted_boarding + ps.setString(idx++, entity.getCargoLoadingStatusCode());// cargo_loading_status_code + ps.setString(idx++, entity.getCasualtyAction()); // casualty_action + ps.setString(idx++, entity.getCasualtyZone()); // casualty_zone + + // 11~20 + ps.setString(idx++, entity.getCasualtyZoneCode()); // casualty_zone_code + ps.setString(idx++, entity.getComponent2()); // component2 + ps.setString(idx++, entity.getCountryCode()); // country_code + ps.setObject(idx++, entity.getDateOfBuild()); // date_of_build (Integer) + ps.setString(idx++, entity.getDescription()); // description + ps.setString(idx++, entity.getEnvironmentLocation()); // environment_location + ps.setString(idx++, entity.getLocationName()); // location_name (누락됨) + ps.setObject(idx++, entity.getMarsdenGridReference()); // marsden_grid_reference (Integer) + ps.setString(idx++, entity.getTownName()); // town_name + ps.setString(idx++, entity.getEventType()); // event_type (누락됨) + + // 21~30 + ps.setString(idx++, entity.getEventTypeDetail()); // event_type_detail + ps.setObject(idx++, entity.getEventTypeDetailID()); // event_type_detail_id (Integer) + ps.setObject(idx++, entity.getEventTypeID()); // event_type_id (Integer) + ps.setString(idx++, entity.getFiredUpon()); // fired_upon + ps.setString(idx++, entity.getHeadline()); // headline (누락됨) + ps.setObject(idx++, entity.getLdtAtTime()); // ldt_at_time (Integer) + ps.setString(idx++, entity.getSignificance()); // significance (누락됨) + ps.setString(idx++, entity.getWeather()); // weather + ps.setString(idx++, entity.getPollutant()); // pollutant + ps.setObject(idx++, entity.getPollutantQuantity()); // pollutant_quantity (Double) + + // 31~42 + ps.setString(idx++, entity.getPollutantUnit()); // pollutant_unit + ps.setString(idx++, entity.getRegisteredOwnerCodeAtTime()); // registered_owner_code_at_time + ps.setString(idx++, entity.getRegisteredOwnerAtTime()); // registered_owner_at_time + ps.setString(idx++, entity.getRegisteredOwnerCountryCodeAtTime()); // registered_owner_country_code_at_time + ps.setString(idx++, entity.getRegisteredOwnerCountryAtTime()); // registered_owner_country_at_time + ps.setObject(idx++, entity.getVesselDWT()); // vessel_dwt (Integer) + ps.setString(idx++, entity.getVesselFlagCode()); // vessel_flag_code + ps.setString(idx++, entity.getVesselFlagDecode()); // vessel_flag_decode (누락됨) + ps.setObject(idx++, entity.getVesselGT()); // vessel_gt (Integer) + ps.setString(idx++, entity.getVesselName()); // vessel_name (누락됨) + ps.setString(idx++, entity.getVesselType()); // vessel_type (누락됨) + ps.setString(idx++, entity.getVesselTypeDecode()); // vessel_type_decode + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + private void setCargoInsertParameters(PreparedStatement ps, CargoEntity entity)throws Exception{ + int idx = 1; + // INSERT 필드 + ps.setObject(idx++, entity.getEventID()); + ps.setString(idx++, entity.getSequence()); + ps.setString(idx++, entity.getIhslrOrImoShipNo()); + ps.setString(idx++, entity.getType()); + ps.setObject(idx++, entity.getQuantity()); // quantity 필드 (Entity에 없을 경우 null 처리) + ps.setString(idx++, entity.getUnitShort()); // unit_short 필드 + ps.setString(idx++, entity.getUnit()); + ps.setString(idx++, entity.getCargoDamage()); + ps.setString(idx++, entity.getDangerous()); + ps.setString(idx++, entity.getText()); + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + private void setHumanCasualtyInsertParameters(PreparedStatement ps, HumanCasualtyEntity entity)throws Exception{ + int idx = 1; + ps.setObject(idx++, entity.getEventID()); + ps.setString(idx++, entity.getScope()); + ps.setString(idx++, entity.getType()); + ps.setString(idx++, entity.getQualifier()); + ps.setObject(idx++, entity.getCount()); + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + private void setRelationshipInsertParameters(PreparedStatement ps, RelationshipEntity entity)throws Exception{ + int idx = 1; + ps.setString(idx++, entity.getIncidentID()); + ps.setObject(idx++, entity.getEventID()); + ps.setString(idx++, entity.getRelationshipType()); + ps.setString(idx++, entity.getRelationshipTypeCode()); + ps.setObject(idx++, entity.getEventID2()); + ps.setString(idx++, entity.getEventType()); + ps.setString(idx++, entity.getEventTypeCode()); + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + + private static void setStringOrNull(PreparedStatement ps, int index, String value) throws Exception { + if (value == null) { + ps.setNull(index, Types.VARCHAR); + } else { + ps.setString(index, value); + } + } + /** + * Double 값을 PreparedStatement에 설정 (null 처리 포함) + */ + private static void setDoubleOrNull(PreparedStatement ps, int index, Double value) throws Exception { + if (value == null) { + ps.setNull(index, Types.DOUBLE); + } else { + ps.setDouble(index, value); + } + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/event/repository/EventSql.java b/src/main/java/com/snp/batch/jobs/batch/event/repository/EventSql.java new file mode 100644 index 0000000..b7bcec4 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/event/repository/EventSql.java @@ -0,0 +1,116 @@ +package com.snp.batch.jobs.batch.event.repository; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * Event 관련 SQL 생성 클래스 + * application.yml의 app.batch.target-schema.name 값을 사용 + */ +@Component +public class EventSql { + + private static String targetSchema; + private static String eventTable; + private static String eventCargoTable; + private static String eventRelationshipTable; + private static String eventHumanCasualtyTable; + + @Value("${app.batch.target-schema.name}") + public void setTargetSchema(String schema) { + EventSql.targetSchema = schema; + } + + @Value("${app.batch.target-schema.tables.event-001}") + public void setEventTable(String table) { + EventSql.eventTable = table; + } + + @Value("${app.batch.target-schema.tables.event-002}") + public void setEventCargoTable(String table) { + EventSql.eventCargoTable = table; + } + + @Value("${app.batch.target-schema.tables.event-004}") + public void setEventRelationshipTable(String table) { + EventSql.eventRelationshipTable = table; + } + + @Value("${app.batch.target-schema.tables.event-003}") + public void setEventHumanCasualtyTable(String table) { + EventSql.eventHumanCasualtyTable = table; + } + + public static String getTargetSchema() { + return targetSchema; + } + + public static String getEventDetailUpdateSql(){ + return """ + INSERT INTO %s.%s ( + event_id, acdnt_id, imo_no, pstg_ymd, event_start_day, event_end_day, + embrk_try_yn, cargo_capacity_status_cd, acdnt_actn, + acdnt_zone, acdnt_zone_cd, cfg_cmpnt_two, country_cd, + build_ymd, "desc", env_position, position_nm, + masd_grid_ref, cty_nm, event_type, event_type_dtl, + event_type_dtl_id, event_type_id, firedupon_yn, title, + ldt_timpt, signfct, wethr, pltn_matral, pltn_matral_cnt, + pltn_matral_unit, reg_shponr_cd_hr, reg_shponr_hr, + reg_shponr_country_cd_hr, reg_shponr_country_hr, + ship_dwt, ship_flg_cd, ship_flg_decd, ship_gt, + ship_nm, ship_type, ship_type_nm, + job_execution_id, creatr_id + ) + VALUES ( + ?, ?, ?, ?::timestamptz,?::timestamptz,?::timestamptz, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ? + ); + """.formatted(targetSchema, eventTable); + } + + public static String getEventCargoSql(){ + return """ + INSERT INTO %s.%s ( + event_id, event_seq, imo_no, "type", cnt, + unit_abbr, unit, cargo_damg, risk_yn, "text", + job_execution_id, creatr_id + ) + VALUES ( + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ? + ); + """.formatted(targetSchema, eventCargoTable); + } + + public static String getEventRelationshipSql(){ + return """ + INSERT INTO %s.%s ( + acdnt_id, event_id, rel_type, rel_type_cd, + event_id_two, event_type, event_type_cd, + job_execution_id, creatr_id + ) + VALUES ( + ?, ?, ?, ?, + ?, ?, ?, + ?, ? + ); + """.formatted(targetSchema, eventRelationshipTable); + } + + public static String getEventHumanCasualtySql(){ + return """ + INSERT INTO %s.%s ( + event_id, "scope", "type", qualfr, cnt, + job_execution_id, creatr_id + ) + VALUES ( + ?, ?, ?, ?, ?, + ?, ? + ); + """.formatted(targetSchema, eventHumanCasualtyTable); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/event/writer/EventDataWriter.java b/src/main/java/com/snp/batch/jobs/batch/event/writer/EventDataWriter.java new file mode 100644 index 0000000..dafa951 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/event/writer/EventDataWriter.java @@ -0,0 +1,48 @@ +package com.snp.batch.jobs.batch.event.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.batch.event.entity.EventDetailEntity; +import com.snp.batch.jobs.batch.event.repository.EventRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.util.CollectionUtils; + +import java.util.List; + +@Slf4j +@Component +public class EventDataWriter extends BaseWriter { + private final EventRepository eventRepository; + public EventDataWriter(EventRepository eventRepository) { + super("EventRepository"); + this.eventRepository = eventRepository; + } + + @Override + protected void writeItems(List items) throws Exception { + + if (CollectionUtils.isEmpty(items)) { + return; + } + + // 1. EventDetail 메인 데이터 저장 + eventRepository.saveEventAll(items); + + for (EventDetailEntity event : items) { + // 2. CargoEntityList Save + if (!CollectionUtils.isEmpty(event.getCargoes())) { + eventRepository.saveCargoAll(event.getCargoes()); + } + // 3. HumanCasualtyEntityList Save + if (!CollectionUtils.isEmpty(event.getHumanCasualties())) { + eventRepository.saveHumanCasualtyAll(event.getHumanCasualties()); + } + // 4. RelationshipEntityList Save + if (!CollectionUtils.isEmpty(event.getRelationships())) { + eventRepository.saveRelationshipAll(event.getRelationships()); + } + } + + log.info("Batch Write 완료: {} 건의 Event 처리됨", items.size()); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/facility/config/PortImportJobConfig.java b/src/main/java/com/snp/batch/jobs/batch/facility/config/PortImportJobConfig.java new file mode 100644 index 0000000..b5aa0dc --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/facility/config/PortImportJobConfig.java @@ -0,0 +1,109 @@ +package com.snp.batch.jobs.batch.facility.config; + +import com.snp.batch.common.batch.config.BaseJobConfig; +import com.snp.batch.jobs.batch.facility.dto.PortDto; +import com.snp.batch.jobs.batch.facility.entity.PortEntity; +import com.snp.batch.jobs.batch.facility.processor.PortDataProcessor; +import com.snp.batch.jobs.batch.facility.reader.PortDataReader; +import com.snp.batch.jobs.batch.facility.writer.PortDataWriter; +import com.snp.batch.service.BatchApiLogService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.reactive.function.client.WebClient; + +@Slf4j +@Configuration +public class PortImportJobConfig extends BaseJobConfig { + private final JdbcTemplate jdbcTemplate; + private final WebClient maritimeServiceApiWebClient; + + private final PortDataWriter portDataWriter; + private final PortDataReader portDataReader; + private final BatchApiLogService batchApiLogService; + + @Value("${app.batch.webservice-api.url}") + private String maritimeServiceApiUrl; + + @Override + protected int getChunkSize() { + return 5000; // API에서 5000개씩 가져오므로 chunk도 5000으로 설정 + } + public PortImportJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + PortDataReader portDataReader, + PortDataWriter portDataWriter, + JdbcTemplate jdbcTemplate, + @Qualifier("maritimeServiceApiWebClient")WebClient maritimeServiceApiWebClient, + BatchApiLogService batchApiLogService) { + super(jobRepository, transactionManager); + this.jdbcTemplate = jdbcTemplate; + this.maritimeServiceApiWebClient = maritimeServiceApiWebClient; + this.portDataWriter = portDataWriter; + this.portDataReader = portDataReader; + this.batchApiLogService = batchApiLogService; + } + + @Override + protected String getJobName() { + return "PortImportJob"; + } + + @Override + protected String getStepName() { + return "PortImportStep"; + } + + @Override + protected ItemReader createReader() { + return portDataReader; + } + + @Bean + @StepScope + public PortDataReader portDataReader( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, // SpEL로 ID 추출 + @Value("#{stepExecution.id}") Long stepExecutionId + ) { + PortDataReader reader = new PortDataReader(maritimeServiceApiWebClient, jdbcTemplate, batchApiLogService, maritimeServiceApiUrl); + reader.setExecutionIds(jobExecutionId, stepExecutionId); // ID 세팅 + return reader; + } + + @Override + protected ItemProcessor createProcessor() { + // 2. 메서드 호출 방식으로 변경 + return portDataProcessor(null); + } + @Bean + @StepScope + public PortDataProcessor portDataProcessor( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId) { + return new PortDataProcessor(jobExecutionId); + } + @Override + protected ItemWriter createWriter() { return portDataWriter; } + + @Bean(name = "PortImportJob") + public Job portImportJob() { + return job(); + } + + @Bean(name = "PortImportStep") + public Step portImportStep() { + return step(); + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/facility/dto/PortDto.java b/src/main/java/com/snp/batch/jobs/batch/facility/dto/PortDto.java new file mode 100644 index 0000000..8789e29 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/facility/dto/PortDto.java @@ -0,0 +1,172 @@ +package com.snp.batch.jobs.batch.facility.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PortDto { + @JsonProperty("port_ID") + private Long portId; + + @JsonProperty("old_ID") + private String oldId; + + @JsonProperty("status") + private String status; + + @JsonProperty("port_Name") + private String portName; + + @JsonProperty("unlocode") + private String unlocode; + + @JsonProperty("countryCode") + private String countryCode; + + @JsonProperty("country_Name") + private String countryName; + + @JsonProperty("dec_Lat") + private Double decLat; + + @JsonProperty("dec_Long") + private Double decLong; + + private PositionDto position; + + @JsonProperty("time_Zone") + private String timeZone; + + @JsonProperty("dayLight_Saving_Time") + private Boolean dayLightSavingTime; + + @JsonProperty("maximum_Draft") + private Double maximumDraft; + + @JsonProperty("breakbulk_Facilities") + private Boolean breakbulkFacilities; + + @JsonProperty("container_Facilities") + private Boolean containerFacilities; + + @JsonProperty("dry_Bulk_Facilities") + private Boolean dryBulkFacilities; + + @JsonProperty("liquid_Facilities") + private Boolean liquidFacilities; + + @JsonProperty("roRo_Facilities") + private Boolean roRoFacilities; + + @JsonProperty("passenger_Facilities") + private Boolean passengerFacilities; + + @JsonProperty("dry_Dock_Facilities") + private Boolean dryDockFacilities; + + @JsonProperty("lpG_Facilities") + private Integer lpgFacilities; + + @JsonProperty("lnG_Facilities") + private Integer lngFacilities; + + @JsonProperty("ispS_Compliant") + private Boolean ispsCompliant; + + @JsonProperty("csI_Compliant") + private Boolean csiCompliant; + + @JsonProperty("last_Update") + private String lastUpdate; + + @JsonProperty("entry_Date") + private String entryDate; + + @JsonProperty("region_Name") + private String regionName; + + @JsonProperty("continent_Name") + private String continentName; + + @JsonProperty("master_POID") + private String masterPoid; + + @JsonProperty("wS_Port") + private Integer wsPort; + + @JsonProperty("max_LOA") + private Double maxLoa; + + @JsonProperty("max_Beam") + private Double maxBeam; + + @JsonProperty("max_DWT") + private Double maxDwt; + + @JsonProperty("max_Offshore_Draught") + private Double maxOffshoreDraught; + + @JsonProperty("max_Offshore_LOA") + private Double maxOffshoreLoa; + + @JsonProperty("max_Offshore_BCM") + private Double maxOffshoreBcm; + + @JsonProperty("max_Offshore_DWT") + private Double maxOffshoreDwt; + + @JsonProperty("lnG_Bunker") + private Boolean lngBunker; + + @JsonProperty("dO_Bunker") + private Boolean doBunker; + + @JsonProperty("fO_Bunker") + private Boolean foBunker; + + @JsonProperty("free_Trade_Zone") + private Boolean freeTradeZone; + + @JsonProperty("ecO_Port") + private Boolean ecoPort; + + @JsonProperty("emission_Control_Area") + private Boolean emissionControlArea; + @Data + @SuperBuilder + @NoArgsConstructor + @AllArgsConstructor + public static class PositionDto { + + @JsonProperty("isNull") + private Boolean isNull; + + @JsonProperty("stSrid") + private Integer stSrid; + + @JsonProperty("lat") + private Double lat; + + @JsonProperty("long") // JSON 키가 Java 예약어 'long'이므로 @JsonProperty를 사용 + private Double longitude; + @JsonProperty("z") + private Object z; + @JsonProperty("m") + private Object m; + + @JsonProperty("hasZ") + private Boolean hasZ; + + @JsonProperty("hasM") + private Boolean hasM; + + } + +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/facility/dto/PortResponse.java b/src/main/java/com/snp/batch/jobs/batch/facility/dto/PortResponse.java new file mode 100644 index 0000000..4a85a4c --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/facility/dto/PortResponse.java @@ -0,0 +1,16 @@ +package com.snp.batch.jobs.batch.facility.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PortResponse { + private List portDtoList; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/facility/entity/PortEntity.java b/src/main/java/com/snp/batch/jobs/batch/facility/entity/PortEntity.java new file mode 100644 index 0000000..49e08be --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/facility/entity/PortEntity.java @@ -0,0 +1,78 @@ +package com.snp.batch.jobs.batch.facility.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import jakarta.persistence.Embedded; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class PortEntity extends BaseEntity { + + private Long portID; + private String oldID; + private String status; + private String portName; + private String unlocode; + private String countryCode; + private String countryName; + private Double decLat; + private Double decLong; + @Embedded + private PositionEntity position; + private String timeZone; + private Boolean dayLightSavingTime; + private Double maximumDraft; + private Boolean breakbulkFacilities; + private Boolean containerFacilities; + private Boolean dryBulkFacilities; + private Boolean liquidFacilities; + private Boolean roRoFacilities; + private Boolean passengerFacilities; + private Boolean dryDockFacilities; + private Integer lpGFacilities; + private Integer lnGFacilities; + private Boolean ispsCompliant; + private Boolean csiCompliant; + private String lastUpdate; + private String entryDate; + private String regionName; + private String continentName; + private String masterPoid; + private Integer wsPort; + private Double maxLoa; + private Double maxBeam; + private Double maxDwt; + private Double maxOffshoreDraught; + private Double maxOffshoreLoa; + private Double maxOffshoreBcm; + private Double maxOffshoreDwt; + private Boolean lngBunker; + private Boolean doBunker; + private Boolean foBunker; + private Boolean freeTradeZone; + private Boolean ecoPort; + private Boolean emissionControlArea; + + @Data + @SuperBuilder + @NoArgsConstructor + @AllArgsConstructor + public static class PositionEntity { + private Boolean isNull; + private Integer stSrid; + private Double lat; + private Double longitude; + private Object z; + private Object m; + private Boolean hasZ; + private Boolean hasM; + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/facility/processor/PortDataProcessor.java b/src/main/java/com/snp/batch/jobs/batch/facility/processor/PortDataProcessor.java new file mode 100644 index 0000000..9f5fcac --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/facility/processor/PortDataProcessor.java @@ -0,0 +1,88 @@ +package com.snp.batch.jobs.batch.facility.processor; + +import com.snp.batch.common.batch.processor.BaseProcessor; +import com.snp.batch.jobs.batch.facility.dto.PortDto; +import com.snp.batch.jobs.batch.facility.entity.PortEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class PortDataProcessor extends BaseProcessor { + private static Long jobExecutionId; + public PortDataProcessor(@Value("#{stepExecution.jobExecution.id}") Long jobExecutionId) { + this.jobExecutionId = jobExecutionId; + } + + @Override + protected PortEntity processItem(PortDto dto) throws Exception { + log.debug("Port 데이터 처리 시작: Port ID = {}", dto.getPortId()); + + PortEntity.PositionEntity positionEntity = null; + if (dto.getPosition() != null) { + positionEntity = PortEntity.PositionEntity.builder() + .isNull(dto.getPosition().getIsNull()) + .stSrid(dto.getPosition().getStSrid()) + .lat(dto.getPosition().getLat()) + .longitude(dto.getPosition().getLongitude()) + .z(dto.getPosition().getZ()) + .m(dto.getPosition().getM()) + .hasZ(dto.getPosition().getHasZ()) + .hasM(dto.getPosition().getHasM()) + .build(); + } + + PortEntity entity = PortEntity.builder() + .portID(dto.getPortId()) + .oldID(dto.getOldId()) + .status(dto.getStatus()) + .portName(dto.getPortName()) + .unlocode(dto.getUnlocode()) + .countryCode(dto.getCountryCode()) + .countryName(dto.getCountryName()) + .decLat(dto.getDecLat()) + .decLong(dto.getDecLong()) + .position(positionEntity) // 변환된 PositionEntity 객체 + .timeZone(dto.getTimeZone()) + .dayLightSavingTime(dto.getDayLightSavingTime()) + .maximumDraft(dto.getMaximumDraft()) + .breakbulkFacilities(dto.getBreakbulkFacilities()) + .containerFacilities(dto.getContainerFacilities()) + .dryBulkFacilities(dto.getDryBulkFacilities()) + .liquidFacilities(dto.getLiquidFacilities()) + .roRoFacilities(dto.getRoRoFacilities()) + .passengerFacilities(dto.getPassengerFacilities()) + .dryDockFacilities(dto.getDryDockFacilities()) + .lpGFacilities(dto.getLpgFacilities()) + .lnGFacilities(dto.getLngFacilities()) + .ispsCompliant(dto.getIspsCompliant()) + .csiCompliant(dto.getCsiCompliant()) + .lastUpdate(dto.getLastUpdate()) + .entryDate(dto.getEntryDate()) + .regionName(dto.getRegionName()) + .continentName(dto.getContinentName()) + .masterPoid(dto.getMasterPoid()) + .wsPort(dto.getWsPort()) + .maxLoa(dto.getMaxLoa()) + .maxBeam(dto.getMaxBeam()) + .maxDwt(dto.getMaxDwt()) + .maxOffshoreDraught(dto.getMaxOffshoreDraught()) + .maxOffshoreLoa(dto.getMaxOffshoreLoa()) + .maxOffshoreBcm(dto.getMaxOffshoreBcm()) + .maxOffshoreDwt(dto.getMaxOffshoreDwt()) + .lngBunker(dto.getLngBunker()) + .doBunker(dto.getDoBunker()) + .foBunker(dto.getFoBunker()) + .freeTradeZone(dto.getFreeTradeZone()) + .ecoPort(dto.getEcoPort()) + .emissionControlArea(dto.getEmissionControlArea()) + .jobExecutionId(jobExecutionId) + .createdBy("SYSTEM") + .build(); + + log.debug("Port 데이터 처리 완료: Port ID = {}", dto.getPortId()); + + return entity; + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/facility/reader/PortDataReader.java b/src/main/java/com/snp/batch/jobs/batch/facility/reader/PortDataReader.java new file mode 100644 index 0000000..aaff25a --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/facility/reader/PortDataReader.java @@ -0,0 +1,71 @@ +package com.snp.batch.jobs.batch.facility.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.batch.facility.dto.PortDto; +import com.snp.batch.service.BatchApiLogService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@Slf4j +public class PortDataReader extends BaseApiReader { + private final JdbcTemplate jdbcTemplate; + private List allImoNumbers; + private int currentBatchIndex = 0; + private final int batchSize = 5000; + private final BatchApiLogService batchApiLogService; + String maritimeServiceApiUrl; + public PortDataReader(WebClient webClient, JdbcTemplate jdbcTemplate, BatchApiLogService batchApiLogService, String maritimeServiceApiUrl) { + super(webClient); + this.jdbcTemplate = jdbcTemplate; + this.batchApiLogService = batchApiLogService; + this.maritimeServiceApiUrl = maritimeServiceApiUrl; + } + + @Override + protected String getReaderName() { + return "PortDataReader"; + } + + @Override + protected String getApiPath() { + return "/Facilities/Ports"; + } + + @Override + protected List fetchDataFromApi() { + try { + log.info("Facility Port API 호출 시작"); + + List response = callFacilityPortApiWithBatch(); + + if (response != null) { + log.info("API 응답 성공: 총 {} 개의 Port 데이터 수신", response.size()); + return response; + } else { + log.warn("API 응답이 null이거나 Port 데이터가 없습니다"); + return new ArrayList<>(); + } + + } catch (Exception e) { + log.error("Facility Port API 호출 실패", e); + log.error("에러 메시지: {}", e.getMessage()); + return new ArrayList<>(); + } + } + + private List callFacilityPortApiWithBatch() { + return executeListApiCall( + maritimeServiceApiUrl, + getApiPath(), + new ParameterizedTypeReference>() {}, + batchApiLogService + ); + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/facility/repository/FacilityRepository.java b/src/main/java/com/snp/batch/jobs/batch/facility/repository/FacilityRepository.java new file mode 100644 index 0000000..7e7223f --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/facility/repository/FacilityRepository.java @@ -0,0 +1,9 @@ +package com.snp.batch.jobs.batch.facility.repository; + +import com.snp.batch.jobs.batch.facility.entity.PortEntity; + +import java.util.List; + +public interface FacilityRepository { + void savePortAll(List items); +} diff --git a/src/main/java/com/snp/batch/jobs/batch/facility/repository/FacilityRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/batch/facility/repository/FacilityRepositoryImpl.java new file mode 100644 index 0000000..105803b --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/facility/repository/FacilityRepositoryImpl.java @@ -0,0 +1,202 @@ +package com.snp.batch.jobs.batch.facility.repository; + +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.batch.facility.entity.PortEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.Types; +import java.util.List; + +@Slf4j +@Repository("FacilityRepository") +public class FacilityRepositoryImpl extends BaseJdbcRepository implements FacilityRepository { + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + + @Value("${app.batch.target-schema.tables.facility-001}") + private String tableName; + + public FacilityRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + protected String getTargetSchema() { + return targetSchema; + } + + @Override + protected String getSimpleTableName() { + return tableName; + } + + @Override + protected RowMapper getRowMapper() { + return null; + } + + @Override + protected Long extractId(PortEntity entity) { + return null; + } + + @Override + protected String getInsertSql() { + return null; + } + + @Override + protected String getUpdateSql() { + return """ + INSERT INTO %s( + port_id, bfr_id, status, port_nm, un_port_cd, country_cd, country_nm, areanm, cntntnm, mst_port_id, + lat_decml, lon_decml, position_lat, position_lon, position_z_val, position_mval_val, z_val_has_yn, mval_val_has_yn, position_nul_yn, position_sts_id, hr_zone, daylgt_save_hr, + max_draft, max_whlnth, max_beam, max_dwt, max_sea_draft, max_sea_whlnth, max_sea_bcm, max_sea_dwt, + bale_cargo_facility, cntnr_facility, case_cargo_facility, liquid_cargo_facility, roro_facility, paxfclty, drydkfclty, + lpg_facility, lng_facility, lng_bnkr, do_bnkr, fo_bnkr, isps_compliance_yn, csi_compliance_yn, free_trd_zone, ecfrd_port, emsn_ctrl_area, ws_port, + last_mdfcn_dt, reg_ymd, + job_execution_id, creatr_id + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?::timestamptz, ?::timestamptz, + ?, ? + ); + """.formatted(getTableName()); + } + + @Override + protected void setInsertParameters(PreparedStatement ps, PortEntity entity) throws Exception { + + } + + @Override + protected void setUpdateParameters(PreparedStatement ps, PortEntity entity) throws Exception { + int idx = 1; + ps.setLong(idx++, entity.getPortID()); + ps.setString(idx++, entity.getOldID()); + ps.setString(idx++, entity.getStatus()); + ps.setString(idx++, entity.getPortName()); + ps.setString(idx++, entity.getUnlocode()); + ps.setString(idx++, entity.getCountryCode()); + ps.setString(idx++, entity.getCountryName()); + ps.setString(idx++, entity.getRegionName()); + ps.setString(idx++, entity.getContinentName()); + ps.setString(idx++, entity.getMasterPoid()); + setDoubleOrNull(ps, idx++, entity.getDecLat()); + setDoubleOrNull(ps, idx++, entity.getDecLong()); + PortEntity.PositionEntity pos = entity.getPosition(); + if (pos != null) { + setDoubleOrNull(ps, idx++, pos.getLat()); + setDoubleOrNull(ps, idx++, pos.getLongitude()); + ps.setObject(idx++, pos.getZ(), Types.OTHER); + ps.setObject(idx++, pos.getM(), Types.OTHER); + setBooleanOrNull(ps, idx++, pos.getHasZ()); + setBooleanOrNull(ps, idx++, pos.getHasM()); + setBooleanOrNull(ps, idx++, pos.getIsNull()); + setIntegerOrNull(ps, idx++, pos.getStSrid()); + } else { + for (int i = 0; i < 8; i++) { + ps.setNull(idx++, Types.NULL); + } + } + ps.setString(idx++, entity.getTimeZone()); + setBooleanOrNull(ps, idx++, entity.getDayLightSavingTime()); + setDoubleOrNull(ps, idx++, entity.getMaximumDraft()); // 원본: setIntegerOrNull(getMaximumDraft())였으나 FLOAT에 맞게 수정 + setDoubleOrNull(ps, idx++, entity.getMaxLoa()); // 원본: setIntegerOrNull(getMaxLoa())였으나 FLOAT에 맞게 수정 + setDoubleOrNull(ps, idx++, entity.getMaxBeam()); // 원본: setIntegerOrNull(getMaxBeam())였으나 FLOAT에 맞게 수정 + setDoubleOrNull(ps, idx++, entity.getMaxDwt()); // 원본: setIntegerOrNull(getMaxDwt())였으나 FLOAT에 맞게 수정 + setDoubleOrNull(ps, idx++, entity.getMaxOffshoreDraught()); // 원본: setIntegerOrNull(getMaxOffshoreDraught())였으나 FLOAT에 맞게 수정 + setDoubleOrNull(ps, idx++, entity.getMaxOffshoreLoa()); // 원본: setIntegerOrNull(getMaxOffshoreLoa())였으나 FLOAT에 맞게 수정 + setDoubleOrNull(ps, idx++, entity.getMaxOffshoreBcm()); // 원본: setIntegerOrNull(getMaxOffshoreBcm())였으나 FLOAT에 맞게 수정 + setDoubleOrNull(ps, idx++, entity.getMaxOffshoreDwt()); // 원본: setIntegerOrNull(getMaxOffshoreDwt())였으나 FLOAT에 맞게 수정 + setBooleanOrNull(ps, idx++, entity.getBreakbulkFacilities()); + setBooleanOrNull(ps, idx++, entity.getContainerFacilities()); + setBooleanOrNull(ps, idx++, entity.getDryBulkFacilities()); + setBooleanOrNull(ps, idx++, entity.getLiquidFacilities()); + setBooleanOrNull(ps, idx++, entity.getRoRoFacilities()); + setBooleanOrNull(ps, idx++, entity.getPassengerFacilities()); + setBooleanOrNull(ps, idx++, entity.getDryDockFacilities()); + setIntegerOrNull(ps, idx++, entity.getLpGFacilities()); // INT8(BIGINT)에 맞게 setLongOrNull 사용 가정 + setIntegerOrNull(ps, idx++, entity.getLnGFacilities()); // INT8(BIGINT)에 맞게 setLongOrNull 사용 가정 + setBooleanOrNull(ps, idx++, entity.getLngBunker()); // 원본 위치: 마지막 부분 + setBooleanOrNull(ps, idx++, entity.getDoBunker()); // 원본 위치: 마지막 부분 + setBooleanOrNull(ps, idx++, entity.getFoBunker()); // 원본 위치: 마지막 부분 + setBooleanOrNull(ps, idx++, entity.getIspsCompliant()); + setBooleanOrNull(ps, idx++, entity.getCsiCompliant()); + setBooleanOrNull(ps, idx++, entity.getFreeTradeZone()); // 원본 위치: 마지막 부분 + setBooleanOrNull(ps, idx++, entity.getEcoPort()); // 원본 위치: 마지막 부분 + setBooleanOrNull(ps, idx++, entity.getEmissionControlArea()); // 원본 위치: 마지막 부분 + setIntegerOrNull(ps, idx++, entity.getWsPort()); // 원본 위치: 마지막 부분 (INT8에 맞게 setLongOrNull 사용 가정) + ps.setString(idx++, entity.getLastUpdate()); // String 대신 Timestamp 타입이 JDBC 표준에 적합합니다. + ps.setString(idx++, entity.getEntryDate()); // String 대신 Timestamp 타입이 JDBC 표준에 적합합니다. + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + + @Override + protected String getEntityName() { + return "RiskEntity"; + } + + @Override + public void savePortAll(List items) { + if (items == null || items.isEmpty()) { + return; + } + jdbcTemplate.batchUpdate(getUpdateSql(), items, items.size(), + (ps, entity) -> { + try { + setUpdateParameters(ps, entity); + } catch (Exception e) { + log.error("배치 수정 파라미터 설정 실패", e); + throw new RuntimeException(e); + } + }); + + log.info("{} 전체 저장 완료: 수정={} 건", getEntityName(), items.size()); + } + + /** + * Integer 값을 PreparedStatement에 설정 (null 처리 포함) + */ + private void setIntegerOrNull(PreparedStatement ps, int index, Integer value) throws Exception { + if (value == null) { + ps.setNull(index, Types.INTEGER); + } else { + ps.setInt(index, value); + } + } + + /** + * Double 값을 PreparedStatement에 설정 (null 처리 포함) + */ + private void setDoubleOrNull(PreparedStatement ps, int index, Double value) throws Exception { + if (value == null) { + ps.setNull(index, Types.DOUBLE); + } else { + ps.setDouble(index, value); + } + } + + /** + * Boolean 값을 PreparedStatement에 설정 (null 처리 포함) + */ + private void setBooleanOrNull(PreparedStatement ps, int index, Boolean value) throws Exception { + if (value == null) { + // DB 타입에 따라 BOOLEAN 또는 TINYINT(1) 사용 + ps.setNull(index, Types.BOOLEAN); + } else { + ps.setBoolean(index, value); + } + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/facility/writer/PortDataWriter.java b/src/main/java/com/snp/batch/jobs/batch/facility/writer/PortDataWriter.java new file mode 100644 index 0000000..7e2984d --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/facility/writer/PortDataWriter.java @@ -0,0 +1,26 @@ +package com.snp.batch.jobs.batch.facility.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.batch.facility.entity.PortEntity; +import com.snp.batch.jobs.batch.facility.repository.FacilityRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Slf4j +@Component +public class PortDataWriter extends BaseWriter { + + private final FacilityRepository facilityRepository; + public PortDataWriter(FacilityRepository facilityRepository) { + super("FacilityRepository"); + this.facilityRepository = facilityRepository; + } + + @Override + protected void writeItems(List items) throws Exception { + facilityRepository.savePortAll(items); + log.info("Port 저장 완료: 수정={} 건", items.size()); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/imometa/config/ImoMetaImportJobConfig.java b/src/main/java/com/snp/batch/jobs/batch/imometa/config/ImoMetaImportJobConfig.java new file mode 100644 index 0000000..edf7387 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/imometa/config/ImoMetaImportJobConfig.java @@ -0,0 +1,216 @@ +package com.snp.batch.jobs.batch.imometa.config; + +import com.snp.batch.common.batch.config.BaseMultiStepJobConfig; +import com.snp.batch.global.model.BatchApiLog; +import com.snp.batch.jobs.batch.imometa.dto.ImoMetaDto; +import com.snp.batch.jobs.batch.imometa.dto.ImoMetaResponse; +import com.snp.batch.jobs.batch.imometa.entity.ImoMetaEntity; +import com.snp.batch.jobs.batch.imometa.reader.ImoMetaDataReader; +import com.snp.batch.jobs.batch.imometa.repository.ImoMetaRepository; +import com.snp.batch.jobs.batch.imometa.writer.ImoMetaDataWriter; +import com.snp.batch.service.BatchApiLogService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import org.springframework.web.util.UriComponentsBuilder; + +import java.time.LocalDateTime; +import java.util.List; + +@Slf4j +@Configuration +public class ImoMetaImportJobConfig extends BaseMultiStepJobConfig { + + private static final String DELETE_IMO_PATH = "/MaritimeWCF/APSShipService.svc/RESTFul/GetAllIMONumbersToDelete"; + private static final String CREATED_BY_SYSTEM = "SYSTEM"; + + private final ImoMetaRepository imoMetaRepository; + private final WebClient maritimeApiWebClient; + private final BatchApiLogService batchApiLogService; + private final ImoMetaDataWriter imoMetaDataWriter; + + @Value("${app.batch.ship-api.url}") + private String maritimeApiUrl; + + public ImoMetaImportJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + ImoMetaRepository imoMetaRepository, + @Qualifier("maritimeApiWebClient") WebClient maritimeApiWebClient, + BatchApiLogService batchApiLogService, + ImoMetaDataWriter imoMetaDataWriter) { + super(jobRepository, transactionManager); + this.imoMetaRepository = imoMetaRepository; + this.maritimeApiWebClient = maritimeApiWebClient; + this.batchApiLogService = batchApiLogService; + this.imoMetaDataWriter = imoMetaDataWriter; + } + + @Override + protected String getJobName() { + return "ImoMetaImportJob"; + } + + @Override + protected String getStepName() { + return "ImoMetaImportStep"; + } + + @Override + protected int getChunkSize() { + return 5000; + } + + @Override + protected Job createJobFlow(JobBuilder jobBuilder) { + return jobBuilder + .start(imoMetaImportStep()) + .next(imoMetaDeleteFlagStep()) + .build(); + } + + // ======================================== + // Step 1: IMO 전체 수집 (Chunk-oriented) + // ======================================== + + @Bean(name = "ImoMetaImportStep") + public Step imoMetaImportStep() { + return new StepBuilder("ImoMetaImportStep", jobRepository) + .chunk(getChunkSize(), transactionManager) + .reader(imoMetaDataReader(null, null)) + .processor(imoMetaProcessor(null)) + .writer(imoMetaDataWriter) + .build(); + } + + @Bean + @StepScope + public ImoMetaDataReader imoMetaDataReader( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, + @Value("#{stepExecution.id}") Long stepExecutionId) { + ImoMetaDataReader reader = new ImoMetaDataReader(maritimeApiWebClient, batchApiLogService, maritimeApiUrl); + reader.setExecutionIds(jobExecutionId, stepExecutionId); + return reader; + } + + @Bean + @StepScope + public ItemProcessor imoMetaProcessor( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId) { + return dto -> ImoMetaEntity.builder() + .imoNo(dto.getImoNo()) + .coreShipInd(dto.getCoreShipInd()) + .datasetVer(dto.getDataSetVersion() != null ? dto.getDataSetVersion().getDataSetVersion() : null) + .jobExecutionId(jobExecutionId) + .createdBy(CREATED_BY_SYSTEM) + .build(); + } + + // ======================================== + // Step 2: 삭제 대상 IMO 플래그 업데이트 (Tasklet) + // ======================================== + + @Bean + public Tasklet imoMetaDeleteFlagTasklet() { + return (contribution, chunkContext) -> { + long jobExecutionId = chunkContext.getStepContext().getStepExecution() + .getJobExecution().getId(); + long stepExecutionId = chunkContext.getStepContext().getStepExecution().getId(); + + String fullUri = UriComponentsBuilder.fromHttpUrl(maritimeApiUrl) + .path(DELETE_IMO_PATH) + .build() + .toUriString(); + + long startTime = System.currentTimeMillis(); + int statusCode = 200; + String errorMessage = null; + long responseSize = 0L; + + try { + log.info("[ImoMetaDeleteFlagTasklet] API 요청 시작: {}", fullUri); + + ImoMetaResponse response = maritimeApiWebClient.get() + .uri(uriBuilder -> uriBuilder.path(DELETE_IMO_PATH).build()) + .retrieve() + .bodyToMono(ImoMetaResponse.class) + .block(); + + List imoNumbers = (response != null && response.getShips() != null) + ? response.getShips().stream() + .map(ImoMetaDto::getImoNo) + .filter(imoNo -> imoNo != null && !imoNo.isBlank()) + .toList() + : List.of(); + + responseSize = imoNumbers.size(); + log.info("[ImoMetaDeleteFlagTasklet] 삭제 대상 IMO {}건 조회됨", responseSize); + + if (!imoNumbers.isEmpty()) { + imoMetaRepository.updateDeleteFlag(imoNumbers); + log.info("[ImoMetaDeleteFlagTasklet] 삭제 플래그 업데이트 완료: {}건", imoNumbers.size()); + } else { + log.info("[ImoMetaDeleteFlagTasklet] 삭제 대상 IMO 없음 → 스킵"); + } + + } catch (WebClientResponseException e) { + statusCode = e.getStatusCode().value(); + errorMessage = String.format("API Error: %s", e.getResponseBodyAsString()); + log.error("[ImoMetaDeleteFlagTasklet] API 호출 실패 (HTTP {}): {}", statusCode, errorMessage); + throw e; + } catch (Exception e) { + statusCode = 500; + errorMessage = String.format("System Error: %s", e.getMessage()); + log.error("[ImoMetaDeleteFlagTasklet] API 호출 중 예외 발생: {}", errorMessage); + throw e; + } finally { + long duration = System.currentTimeMillis() - startTime; + batchApiLogService.saveLog(BatchApiLog.builder() + .apiRequestLocation("ImoMetaDeleteFlagTasklet") + .requestUri(fullUri) + .httpMethod("GET") + .statusCode(statusCode) + .responseTimeMs(duration) + .responseCount(responseSize) + .errorMessage(errorMessage) + .createdAt(LocalDateTime.now()) + .jobExecutionId(jobExecutionId) + .stepExecutionId(stepExecutionId) + .build()); + } + + return RepeatStatus.FINISHED; + }; + } + + @Bean(name = "ImoMetaDeleteFlagStep") + public Step imoMetaDeleteFlagStep() { + return new StepBuilder("ImoMetaDeleteFlagStep", jobRepository) + .tasklet(imoMetaDeleteFlagTasklet(), transactionManager) + .build(); + } + + // ======================================== + // Job Bean + // ======================================== + + @Bean(name = "ImoMetaImportJob") + public Job imoMetaImportJob() { + return job(); + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/imometa/dto/ImoMetaDto.java b/src/main/java/com/snp/batch/jobs/batch/imometa/dto/ImoMetaDto.java new file mode 100644 index 0000000..9f43b94 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/imometa/dto/ImoMetaDto.java @@ -0,0 +1,31 @@ +package com.snp.batch.jobs.batch.imometa.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ImoMetaDto { + + @JsonProperty("CoreShipInd") + private String coreShipInd; + + @JsonProperty("DataSetVersion") + private DataSetVersionWrapper dataSetVersion; + + @JsonProperty("IHSLRorIMOShipNo") + private String imoNo; + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class DataSetVersionWrapper { + @JsonProperty("DataSetVersion") + private String dataSetVersion; + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/imometa/dto/ImoMetaResponse.java b/src/main/java/com/snp/batch/jobs/batch/imometa/dto/ImoMetaResponse.java new file mode 100644 index 0000000..d653d23 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/imometa/dto/ImoMetaResponse.java @@ -0,0 +1,20 @@ +package com.snp.batch.jobs.batch.imometa.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ImoMetaResponse { + + @JsonProperty("shipCount") + private int shipCount; + + @JsonProperty("Ships") + private List ships; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/imometa/entity/ImoMetaEntity.java b/src/main/java/com/snp/batch/jobs/batch/imometa/entity/ImoMetaEntity.java new file mode 100644 index 0000000..f7fdb65 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/imometa/entity/ImoMetaEntity.java @@ -0,0 +1,20 @@ +package com.snp.batch.jobs.batch.imometa.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class ImoMetaEntity extends BaseEntity { + + private String imoNo; + private String coreShipInd; + private String datasetVer; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/imometa/reader/ImoMetaDataReader.java b/src/main/java/com/snp/batch/jobs/batch/imometa/reader/ImoMetaDataReader.java new file mode 100644 index 0000000..31d50cf --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/imometa/reader/ImoMetaDataReader.java @@ -0,0 +1,141 @@ +package com.snp.batch.jobs.batch.imometa.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.global.model.BatchApiLog; +import com.snp.batch.jobs.batch.imometa.dto.ImoMetaDto; +import com.snp.batch.jobs.batch.imometa.dto.ImoMetaResponse; +import com.snp.batch.service.BatchApiLogService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import org.springframework.web.util.UriComponentsBuilder; + +import java.time.LocalDateTime; +import java.util.List; + +@Slf4j +public class ImoMetaDataReader extends BaseApiReader { + + private static final String ALL_IMO_PATH = "/MaritimeWCF/APSShipService.svc/RESTFul/GetAllIMONumbers"; + private static final int BATCH_SIZE = 5000; + + private final BatchApiLogService batchApiLogService; + private final String maritimeApiUrl; + + private List allData; + private int currentBatchIndex = 0; + + public ImoMetaDataReader(WebClient webClient, BatchApiLogService batchApiLogService, String maritimeApiUrl) { + super(webClient); + this.batchApiLogService = batchApiLogService; + this.maritimeApiUrl = maritimeApiUrl; + enableChunkMode(); + } + + @Override + protected String getReaderName() { + return "ImoMetaDataReader"; + } + + @Override + protected String getApiPath() { + return ALL_IMO_PATH; + } + + @Override + protected void resetCustomState() { + this.currentBatchIndex = 0; + this.allData = null; + } + + @Override + protected List fetchNextBatch() throws Exception { + if (allData == null) { + allData = fetchAllImoData(); + + if (allData == null || allData.isEmpty()) { + log.warn("[{}] 조회된 데이터 없음 → 종료", getReaderName()); + return null; + } + + log.info("[{}] 총 {}건 데이터 조회됨. batchSize = {}", getReaderName(), allData.size(), BATCH_SIZE); + } + + if (currentBatchIndex >= allData.size()) { + log.info("[{}] 모든 배치 처리 완료", getReaderName()); + return null; + } + + int end = Math.min(currentBatchIndex + BATCH_SIZE, allData.size()); + List batch = allData.subList(currentBatchIndex, end); + + int batchNum = (currentBatchIndex / BATCH_SIZE) + 1; + int totalBatches = (int) Math.ceil((double) allData.size() / BATCH_SIZE); + + log.info("[{}] 배치 {}/{} 처리 중: {}건", getReaderName(), batchNum, totalBatches, batch.size()); + + currentBatchIndex = end; + updateApiCallStats(totalBatches, batchNum); + + return batch; + } + + private List fetchAllImoData() { + String fullUri = UriComponentsBuilder.fromHttpUrl(maritimeApiUrl) + .path(ALL_IMO_PATH) + .queryParam("includeDeadShips", "0") + .build() + .toUriString(); + + long startTime = System.currentTimeMillis(); + int statusCode = 200; + String errorMessage = null; + long responseSize = 0L; + + try { + log.info("[{}] API 요청 시작: {}", getReaderName(), fullUri); + + ImoMetaResponse response = webClient.get() + .uri(uriBuilder -> uriBuilder + .path(ALL_IMO_PATH) + .queryParam("includeDeadShips", "0") + .build()) + .retrieve() + .bodyToMono(ImoMetaResponse.class) + .block(); + + List result = (response != null && response.getShips() != null) + ? response.getShips() + : List.of(); + + responseSize = result.size(); + log.info("[{}] API 응답 수신: {}건", getReaderName(), responseSize); + return result; + + } catch (WebClientResponseException e) { + statusCode = e.getStatusCode().value(); + errorMessage = String.format("API Error: %s", e.getResponseBodyAsString()); + log.error("[{}] API 호출 실패 (HTTP {}): {}", getReaderName(), statusCode, errorMessage); + throw e; + } catch (Exception e) { + statusCode = 500; + errorMessage = String.format("System Error: %s", e.getMessage()); + log.error("[{}] API 호출 중 예외 발생: {}", getReaderName(), errorMessage); + throw e; + } finally { + long duration = System.currentTimeMillis() - startTime; + batchApiLogService.saveLog(BatchApiLog.builder() + .apiRequestLocation(getReaderName()) + .requestUri(fullUri) + .httpMethod("GET") + .statusCode(statusCode) + .responseTimeMs(duration) + .responseCount(responseSize) + .errorMessage(errorMessage) + .createdAt(LocalDateTime.now()) + .jobExecutionId(getJobExecutionId()) + .stepExecutionId(getStepExecutionId()) + .build()); + } + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/imometa/repository/ImoMetaRepository.java b/src/main/java/com/snp/batch/jobs/batch/imometa/repository/ImoMetaRepository.java new file mode 100644 index 0000000..6f39731 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/imometa/repository/ImoMetaRepository.java @@ -0,0 +1,10 @@ +package com.snp.batch.jobs.batch.imometa.repository; + +import com.snp.batch.jobs.batch.imometa.entity.ImoMetaEntity; + +import java.util.List; + +public interface ImoMetaRepository { + void upsertAll(List items); + void updateDeleteFlag(List imoNumbers); +} diff --git a/src/main/java/com/snp/batch/jobs/batch/imometa/repository/ImoMetaRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/batch/imometa/repository/ImoMetaRepositoryImpl.java new file mode 100644 index 0000000..826c507 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/imometa/repository/ImoMetaRepositoryImpl.java @@ -0,0 +1,76 @@ +package com.snp.batch.jobs.batch.imometa.repository; + +import com.snp.batch.jobs.batch.imometa.entity.ImoMetaEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.Types; +import java.util.List; + +@Slf4j +@Repository +public class ImoMetaRepositoryImpl implements ImoMetaRepository { + + private final JdbcTemplate jdbcTemplate; + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + + @Value("${app.batch.target-schema.tables.imo-meta-001:tb_ship_default_info}") + private String tableName; + + public ImoMetaRepositoryImpl(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + private String getTableName() { + return targetSchema + "." + tableName; + } + + @Override + @Transactional + public void upsertAll(List items) { + if (items == null || items.isEmpty()) return; + + String sql = """ + INSERT INTO %s (imo_no, core_ship_ind, dataset_ver, job_execution_id, creatr_id) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (imo_no) DO UPDATE SET + core_ship_ind = EXCLUDED.core_ship_ind, + dataset_ver = EXCLUDED.dataset_ver, + job_execution_id = EXCLUDED.job_execution_id, + mdfcn_dt = CURRENT_TIMESTAMP, + mdfr_id = EXCLUDED.creatr_id + """.formatted(getTableName()); + + jdbcTemplate.batchUpdate(sql, items, items.size(), (ps, entity) -> { + int idx = 1; + ps.setString(idx++, entity.getImoNo()); + ps.setString(idx++, entity.getCoreShipInd()); + ps.setString(idx++, entity.getDatasetVer()); + ps.setObject(idx++, entity.getJobExecutionId(), Types.BIGINT); + ps.setString(idx, entity.getCreatedBy()); + }); + + log.info("ImoMeta upsert 완료: {} 건", items.size()); + } + + @Override + @Transactional + public void updateDeleteFlag(List imoNumbers) { + if (imoNumbers == null || imoNumbers.isEmpty()) return; + + String sql = """ + UPDATE %s SET umnged_ship_flag = 'Y', mdfcn_dt = CURRENT_TIMESTAMP, mdfr_id = 'SYSTEM' + WHERE imo_no = ? + """.formatted(getTableName()); + + jdbcTemplate.batchUpdate(sql, imoNumbers, imoNumbers.size(), + (ps, imoNo) -> ps.setString(1, imoNo)); + + log.info("ImoMeta delete flag 업데이트 완료: {} 건", imoNumbers.size()); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/imometa/writer/ImoMetaDataWriter.java b/src/main/java/com/snp/batch/jobs/batch/imometa/writer/ImoMetaDataWriter.java new file mode 100644 index 0000000..9d5de26 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/imometa/writer/ImoMetaDataWriter.java @@ -0,0 +1,26 @@ +package com.snp.batch.jobs.batch.imometa.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.batch.imometa.entity.ImoMetaEntity; +import com.snp.batch.jobs.batch.imometa.repository.ImoMetaRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Slf4j +@Component +public class ImoMetaDataWriter extends BaseWriter { + + private final ImoMetaRepository imoMetaRepository; + + public ImoMetaDataWriter(ImoMetaRepository imoMetaRepository) { + super("ImoMetaEntity"); + this.imoMetaRepository = imoMetaRepository; + } + + @Override + protected void writeItems(List items) throws Exception { + imoMetaRepository.upsertAll(items); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/config/AnchorageCallsRangeJobConfig.java b/src/main/java/com/snp/batch/jobs/batch/movement/config/AnchorageCallsRangeJobConfig.java new file mode 100644 index 0000000..246d1e6 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/config/AnchorageCallsRangeJobConfig.java @@ -0,0 +1,158 @@ +package com.snp.batch.jobs.batch.movement.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.config.BaseMultiStepJobConfig; +import com.snp.batch.common.batch.tasklet.LastExecutionUpdateTasklet; +import com.snp.batch.jobs.batch.movement.dto.AnchorageCallsDto; +import com.snp.batch.jobs.batch.movement.entity.AnchorageCallsEntity; +import com.snp.batch.jobs.batch.movement.processor.AnchorageCallsProcessor; +import com.snp.batch.jobs.batch.movement.reader.AnchorageCallsRangeReader; +import com.snp.batch.jobs.batch.movement.writer.AnchorageCallsWriter; +import com.snp.batch.service.BatchApiLogService; +import com.snp.batch.service.BatchDateService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.reactive.function.client.WebClient; + + +@Slf4j +@Configuration +public class AnchorageCallsRangeJobConfig extends BaseMultiStepJobConfig { + + private final AnchorageCallsProcessor anchorageCallsProcessor; + private final AnchorageCallsWriter anchorageCallsWriter; + private final AnchorageCallsRangeReader anchorageCallsRangeReader; + private final WebClient maritimeServiceApiWebClient; + private final JdbcTemplate jdbcTemplate; + private final BatchDateService batchDateService; + private final BatchApiLogService batchApiLogService; + private final ObjectMapper objectMapper; + + @Value("${app.batch.webservice-api.url}") + private String maritimeServiceApiUrl; + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + + protected String getApiKey() {return "ANCHORAGE_CALLS_IMPORT_API";} + + + public AnchorageCallsRangeJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + AnchorageCallsProcessor anchorageCallsProcessor, + AnchorageCallsWriter anchorageCallsWriter, + AnchorageCallsRangeReader anchorageCallsRangeReader, + @Qualifier("maritimeServiceApiWebClient")WebClient maritimeServiceApiWebClient, + JdbcTemplate jdbcTemplate, + BatchDateService batchDateService, + BatchApiLogService batchApiLogService, + ObjectMapper objectMapper + ) { + super(jobRepository, transactionManager); + this.anchorageCallsProcessor = anchorageCallsProcessor; + this.anchorageCallsWriter = anchorageCallsWriter; + this.anchorageCallsRangeReader = anchorageCallsRangeReader; + this.maritimeServiceApiWebClient = maritimeServiceApiWebClient; + this.jdbcTemplate = jdbcTemplate; + this.batchDateService = batchDateService; + this.batchApiLogService = batchApiLogService; + this.objectMapper = objectMapper; + } + + @Override + protected String getJobName() { + return "AnchorageCallsRangeImportJob"; + } + + @Override + protected String getStepName() { + return "AnchorageCallsRangeImportStep"; + } + + @Override + protected Job createJobFlow(JobBuilder jobBuilder) { + return jobBuilder + .start(anchorageCallsRangeImportStep()) // 1단계: API 데이터 적재 + .next(anchorageCallsLastExecutionUpdateStep()) // 2단계: 모두 완료 시, BATCH_LAST_EXECUTION 마지막 성공일자 업데이트 + .build(); + } + + @Override + protected ItemReader createReader() { // 타입 변경 + return anchorageCallsRangeReader; + } + + @Bean + @StepScope + public AnchorageCallsRangeReader anchorageCallsReader( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, // SpEL로 ID 추출 + @Value("#{stepExecution.id}") Long stepExecutionId + ) { + AnchorageCallsRangeReader reader = new AnchorageCallsRangeReader(maritimeServiceApiWebClient, batchDateService, batchApiLogService, maritimeServiceApiUrl); + reader.setExecutionIds(jobExecutionId, stepExecutionId); // ID 세팅 + return reader; + } + + @Override + protected ItemProcessor createProcessor() { + return anchorageCallsProcessor; + } + @Bean + @StepScope + public AnchorageCallsProcessor anchorageCallsProcessor( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, + ObjectMapper objectMapper + ) { + return new AnchorageCallsProcessor(jobExecutionId, objectMapper); + } + + @Override + protected ItemWriter createWriter() { // 타입 변경 + return anchorageCallsWriter; + } + + @Override + protected int getChunkSize() { + return 5000; + } + + @Bean(name = "AnchorageCallsRangeImportJob") + public Job anchorageCallsRangeImportJob() { + return job(); + } + + @Bean(name = "AnchorageCallsRangeImportStep") + public Step anchorageCallsRangeImportStep() { + return step(); + } + + /** + * 2단계: 모든 스텝 성공 시 배치 실행 로그(날짜) 업데이트 + */ + @Bean + public Tasklet anchorageCallsLastExecutionUpdateTasklet() { + return new LastExecutionUpdateTasklet(jdbcTemplate, targetSchema, getApiKey(), 0); + } + @Bean(name = "AnchorageCallsLastExecutionUpdateStep") + public Step anchorageCallsLastExecutionUpdateStep() { + return new StepBuilder("AnchorageCallsLastExecutionUpdateStep", jobRepository) + .tasklet(anchorageCallsLastExecutionUpdateTasklet(), transactionManager) + .build(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/config/BerthCallsRangJobConfig.java b/src/main/java/com/snp/batch/jobs/batch/movement/config/BerthCallsRangJobConfig.java new file mode 100644 index 0000000..eb30f20 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/config/BerthCallsRangJobConfig.java @@ -0,0 +1,152 @@ +package com.snp.batch.jobs.batch.movement.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.config.BaseMultiStepJobConfig; +import com.snp.batch.common.batch.tasklet.LastExecutionUpdateTasklet; +import com.snp.batch.jobs.batch.movement.dto.BerthCallsDto; +import com.snp.batch.jobs.batch.movement.entity.BerthCallsEntity; +import com.snp.batch.jobs.batch.movement.processor.BerthCallsProcessor; +import com.snp.batch.jobs.batch.movement.reader.BerthCallsRangeReader; +import com.snp.batch.jobs.batch.movement.writer.BerthCallsWriter; +import com.snp.batch.service.BatchApiLogService; +import com.snp.batch.service.BatchDateService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.reactive.function.client.WebClient; + + +@Slf4j +@Configuration +public class BerthCallsRangJobConfig extends BaseMultiStepJobConfig { + + private final BerthCallsProcessor berthCallsProcessor; + private final BerthCallsWriter berthCallsWriter; + private final BerthCallsRangeReader berthCallsRangeReader; + private final WebClient maritimeApiWebClient; + private final JdbcTemplate jdbcTemplate; + private final BatchDateService batchDateService; + private final BatchApiLogService batchApiLogService; + + @Value("${app.batch.webservice-api.url}") + private String maritimeServiceApiUrl; + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + + protected String getApiKey() {return "BERTH_CALLS_IMPORT_API";} + + public BerthCallsRangJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + BerthCallsProcessor berthCallsProcessor, + BerthCallsWriter berthCallsWriter, + BerthCallsRangeReader berthCallsRangeReader, + @Qualifier("maritimeServiceApiWebClient") WebClient maritimeApiWebClient, + JdbcTemplate jdbcTemplate, + BatchDateService batchDateService, + BatchApiLogService batchApiLogService + ) { + super(jobRepository, transactionManager); + this.berthCallsProcessor = berthCallsProcessor; + this.berthCallsWriter = berthCallsWriter; + this.berthCallsRangeReader = berthCallsRangeReader; + this.maritimeApiWebClient = maritimeApiWebClient; + this.jdbcTemplate = jdbcTemplate; + this.batchDateService = batchDateService; + this.batchApiLogService = batchApiLogService; + } + + @Override + protected String getJobName() { + return "BerthCallsRangeImportJob"; + } + + @Override + protected String getStepName() { + return "BerthCallsRangeImportStep"; + } + + @Override + protected Job createJobFlow(JobBuilder jobBuilder) { + return jobBuilder + .start(berthCallsRangeImportStep()) // 1단계: API 데이터 적재 + .next(berthCallsLastExecutionUpdateStep()) // 2단계: 모두 완료 시, BATCH_LAST_EXECUTION 마지막 성공일자 업데이트 + .build(); + } + + @Override + protected ItemReader createReader() { // 타입 변경 + return berthCallsRangeReader; + } + @Bean + @StepScope + public BerthCallsRangeReader berthCallsRangeReader( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, // SpEL로 ID 추출 + @Value("#{stepExecution.id}") Long stepExecutionId + ) { + BerthCallsRangeReader reader = new BerthCallsRangeReader(maritimeApiWebClient, batchDateService, batchApiLogService, maritimeServiceApiUrl); + reader.setExecutionIds(jobExecutionId, stepExecutionId); // ID 세팅 + return reader; + } + @Override + protected ItemProcessor createProcessor() { + return berthCallsProcessor; + } + + @Bean + @StepScope + public BerthCallsProcessor berthCallsProcessor( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, + ObjectMapper objectMapper + ) { + return new BerthCallsProcessor(jobExecutionId, objectMapper); + } + + @Override + protected ItemWriter createWriter() { + return berthCallsWriter; + } + + @Override + protected int getChunkSize() { + return 5000; + } + + @Bean(name = "BerthCallsRangeImportJob") + public Job berthCallsRangeImportJob() { + return job(); + } + + @Bean(name = "BerthCallsRangeImportStep") + public Step berthCallsRangeImportStep() { + return step(); + } + /** + * 2단계: 모든 스텝 성공 시 배치 실행 로그(날짜) 업데이트 + */ + @Bean + public Tasklet berthCallsLastExecutionUpdateTasklet() { + return new LastExecutionUpdateTasklet(jdbcTemplate, targetSchema, getApiKey(), 0); + } + @Bean(name = "BerthCallsLastExecutionUpdateStep") + public Step berthCallsLastExecutionUpdateStep() { + return new StepBuilder("BerthCallsLastExecutionUpdateStep", jobRepository) + .tasklet(berthCallsLastExecutionUpdateTasklet(), transactionManager) + .build(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/config/CurrentlyAtRangeJobConfig.java b/src/main/java/com/snp/batch/jobs/batch/movement/config/CurrentlyAtRangeJobConfig.java new file mode 100644 index 0000000..226ccd6 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/config/CurrentlyAtRangeJobConfig.java @@ -0,0 +1,150 @@ +package com.snp.batch.jobs.batch.movement.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.config.BaseMultiStepJobConfig; +import com.snp.batch.common.batch.tasklet.LastExecutionUpdateTasklet; +import com.snp.batch.jobs.batch.movement.dto.CurrentlyAtDto; +import com.snp.batch.jobs.batch.movement.entity.CurrentlyAtEntity; +import com.snp.batch.jobs.batch.movement.processor.CurrentlyAtProcessor; +import com.snp.batch.jobs.batch.movement.reader.CurrentlyAtRangeReader; +import com.snp.batch.jobs.batch.movement.writer.CurrentlyAtWriter; +import com.snp.batch.service.BatchApiLogService; +import com.snp.batch.service.BatchDateService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.reactive.function.client.WebClient; + + +@Slf4j +@Configuration +public class CurrentlyAtRangeJobConfig extends BaseMultiStepJobConfig { + + private final CurrentlyAtProcessor currentlyAtProcessor; + private final CurrentlyAtWriter currentlyAtWriter; + private final CurrentlyAtRangeReader currentlyAtRangeReader; + private final WebClient maritimeApiWebClient; + private final JdbcTemplate jdbcTemplate; + private final BatchDateService batchDateService; + private final BatchApiLogService batchApiLogService; + + @Value("${app.batch.webservice-api.url}") + private String maritimeServiceApiUrl; + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + + protected String getApiKey() {return "CURRENTLY_AT_IMPORT_API";} + + public CurrentlyAtRangeJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + CurrentlyAtProcessor currentlyAtProcessor, + CurrentlyAtWriter currentlyAtWriter, + CurrentlyAtRangeReader currentlyAtRangeReader, + @Qualifier("maritimeServiceApiWebClient") WebClient maritimeApiWebClient, + JdbcTemplate jdbcTemplate, + BatchDateService batchDateService, + BatchApiLogService batchApiLogService + ) { // ObjectMapper 주입 추가 + super(jobRepository, transactionManager); + this.currentlyAtProcessor = currentlyAtProcessor; + this.currentlyAtWriter = currentlyAtWriter; + this.currentlyAtRangeReader = currentlyAtRangeReader; + this.maritimeApiWebClient = maritimeApiWebClient; + this.jdbcTemplate = jdbcTemplate; + this.batchDateService = batchDateService; + this.batchApiLogService = batchApiLogService; + } + + @Override + protected String getJobName() { + return "CurrentlyAtRangeImportJob"; + } + + @Override + protected String getStepName() { + return "CurrentlyAtRangeImportStep"; + } + + @Override + protected Job createJobFlow(JobBuilder jobBuilder) { + return jobBuilder + .start(currentlyAtRangeImportStep()) // 1단계: API 데이터 적재 + .next(currentlyAtLastExecutionUpdateStep()) // 2단계: 모두 완료 시, BATCH_LAST_EXECUTION 마지막 성공일자 업데이트 + .build(); + } + + @Override + protected ItemReader createReader() { // 타입 변경 + return currentlyAtRangeReader; + } + @Bean + @StepScope + public CurrentlyAtRangeReader currentlyAtReader( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, // SpEL로 ID 추출 + @Value("#{stepExecution.id}") Long stepExecutionId + ) { + CurrentlyAtRangeReader reader = new CurrentlyAtRangeReader(maritimeApiWebClient, batchDateService, batchApiLogService, maritimeServiceApiUrl); + reader.setExecutionIds(jobExecutionId, stepExecutionId); // ID 세팅 + return reader; + } + @Override + protected ItemProcessor createProcessor() { + return currentlyAtProcessor; + } + @Bean + @StepScope + public CurrentlyAtProcessor currentlyAtProcessor( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, + ObjectMapper objectMapper + ) { + return new CurrentlyAtProcessor(jobExecutionId, objectMapper); + } + @Override + protected ItemWriter createWriter() { // 타입 변경 + return currentlyAtWriter; + } + + @Override + protected int getChunkSize() { + return 5000; // API에서 100개씩 가져오므로 chunk도 100으로 설정 + } + + @Bean(name = "CurrentlyAtRangeImportJob") + public Job currentlyAtRangeImportJob() { + return job(); + } + + @Bean(name = "CurrentlyAtRangeImportStep") + public Step currentlyAtRangeImportStep() { + return step(); + } + /** + * 2단계: 모든 스텝 성공 시 배치 실행 로그(날짜) 업데이트 + */ + @Bean + public Tasklet currentlyAtLastExecutionUpdateTasklet() { + return new LastExecutionUpdateTasklet(jdbcTemplate, targetSchema, getApiKey(), 0); + } + @Bean(name = "CurrentlyAtLastExecutionUpdateStep") + public Step currentlyAtLastExecutionUpdateStep() { + return new StepBuilder("CurrentlyAtLastExecutionUpdateStep", jobRepository) + .tasklet(currentlyAtLastExecutionUpdateTasklet(), transactionManager) + .build(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/config/DestinationsRangeJobConfig.java b/src/main/java/com/snp/batch/jobs/batch/movement/config/DestinationsRangeJobConfig.java new file mode 100644 index 0000000..9e74c79 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/config/DestinationsRangeJobConfig.java @@ -0,0 +1,153 @@ +package com.snp.batch.jobs.batch.movement.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.config.BaseMultiStepJobConfig; +import com.snp.batch.common.batch.tasklet.LastExecutionUpdateTasklet; +import com.snp.batch.jobs.batch.movement.dto.DestinationDto; +import com.snp.batch.jobs.batch.movement.entity.DestinationEntity; +import com.snp.batch.jobs.batch.movement.processor.DestinationProcessor; +import com.snp.batch.jobs.batch.movement.reader.DestinationRangeReader; +import com.snp.batch.jobs.batch.movement.writer.DestinationWriter; +import com.snp.batch.service.BatchApiLogService; +import com.snp.batch.service.BatchDateService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.reactive.function.client.WebClient; + + +@Slf4j +@Configuration +public class DestinationsRangeJobConfig extends BaseMultiStepJobConfig { + + private final DestinationProcessor destinationProcessor; + private final DestinationWriter destinationWriter; + private final DestinationRangeReader destinationRangeReader; + private final WebClient maritimeApiWebClient; + private final JdbcTemplate jdbcTemplate; + private final BatchDateService batchDateService; + private final BatchApiLogService batchApiLogService; + + @Value("${app.batch.webservice-api.url}") + private String maritimeServiceApiUrl; + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + + protected String getApiKey() {return "DESTINATIONS_IMPORT_API";} + + + public DestinationsRangeJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + DestinationProcessor destinationProcessor, + DestinationWriter destinationWriter, + DestinationRangeReader destinationRangeReader, + @Qualifier("maritimeServiceApiWebClient") WebClient maritimeApiWebClient, + JdbcTemplate jdbcTemplate, + BatchDateService batchDateService, + BatchApiLogService batchApiLogService + ) { // ObjectMapper 주입 추가 + super(jobRepository, transactionManager); + this.destinationProcessor = destinationProcessor; + this.destinationWriter = destinationWriter; + this.destinationRangeReader = destinationRangeReader; + this.maritimeApiWebClient = maritimeApiWebClient; + this.jdbcTemplate = jdbcTemplate; + this.batchDateService = batchDateService; + this.batchApiLogService = batchApiLogService; + } + + @Override + protected String getJobName() { + return "DestinationsRangeImportJob"; + } + + @Override + protected String getStepName() { + return "DestinationsRangeImportStep"; + } + + @Override + protected Job createJobFlow(JobBuilder jobBuilder) { + return jobBuilder + .start(destinationsRangeImportStep()) // 1단계: API 데이터 적재 + .next(destinationsLastExecutionUpdateStep()) // 2단계: 모두 완료 시, BATCH_LAST_EXECUTION 마지막 성공일자 업데이트 + .build(); + } + + @Override + protected ItemReader createReader() { // 타입 변경 + return destinationRangeReader; + } + @Bean + @StepScope + public DestinationRangeReader destinationRangeReader( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, // SpEL로 ID 추출 + @Value("#{stepExecution.id}") Long stepExecutionId + ) { + DestinationRangeReader reader = new DestinationRangeReader(maritimeApiWebClient, batchDateService, batchApiLogService, maritimeServiceApiUrl); + reader.setExecutionIds(jobExecutionId, stepExecutionId); // ID 세팅 + return reader; + } + @Override + protected ItemProcessor createProcessor() { + return destinationProcessor; + } + + @Bean + @StepScope + public DestinationProcessor destinationProcessor( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, + ObjectMapper objectMapper + ) { + return new DestinationProcessor(jobExecutionId, objectMapper); + } + + @Override + protected ItemWriter createWriter() { // 타입 변경 + return destinationWriter; + } + + @Override + protected int getChunkSize() { + return 5000; + } + + @Bean(name = "DestinationsRangeImportJob") + public Job destinationsRangeImportJob() { + return job(); + } + + @Bean(name = "DestinationsRangeImportStep") + public Step destinationsRangeImportStep() { + return step(); + } + /** + * 2단계: 모든 스텝 성공 시 배치 실행 로그(날짜) 업데이트 + */ + @Bean + public Tasklet destinationsLastExecutionUpdateTasklet() { + return new LastExecutionUpdateTasklet(jdbcTemplate, targetSchema, getApiKey(), 0); + } + @Bean(name = "DestinationsLastExecutionUpdateStep") + public Step destinationsLastExecutionUpdateStep() { + return new StepBuilder("DestinationsLastExecutionUpdateStep", jobRepository) + .tasklet(destinationsLastExecutionUpdateTasklet(), transactionManager) + .build(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/config/ShipPortCallsRangeJobConfig.java b/src/main/java/com/snp/batch/jobs/batch/movement/config/ShipPortCallsRangeJobConfig.java new file mode 100644 index 0000000..7758b53 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/config/ShipPortCallsRangeJobConfig.java @@ -0,0 +1,154 @@ +package com.snp.batch.jobs.batch.movement.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.config.BaseMultiStepJobConfig; +import com.snp.batch.common.batch.tasklet.LastExecutionUpdateTasklet; +import com.snp.batch.jobs.batch.movement.dto.PortCallsDto; +import com.snp.batch.jobs.batch.movement.entity.PortCallsEntity; +import com.snp.batch.jobs.batch.movement.processor.PortCallsProcessor; +import com.snp.batch.jobs.batch.movement.reader.PortCallsRangeReader; +import com.snp.batch.jobs.batch.movement.writer.PortCallsWriter; +import com.snp.batch.service.BatchApiLogService; +import com.snp.batch.service.BatchDateService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.reactive.function.client.WebClient; + + +@Slf4j +@Configuration +public class ShipPortCallsRangeJobConfig extends BaseMultiStepJobConfig { + + private final PortCallsProcessor portCallsProcessor; + private final PortCallsWriter portCallsWriter; + private final PortCallsRangeReader portCallsRangeReader; + private final WebClient maritimeApiWebClient; + private final JdbcTemplate jdbcTemplate; + private final BatchDateService batchDateService; + private final BatchApiLogService batchApiLogService; + + @Value("${app.batch.webservice-api.url}") + private String maritimeServiceApiUrl; + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + + protected String getApiKey() {return "PORT_CALLS_IMPORT_API";} + + public ShipPortCallsRangeJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + PortCallsProcessor portCallsProcessor, + PortCallsWriter portCallsWriter, + PortCallsRangeReader portCallsRangeReader, + @Qualifier("maritimeServiceApiWebClient") WebClient maritimeApiWebClient, + JdbcTemplate jdbcTemplate, + BatchDateService batchDateService, + BatchApiLogService batchApiLogService + ) { + super(jobRepository, transactionManager); + this.portCallsProcessor = portCallsProcessor; + this.portCallsWriter = portCallsWriter; + this.portCallsRangeReader = portCallsRangeReader; + this.maritimeApiWebClient = maritimeApiWebClient; + this.jdbcTemplate = jdbcTemplate; + this.batchDateService = batchDateService; + this.batchApiLogService = batchApiLogService; + } + + @Override + protected String getJobName() { + return "PortCallsRangeImportJob"; + } + + @Override + protected String getStepName() { + return "PortCallsRangeImportStep"; + } + + @Bean + @StepScope + public PortCallsRangeReader portCallsRangeReader( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, // SpEL로 ID 추출 + @Value("#{stepExecution.id}") Long stepExecutionId + ) { + PortCallsRangeReader reader = new PortCallsRangeReader(maritimeApiWebClient, batchDateService, batchApiLogService, maritimeServiceApiUrl); + reader.setExecutionIds(jobExecutionId, stepExecutionId); // ID 세팅 + return reader; + } + + @Override + protected Job createJobFlow(JobBuilder jobBuilder) { + return jobBuilder + .start(portCallsRangeImportStep()) // 1단계: API 데이터 적재 + .next(portCallsLastExecutionUpdateStep()) // 2단계: 모두 완료 시, BATCH_LAST_EXECUTION 마지막 성공일자 업데이트 + .build(); + } + + @Override + protected ItemReader createReader() { // 타입 변경 + return portCallsRangeReader; + } + + @Override + protected ItemProcessor createProcessor() { + return portCallsProcessor; + } + + @Bean + @StepScope + public PortCallsProcessor portCallsProcessor( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, + ObjectMapper objectMapper + ) { + return new PortCallsProcessor(jobExecutionId, objectMapper); + } + + @Override + protected ItemWriter createWriter() { // 타입 변경 + return portCallsWriter; + } + + @Override + protected int getChunkSize() { + return 5000; // API에서 5000개 가져오므로 chunk도 5000개씩 설정 + } + + @Bean(name = "PortCallsRangeImportJob") + public Job portCallsRangeImportJob() { + return job(); + } + + @Bean(name = "PortCallsRangeImportStep") + public Step portCallsRangeImportStep() { + return step(); + } + /** + * 2단계: 모든 스텝 성공 시 배치 실행 로그(날짜) 업데이트 + */ + @Bean + public Tasklet portCallsLastExecutionUpdateTasklet() { + return new LastExecutionUpdateTasklet(jdbcTemplate, targetSchema, getApiKey(), 0); + } + @Bean(name = "PortCallsLastExecutionUpdateStep") + public Step portCallsLastExecutionUpdateStep() { + return new StepBuilder("PortCallsLastExecutionUpdateStep", jobRepository) + .tasklet(portCallsLastExecutionUpdateTasklet(), transactionManager) + .build(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/config/StsOperationRangeJobConfig.java b/src/main/java/com/snp/batch/jobs/batch/movement/config/StsOperationRangeJobConfig.java new file mode 100644 index 0000000..93c388f --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/config/StsOperationRangeJobConfig.java @@ -0,0 +1,152 @@ +package com.snp.batch.jobs.batch.movement.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.config.BaseMultiStepJobConfig; +import com.snp.batch.common.batch.tasklet.LastExecutionUpdateTasklet; +import com.snp.batch.jobs.batch.movement.dto.StsOperationDto; +import com.snp.batch.jobs.batch.movement.entity.StsOperationEntity; +import com.snp.batch.jobs.batch.movement.processor.StsOperationProcessor; +import com.snp.batch.jobs.batch.movement.reader.StsOperationRangeReader; +import com.snp.batch.jobs.batch.movement.writer.StsOperationWriter; +import com.snp.batch.service.BatchApiLogService; +import com.snp.batch.service.BatchDateService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.reactive.function.client.WebClient; + + +@Slf4j +@Configuration +public class StsOperationRangeJobConfig extends BaseMultiStepJobConfig { + + private final StsOperationProcessor stsOperationProcessor; + private final StsOperationWriter stsOperationWriter; + private final StsOperationRangeReader stsOperationRangeReader; + private final WebClient maritimeApiWebClient; + private final JdbcTemplate jdbcTemplate; + private final BatchDateService batchDateService; + private final BatchApiLogService batchApiLogService; + + @Value("${app.batch.webservice-api.url}") + private String maritimeServiceApiUrl; + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + + protected String getApiKey() {return "STS_OPERATION_IMPORT_API";} + + + public StsOperationRangeJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + StsOperationProcessor stsOperationProcessor, + StsOperationWriter stsOperationWriter, + StsOperationRangeReader stsOperationRangeReader, + @Qualifier("maritimeServiceApiWebClient") WebClient maritimeApiWebClient, + JdbcTemplate jdbcTemplate, + BatchDateService batchDateService, + BatchApiLogService batchApiLogService + ) { // ObjectMapper 주입 추가 + super(jobRepository, transactionManager); + this.stsOperationProcessor = stsOperationProcessor; + this.stsOperationWriter = stsOperationWriter; + this.stsOperationRangeReader = stsOperationRangeReader; + this.maritimeApiWebClient = maritimeApiWebClient; + this.jdbcTemplate = jdbcTemplate; + this.batchDateService = batchDateService; + this.batchApiLogService = batchApiLogService; + } + + @Override + protected String getJobName() { + return "STSOperationRangeImportJob"; + } + + @Override + protected String getStepName() { + return "STSOperationRangeImportStep"; + } + + @Override + protected Job createJobFlow(JobBuilder jobBuilder) { + return jobBuilder + .start(STSOperationRangeImportStep()) // 1단계: API 데이터 적재 + .next(stsOperationLastExecutionUpdateStep()) // 2단계: 모두 완료 시, BATCH_LAST_EXECUTION 마지막 성공일자 업데이트 + .build(); + } + + @Override + protected ItemReader createReader() { // 타입 변경 + return stsOperationRangeReader; + } + @Bean + @StepScope + public StsOperationRangeReader stsOperationRangeReader( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, // SpEL로 ID 추출 + @Value("#{stepExecution.id}") Long stepExecutionId + ) { + StsOperationRangeReader reader = new StsOperationRangeReader(maritimeApiWebClient, batchDateService, batchApiLogService, maritimeServiceApiUrl); + reader.setExecutionIds(jobExecutionId, stepExecutionId); // ID 세팅 + return reader; + } + @Override + protected ItemProcessor createProcessor() { + return stsOperationProcessor; + } + @Bean + @StepScope + public StsOperationProcessor stsOperationProcessor( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, + ObjectMapper objectMapper + ) { + return new StsOperationProcessor(jobExecutionId, objectMapper); + } + + @Override + protected ItemWriter createWriter() { // 타입 변경 + return stsOperationWriter; + } + + @Override + protected int getChunkSize() { + return 5000; // API에서 100개씩 가져오므로 chunk도 100으로 설정 + } + + @Bean(name = "STSOperationRangeImportJob") + public Job STSOperationRangeImportJob() { + return job(); + } + + @Bean(name = "STSOperationRangeImportStep") + public Step STSOperationRangeImportStep() { + return step(); + } + /** + * 2단계: 모든 스텝 성공 시 배치 실행 로그(날짜) 업데이트 + */ + @Bean + public Tasklet stsOperationLastExecutionUpdateTasklet() { + return new LastExecutionUpdateTasklet(jdbcTemplate, targetSchema, getApiKey(), 0); + } + @Bean(name = "StsOperationLastExecutionUpdateStep") + public Step stsOperationLastExecutionUpdateStep() { + return new StepBuilder("StsOperationLastExecutionUpdateStep", jobRepository) + .tasklet(stsOperationLastExecutionUpdateTasklet(), transactionManager) + .build(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/config/TerminalCallsRangeJobConfig.java b/src/main/java/com/snp/batch/jobs/batch/movement/config/TerminalCallsRangeJobConfig.java new file mode 100644 index 0000000..bbc2f8a --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/config/TerminalCallsRangeJobConfig.java @@ -0,0 +1,152 @@ +package com.snp.batch.jobs.batch.movement.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.config.BaseMultiStepJobConfig; +import com.snp.batch.common.batch.tasklet.LastExecutionUpdateTasklet; +import com.snp.batch.jobs.batch.movement.dto.TerminalCallsDto; +import com.snp.batch.jobs.batch.movement.entity.TerminalCallsEntity; +import com.snp.batch.jobs.batch.movement.processor.TerminalCallsProcessor; +import com.snp.batch.jobs.batch.movement.reader.TerminalCallsRangeReader; +import com.snp.batch.jobs.batch.movement.writer.TerminalCallsWriter; +import com.snp.batch.service.BatchApiLogService; +import com.snp.batch.service.BatchDateService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.reactive.function.client.WebClient; + + +@Slf4j +@Configuration +public class TerminalCallsRangeJobConfig extends BaseMultiStepJobConfig { + + private final TerminalCallsProcessor terminalCallsProcessor; + private final TerminalCallsWriter terminalCallsWriter; + private final TerminalCallsRangeReader terminalCallsRangeReader; + private final WebClient maritimeApiWebClient; + private final JdbcTemplate jdbcTemplate; + private final BatchDateService batchDateService; + private final BatchApiLogService batchApiLogService; + + @Value("${app.batch.webservice-api.url}") + private String maritimeServiceApiUrl; + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + + protected String getApiKey() {return "TERMINAL_CALLS_IMPORT_API";} + + + public TerminalCallsRangeJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + TerminalCallsProcessor terminalCallsProcessor, + TerminalCallsWriter terminalCallsWriter, + TerminalCallsRangeReader terminalCallsRangeReader, + @Qualifier("maritimeServiceApiWebClient") WebClient maritimeApiWebClient, + JdbcTemplate jdbcTemplate, + BatchDateService batchDateService, + BatchApiLogService batchApiLogService + ) { // ObjectMapper 주입 추가 + super(jobRepository, transactionManager); + this.terminalCallsProcessor = terminalCallsProcessor; + this.terminalCallsWriter = terminalCallsWriter; + this.terminalCallsRangeReader = terminalCallsRangeReader; + this.maritimeApiWebClient = maritimeApiWebClient; + this.jdbcTemplate = jdbcTemplate; + this.batchDateService = batchDateService; + this.batchApiLogService = batchApiLogService; + } + + @Override + protected String getJobName() { + return "TerminalCallsRangeImportJob"; + } + + @Override + protected String getStepName() { + return "TerminalCallsRangeImportStep"; + } + + @Override + protected Job createJobFlow(JobBuilder jobBuilder) { + return jobBuilder + .start(terminalCallsRangeImportStep()) // 1단계: API 데이터 적재 + .next(terminalCallsLastExecutionUpdateStep()) // 2단계: 모두 완료 시, BATCH_LAST_EXECUTION 마지막 성공일자 업데이트 + .build(); + } + + @Override + protected ItemReader createReader() { // 타입 변경 + return terminalCallsRangeReader; + } + @Bean + @StepScope + public TerminalCallsRangeReader terminalCallsRangeReader( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, // SpEL로 ID 추출 + @Value("#{stepExecution.id}") Long stepExecutionId + ) { + TerminalCallsRangeReader reader = new TerminalCallsRangeReader(maritimeApiWebClient, batchDateService, batchApiLogService, maritimeServiceApiUrl); + reader.setExecutionIds(jobExecutionId, stepExecutionId); // ID 세팅 + return reader; + } + @Override + protected ItemProcessor createProcessor() { + return terminalCallsProcessor; + } + @Bean + @StepScope + public TerminalCallsProcessor terminalCallsProcessor( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, + ObjectMapper objectMapper + ) { + return new TerminalCallsProcessor(jobExecutionId, objectMapper); + } + + @Override + protected ItemWriter createWriter() { // 타입 변경 + return terminalCallsWriter; + } + + @Override + protected int getChunkSize() { + return 5000; + } + + @Bean(name = "TerminalCallsRangeImportJob") + public Job terminalCallsRangeImportJob() { + return job(); + } + + @Bean(name = "TerminalCallsRangeImportStep") + public Step terminalCallsRangeImportStep() { + return step(); + } + /** + * 2단계: 모든 스텝 성공 시 배치 실행 로그(날짜) 업데이트 + */ + @Bean + public Tasklet terminalCallsLastExecutionUpdateTasklet() { + return new LastExecutionUpdateTasklet(jdbcTemplate, targetSchema, getApiKey(), 0); + } + @Bean(name = "TerminalCallsLastExecutionUpdateStep") + public Step terminalCallsLastExecutionUpdateStep() { + return new StepBuilder("TerminalCallsLastExecutionUpdateStep", jobRepository) + .tasklet(terminalCallsLastExecutionUpdateTasklet(), transactionManager) + .build(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/config/TransitsRangeJobConfig.java b/src/main/java/com/snp/batch/jobs/batch/movement/config/TransitsRangeJobConfig.java new file mode 100644 index 0000000..b8b2a14 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/config/TransitsRangeJobConfig.java @@ -0,0 +1,150 @@ +package com.snp.batch.jobs.batch.movement.config; + +import com.snp.batch.common.batch.config.BaseMultiStepJobConfig; +import com.snp.batch.common.batch.tasklet.LastExecutionUpdateTasklet; +import com.snp.batch.jobs.batch.movement.dto.TransitsDto; +import com.snp.batch.jobs.batch.movement.entity.TransitsEntity; +import com.snp.batch.jobs.batch.movement.processor.TransitsProcessor; +import com.snp.batch.jobs.batch.movement.reader.TransitsRangeReader; +import com.snp.batch.jobs.batch.movement.writer.TransitsWriter; +import com.snp.batch.service.BatchApiLogService; +import com.snp.batch.service.BatchDateService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.reactive.function.client.WebClient; + + +@Slf4j +@Configuration +public class TransitsRangeJobConfig extends BaseMultiStepJobConfig { + + private final TransitsProcessor transitsProcessor; + private final TransitsWriter transitsWriter; + private final TransitsRangeReader transitsRangeReader; + private final WebClient maritimeApiWebClient; + private final JdbcTemplate jdbcTemplate; + private final BatchDateService batchDateService; + private final BatchApiLogService batchApiLogService; + + @Value("${app.batch.webservice-api.url}") + private String maritimeServiceApiUrl; + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + + protected String getApiKey() {return "TRANSITS_IMPORT_API";} + + public TransitsRangeJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + TransitsProcessor TransitsProcessor, + TransitsWriter transitsWriter, + TransitsRangeReader transitsRangeReader, + @Qualifier("maritimeServiceApiWebClient") WebClient maritimeApiWebClient, + JdbcTemplate jdbcTemplate, + BatchDateService batchDateService, + BatchApiLogService batchApiLogService + ) { // ObjectMapper 주입 추가 + super(jobRepository, transactionManager); + this.transitsProcessor = TransitsProcessor; + this.transitsWriter = transitsWriter; + this.transitsRangeReader = transitsRangeReader; + this.maritimeApiWebClient = maritimeApiWebClient; + this.jdbcTemplate = jdbcTemplate; + this.batchDateService = batchDateService; + this.batchApiLogService = batchApiLogService; + } + + @Override + protected String getJobName() { + return "TransitsRangeImportJob"; + } + + @Override + protected String getStepName() { + return "TransitsRangeImportStep"; + } + + @Override + protected Job createJobFlow(JobBuilder jobBuilder) { + return jobBuilder + .start(transitsRangeImportStep()) // 1단계: API 데이터 적재 + .next(transitsLastExecutionUpdateStep()) // 2단계: 모두 완료 시, BATCH_LAST_EXECUTION 마지막 성공일자 업데이트 + .build(); + } + + @Override + protected ItemReader createReader() { // 타입 변경 + return transitsRangeReader; + } + @Bean + @StepScope + public TransitsRangeReader transitsRangeReaderanchorageCallsReader( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, // SpEL로 ID 추출 + @Value("#{stepExecution.id}") Long stepExecutionId + ) { + TransitsRangeReader reader = new TransitsRangeReader(maritimeApiWebClient, batchDateService, batchApiLogService, maritimeServiceApiUrl); + reader.setExecutionIds(jobExecutionId, stepExecutionId); // ID 세팅 + return reader; + } + @Override + protected ItemProcessor createProcessor() { + return transitsProcessor; + } + + @Bean + @StepScope + public TransitsProcessor transitsProcessor( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId + ) { + return new TransitsProcessor(jobExecutionId); + } + + @Override + protected ItemWriter createWriter() { // 타입 변경 + return transitsWriter; + } + + @Override + protected int getChunkSize() { + return 5000; // API에서 100개씩 가져오므로 chunk도 100으로 설정 + } + + @Bean(name = "TransitsRangeImportJob") + public Job transitsRangeImportJob() { + return job(); + } + + @Bean(name = "TransitsRangeImportStep") + public Step transitsRangeImportStep() { + return step(); + } + /** + * 2단계: 모든 스텝 성공 시 배치 실행 로그(날짜) 업데이트 + */ + @Bean + public Tasklet transitsLastExecutionUpdateTasklet() { + return new LastExecutionUpdateTasklet(jdbcTemplate, targetSchema, getApiKey(), 0); + } + @Bean(name = "TransitsLastExecutionUpdateStep") + public Step transitsLastExecutionUpdateStep() { + return new StepBuilder("TransitsLastExecutionUpdateStep", jobRepository) + .tasklet(transitsLastExecutionUpdateTasklet(), transactionManager) + .build(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/dto/AnchorageCallsDto.java b/src/main/java/com/snp/batch/jobs/batch/movement/dto/AnchorageCallsDto.java new file mode 100644 index 0000000..f345154 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/dto/AnchorageCallsDto.java @@ -0,0 +1,31 @@ +package com.snp.batch.jobs.batch.movement.dto; + +import lombok.Data; + +@Data +public class AnchorageCallsDto { + private String movementType; + private String imolRorIHSNumber; + private String movementDate; + + private Integer portCallId; + private Integer facilityId; + private String facilityName; + private String facilityType; + + private Integer subFacilityId; + private String subFacilityName; + private String subFacilityType; + + private String countryCode; + private String countryName; + + private Double draught; + private Double latitude; + private Double longitude; + + private AnchorageCallsPositionDto position; + + private String destination; + private String iso2; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/dto/AnchorageCallsPositionDto.java b/src/main/java/com/snp/batch/jobs/batch/movement/dto/AnchorageCallsPositionDto.java new file mode 100644 index 0000000..856e271 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/dto/AnchorageCallsPositionDto.java @@ -0,0 +1,19 @@ +package com.snp.batch.jobs.batch.movement.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class AnchorageCallsPositionDto { + private Boolean isNull; + private Integer stSrid; + private Double lat; + @JsonProperty("long") + private Double lon; + private Double z; + private Double m; + private Boolean hasZ; + private Boolean hasM; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/dto/BerthCallsDto.java b/src/main/java/com/snp/batch/jobs/batch/movement/dto/BerthCallsDto.java new file mode 100644 index 0000000..e02a726 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/dto/BerthCallsDto.java @@ -0,0 +1,32 @@ +package com.snp.batch.jobs.batch.movement.dto; + +import lombok.Data; + +@Data +public class BerthCallsDto { + private String movementType; + private String imolRorIHSNumber; + private String movementDate; + + + private Integer facilityId; + private String facilityName; + private String facilityType; + + private Integer parentFacilityId; + private String parentFacilityName; + private String parentFacilityType; + + private String countryCode; + private String countryName; + + private Double draught; + private Double latitude; + private Double longitude; + + private BerthCallsPositionDto position; + + private Integer parentCallId; + private String iso2; + private String eventStartDate; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/dto/BerthCallsPositionDto.java b/src/main/java/com/snp/batch/jobs/batch/movement/dto/BerthCallsPositionDto.java new file mode 100644 index 0000000..73087d7 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/dto/BerthCallsPositionDto.java @@ -0,0 +1,17 @@ +package com.snp.batch.jobs.batch.movement.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class BerthCallsPositionDto { + private boolean isNull; + private int stSrid; + private double lat; + @JsonProperty("long") + private double lon; + private double z; + private double m; + private boolean hasZ; + private boolean hasM; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/dto/CurrentlyAtDto.java b/src/main/java/com/snp/batch/jobs/batch/movement/dto/CurrentlyAtDto.java new file mode 100644 index 0000000..22d2728 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/dto/CurrentlyAtDto.java @@ -0,0 +1,36 @@ +package com.snp.batch.jobs.batch.movement.dto; + +import lombok.Data; + +@Data +public class CurrentlyAtDto { + private String movementType; + private String imolRorIHSNumber; + private String movementDate; + + private Integer portCallId; + + private Integer facilityId; + private String facilityName; + private String facilityType; + + private Integer subFacilityId; + private String subFacilityName; + private String subFacilityType; + + private Integer parentFacilityId; + private String parentFacilityName; + private String parentFacilityType; + + private String countryCode; + private String countryName; + + private Double draught; + private Double latitude; + private Double longitude; + + private PortCallsPositionDto position; + + private String destination; + private String iso2; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/dto/CurrentlyAtPositionDto.java b/src/main/java/com/snp/batch/jobs/batch/movement/dto/CurrentlyAtPositionDto.java new file mode 100644 index 0000000..633def4 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/dto/CurrentlyAtPositionDto.java @@ -0,0 +1,17 @@ +package com.snp.batch.jobs.batch.movement.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class CurrentlyAtPositionDto { + private boolean isNull; + private int stSrid; + private double lat; + @JsonProperty("long") + private double lon; + private double z; + private double m; + private boolean hasZ; + private boolean hasM; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/dto/DestinationDto.java b/src/main/java/com/snp/batch/jobs/batch/movement/dto/DestinationDto.java new file mode 100644 index 0000000..b0aaa71 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/dto/DestinationDto.java @@ -0,0 +1,24 @@ +package com.snp.batch.jobs.batch.movement.dto; + +import lombok.Data; + +@Data +public class DestinationDto { + private String movementType; + private String imolRorIHSNumber; + private String movementDate; + + private Integer facilityId; + private String facilityName; + private String facilityType; + + private String countryCode; + private String countryName; + + private Double latitude; + private Double longitude; + + private DestinationPositionDto position; + + private String iso2; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/dto/DestinationPositionDto.java b/src/main/java/com/snp/batch/jobs/batch/movement/dto/DestinationPositionDto.java new file mode 100644 index 0000000..33380b4 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/dto/DestinationPositionDto.java @@ -0,0 +1,17 @@ +package com.snp.batch.jobs.batch.movement.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class DestinationPositionDto { + private boolean isNull; + private int stSrid; + private double lat; + @JsonProperty("long") + private double lon; + private double z; + private double m; + private boolean hasZ; + private boolean hasM; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/dto/PortCallsDto.java b/src/main/java/com/snp/batch/jobs/batch/movement/dto/PortCallsDto.java new file mode 100644 index 0000000..d8134dc --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/dto/PortCallsDto.java @@ -0,0 +1,36 @@ +package com.snp.batch.jobs.batch.movement.dto; + +import lombok.Data; + +@Data +public class PortCallsDto { + private String movementType; + private String imolRorIHSNumber; + private String movementDate; + + private Integer portCallId; + + private Integer facilityId; + private String facilityName; + private String facilityType; + + private Integer subFacilityId; + private String subFacilityName; + private String subFacilityType; + + private Integer parentFacilityId; + private String parentFacilityName; + private String parentFacilityType; + + private String countryCode; + private String countryName; + + private Double draught; + private Double latitude; + private Double longitude; + + private PortCallsPositionDto position; + + private String destination; + private String iso2; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/dto/PortCallsPositionDto.java b/src/main/java/com/snp/batch/jobs/batch/movement/dto/PortCallsPositionDto.java new file mode 100644 index 0000000..ecb13d8 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/dto/PortCallsPositionDto.java @@ -0,0 +1,17 @@ +package com.snp.batch.jobs.batch.movement.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class PortCallsPositionDto { + private boolean isNull; + private int stSrid; + private double lat; + @JsonProperty("long") + private double lon; + private double z; + private double m; + private boolean hasZ; + private boolean hasM; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/dto/ShipMovementApiResponse.java b/src/main/java/com/snp/batch/jobs/batch/movement/dto/ShipMovementApiResponse.java new file mode 100644 index 0000000..c12d407 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/dto/ShipMovementApiResponse.java @@ -0,0 +1,12 @@ +package com.snp.batch.jobs.batch.movement.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; + +@Data +public class ShipMovementApiResponse { + @JsonProperty("portCalls") + List portCallList; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/dto/StsOperationDto.java b/src/main/java/com/snp/batch/jobs/batch/movement/dto/StsOperationDto.java new file mode 100644 index 0000000..4b1c9c4 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/dto/StsOperationDto.java @@ -0,0 +1,34 @@ +package com.snp.batch.jobs.batch.movement.dto; + +import lombok.Data; + +@Data +public class StsOperationDto { + private String movementType; + private String imolRorIHSNumber; + private String movementDate; + + private Integer facilityId; + private String facilityName; + private String facilityType; + + private Integer parentFacilityId; + private String parentFacilityName; + private String parentFacilityType; + + private Double draught; + private Double latitude; + private Double longitude; + + private StsOperationPositionDto position; + + private Long parentCallId; + + private String countryCode; + private String countryName; + + private String stsLocation; + private String stsType; + + private String eventStartDate; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/dto/StsOperationPositionDto.java b/src/main/java/com/snp/batch/jobs/batch/movement/dto/StsOperationPositionDto.java new file mode 100644 index 0000000..d6bbf9c --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/dto/StsOperationPositionDto.java @@ -0,0 +1,17 @@ +package com.snp.batch.jobs.batch.movement.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class StsOperationPositionDto { + private boolean isNull; + private int stSrid; + private double lat; + @JsonProperty("long") + private double lon; + private double z; + private double m; + private boolean hasZ; + private boolean hasM; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/dto/TerminalCallsDto.java b/src/main/java/com/snp/batch/jobs/batch/movement/dto/TerminalCallsDto.java new file mode 100644 index 0000000..6c5cbfe --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/dto/TerminalCallsDto.java @@ -0,0 +1,43 @@ +package com.snp.batch.jobs.batch.movement.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class TerminalCallsDto { + private String movementType; + private String imolRorIHSNumber; + private String movementDate; + + + private Integer facilityId; + private String facilityName; + private String facilityType; + + private Integer parentFacilityId; + private String parentFacilityName; + private String parentFacilityType; + + private String countryCode; + private String countryName; + + private Double draught; + private Double latitude; + private Double longitude; + + private TerminalCallsPositionDto position; + + private Integer parentCallId; + private String iso2; + private String eventStartDate; + + @JsonProperty("subFacilityId") + private Integer subFacilityId; + + @JsonProperty("subFacilityName") + private String subFacilityName; + + @JsonProperty("subFacilityType") + private String subFacilityType; + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/dto/TerminalCallsPositionDto.java b/src/main/java/com/snp/batch/jobs/batch/movement/dto/TerminalCallsPositionDto.java new file mode 100644 index 0000000..33b857d --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/dto/TerminalCallsPositionDto.java @@ -0,0 +1,17 @@ +package com.snp.batch.jobs.batch.movement.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class TerminalCallsPositionDto { + private boolean isNull; + private int stSrid; + private double lat; + @JsonProperty("long") + private double lon; + private double z; + private double m; + private boolean hasZ; + private boolean hasM; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/dto/TransitsDto.java b/src/main/java/com/snp/batch/jobs/batch/movement/dto/TransitsDto.java new file mode 100644 index 0000000..4e613f7 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/dto/TransitsDto.java @@ -0,0 +1,13 @@ +package com.snp.batch.jobs.batch.movement.dto; + +import lombok.Data; + +@Data +public class TransitsDto { + private String movementType; + private String imolRorIHSNumber; + private String movementDate; + private String facilityName; + private String facilityType; + private Double draught; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/entity/AnchorageCallsEntity.java b/src/main/java/com/snp/batch/jobs/batch/movement/entity/AnchorageCallsEntity.java new file mode 100644 index 0000000..8eee6dd --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/entity/AnchorageCallsEntity.java @@ -0,0 +1,46 @@ +package com.snp.batch.jobs.batch.movement.entity; + +import com.fasterxml.jackson.databind.JsonNode; +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDateTime; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class AnchorageCallsEntity extends BaseEntity { + + private Long id; + + private String movementType; + private String imolRorIHSNumber; + private LocalDateTime movementDate; + + private Integer portCallId; + private Integer facilityId; + private String facilityName; + private String facilityType; + + private Integer subFacilityId; + private String subFacilityName; + private String subFacilityType; + + private String countryCode; + private String countryName; + + private Double draught; + private Double latitude; + private Double longitude; + + private JsonNode position; + + private String destination; + private String iso2; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/entity/BerthCallsEntity.java b/src/main/java/com/snp/batch/jobs/batch/movement/entity/BerthCallsEntity.java new file mode 100644 index 0000000..35749a7 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/entity/BerthCallsEntity.java @@ -0,0 +1,46 @@ +package com.snp.batch.jobs.batch.movement.entity; + +import com.fasterxml.jackson.databind.JsonNode; +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDateTime; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class BerthCallsEntity extends BaseEntity { + + private Long id; + + private String movementType; + private String imolRorIHSNumber; + private LocalDateTime movementDate; + + private Integer facilityId; + private String facilityName; + private String facilityType; + + private Integer parentFacilityId; + private String parentFacilityName; + private String parentFacilityType; + + private String countryCode; + private String countryName; + + private Double draught; + private Double latitude; + private Double longitude; + + private JsonNode position; + + private Integer parentCallId; + private String iso2; + private LocalDateTime eventStartDate; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/entity/CurrentlyAtEntity.java b/src/main/java/com/snp/batch/jobs/batch/movement/entity/CurrentlyAtEntity.java new file mode 100644 index 0000000..7ba34cf --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/entity/CurrentlyAtEntity.java @@ -0,0 +1,40 @@ +package com.snp.batch.jobs.batch.movement.entity; + +import com.fasterxml.jackson.databind.JsonNode; +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDateTime; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class CurrentlyAtEntity extends BaseEntity { + private String movementType; + private String imolRorIHSNumber; + private LocalDateTime movementDate; + private Integer portCallId; + private Integer facilityId; + private String facilityName; + private String facilityType; + private Integer subFacilityId; + private String subFacilityName; + private String subFacilityType; + private Integer parentFacilityId; + private String parentFacilityName; + private String parentFacilityType; + private String countryCode; + private String countryName; + private Double draught; + private Double latitude; + private Double longitude; + private String destination; + private String iso2; + private JsonNode position; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/entity/DestinationEntity.java b/src/main/java/com/snp/batch/jobs/batch/movement/entity/DestinationEntity.java new file mode 100644 index 0000000..7ed8a3d --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/entity/DestinationEntity.java @@ -0,0 +1,35 @@ +package com.snp.batch.jobs.batch.movement.entity; + +import com.fasterxml.jackson.databind.JsonNode; +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDateTime; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class DestinationEntity extends BaseEntity { + private String movementType; + private String imolRorIHSNumber; + private LocalDateTime movementDate; + + private Integer facilityId; + private String facilityName; + private String facilityType; + + private String countryCode; + private String countryName; + + private Double latitude; + private Double longitude; + + private JsonNode position; + private String iso2; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/entity/PortCallsEntity.java b/src/main/java/com/snp/batch/jobs/batch/movement/entity/PortCallsEntity.java new file mode 100644 index 0000000..f2735a7 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/entity/PortCallsEntity.java @@ -0,0 +1,42 @@ +package com.snp.batch.jobs.batch.movement.entity; + +import com.fasterxml.jackson.databind.JsonNode; +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDateTime; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class PortCallsEntity extends BaseEntity { + private Long id; + private String movementType; + private String imolRorIHSNumber; + private LocalDateTime movementDate; + private Integer portCallId; + private Integer facilityId; + private String facilityName; + private String facilityType; + private Integer subFacilityId; + private String subFacilityName; + private String subFacilityType; + private Integer parentFacilityId; + private String parentFacilityName; + private String parentFacilityType; + private String countryCode; + private String countryName; + private Double draught; + private Double latitude; + private Double longitude; + private String destination; + private String iso2; + private JsonNode position; + private String schemaType; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/entity/StsOperationEntity.java b/src/main/java/com/snp/batch/jobs/batch/movement/entity/StsOperationEntity.java new file mode 100644 index 0000000..c645a27 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/entity/StsOperationEntity.java @@ -0,0 +1,48 @@ +package com.snp.batch.jobs.batch.movement.entity; + +import com.fasterxml.jackson.databind.JsonNode; +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDateTime; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class StsOperationEntity extends BaseEntity { + + private Long id; + + private String movementType; + private String imolRorIHSNumber; + private java.time.LocalDateTime movementDate; + + private Integer facilityId; + private String facilityName; + private String facilityType; + + private Integer parentFacilityId; + private String parentFacilityName; + private String parentFacilityType; + + private Double draught; + private Double latitude; + private Double longitude; + + private JsonNode position; + + private Long parentCallId; + + private String countryCode; + private String countryName; + + private String stsLocation; + private String stsType; + private LocalDateTime eventStartDate; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/entity/TerminalCallsEntity.java b/src/main/java/com/snp/batch/jobs/batch/movement/entity/TerminalCallsEntity.java new file mode 100644 index 0000000..0211cb8 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/entity/TerminalCallsEntity.java @@ -0,0 +1,51 @@ +package com.snp.batch.jobs.batch.movement.entity; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDateTime; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class TerminalCallsEntity extends BaseEntity { + + private Long id; + + private String movementType; + private String imolRorIHSNumber; + private LocalDateTime movementDate; + + private Integer facilityId; + private String facilityName; + private String facilityType; + + private Integer parentFacilityId; + private String parentFacilityName; + private String parentFacilityType; + + private String countryCode; + private String countryName; + + private Double draught; + private Double latitude; + private Double longitude; + + private JsonNode position; + + private Integer parentCallId; + private String iso2; + private LocalDateTime eventStartDate; + + private Integer subFacilityId; + private String subFacilityName; + private String subFacilityType; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/entity/TransitsEntity.java b/src/main/java/com/snp/batch/jobs/batch/movement/entity/TransitsEntity.java new file mode 100644 index 0000000..2d3ef25 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/entity/TransitsEntity.java @@ -0,0 +1,24 @@ +package com.snp.batch.jobs.batch.movement.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDateTime; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class TransitsEntity extends BaseEntity { + private String movementType; + private String imolRorIHSNumber; + private LocalDateTime movementDate; + private String facilityName; + private String facilityType; + private Double draught; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/processor/AnchorageCallsProcessor.java b/src/main/java/com/snp/batch/jobs/batch/movement/processor/AnchorageCallsProcessor.java new file mode 100644 index 0000000..4174813 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/processor/AnchorageCallsProcessor.java @@ -0,0 +1,65 @@ +package com.snp.batch.jobs.batch.movement.processor; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.processor.BaseProcessor; +import com.snp.batch.jobs.batch.movement.dto.AnchorageCallsDto; +import com.snp.batch.jobs.batch.movement.entity.AnchorageCallsEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +@Slf4j +@Component +public class AnchorageCallsProcessor extends BaseProcessor { + + private final ObjectMapper objectMapper; + private final Long jobExecutionId; + public AnchorageCallsProcessor( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, + ObjectMapper objectMapper) + { + this.jobExecutionId = jobExecutionId; + this.objectMapper = objectMapper; + } + + @Override + protected AnchorageCallsEntity processItem(AnchorageCallsDto dto) throws Exception { + log.debug("AnchorageCalls 정보 처리 시작: imoNumber={}, facilityName={}", + dto.getImolRorIHSNumber(), dto.getFacilityName()); + + JsonNode positionNode = null; + if (dto.getPosition() != null) { + // Position 객체를 JsonNode로 변환 + positionNode = objectMapper.valueToTree(dto.getPosition()); + } + + AnchorageCallsEntity entity = AnchorageCallsEntity.builder() + .movementType(dto.getMovementType()) + .imolRorIHSNumber(dto.getImolRorIHSNumber()) + .movementDate(LocalDateTime.parse(dto.getMovementDate())) + .portCallId(dto.getPortCallId()) + .facilityId(dto.getFacilityId()) + .facilityName(dto.getFacilityName()) + .facilityType(dto.getFacilityType()) + .subFacilityId(dto.getSubFacilityId()) + .subFacilityName(dto.getSubFacilityName()) + .subFacilityType(dto.getSubFacilityType()) + .countryCode(dto.getCountryCode()) + .countryName(dto.getCountryName()) + .draught(dto.getDraught()) + .latitude(dto.getLatitude()) + .longitude(dto.getLongitude()) + .destination(dto.getDestination()) + .iso2(dto.getIso2()) + .position(positionNode) // JsonNode로 매핑 + .jobExecutionId(jobExecutionId) + .createdBy("SYSTEM") + .build(); + + return entity; + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/processor/BerthCallsProcessor.java b/src/main/java/com/snp/batch/jobs/batch/movement/processor/BerthCallsProcessor.java new file mode 100644 index 0000000..7225e2b --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/processor/BerthCallsProcessor.java @@ -0,0 +1,66 @@ +package com.snp.batch.jobs.batch.movement.processor; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.processor.BaseProcessor; +import com.snp.batch.jobs.batch.movement.dto.BerthCallsDto; +import com.snp.batch.jobs.batch.movement.entity.BerthCallsEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +@Slf4j +@Component +public class BerthCallsProcessor extends BaseProcessor { + + private final ObjectMapper objectMapper; + private final Long jobExecutionId; + + public BerthCallsProcessor( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, + ObjectMapper objectMapper) + { + this.jobExecutionId = jobExecutionId; + this.objectMapper = objectMapper; + } + + @Override + protected BerthCallsEntity processItem(BerthCallsDto dto) throws Exception { + log.debug("BerthCalls 정보 처리 시작: imoNumber={}, facilityName={}", + dto.getImolRorIHSNumber(), dto.getFacilityName()); + + JsonNode positionNode = null; + if (dto.getPosition() != null) { + // Position 객체를 JsonNode로 변환 + positionNode = objectMapper.valueToTree(dto.getPosition()); + } + + BerthCallsEntity entity = BerthCallsEntity.builder() + .movementType(dto.getMovementType()) + .imolRorIHSNumber(dto.getImolRorIHSNumber()) + .movementDate(LocalDateTime.parse(dto.getMovementDate())) + .facilityId(dto.getFacilityId()) + .facilityName(dto.getFacilityName()) + .facilityType(dto.getFacilityType()) + .parentFacilityId(dto.getParentFacilityId()) + .parentFacilityName(dto.getParentFacilityName()) + .parentFacilityType(dto.getParentFacilityType()) + .countryCode(dto.getCountryCode()) + .countryName(dto.getCountryName()) + .draught(dto.getDraught()) + .latitude(dto.getLatitude()) + .longitude(dto.getLongitude()) + .position(positionNode) // JsonNode로 매핑 + .parentCallId(dto.getParentCallId()) + .iso2(dto.getIso2()) + .eventStartDate(LocalDateTime.parse(dto.getEventStartDate())) + .jobExecutionId(jobExecutionId) + .createdBy("SYSTEM") + .build(); + + return entity; + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/processor/CurrentlyAtProcessor.java b/src/main/java/com/snp/batch/jobs/batch/movement/processor/CurrentlyAtProcessor.java new file mode 100644 index 0000000..8cf0a11 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/processor/CurrentlyAtProcessor.java @@ -0,0 +1,69 @@ +package com.snp.batch.jobs.batch.movement.processor; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.processor.BaseProcessor; +import com.snp.batch.jobs.batch.movement.dto.CurrentlyAtDto; +import com.snp.batch.jobs.batch.movement.entity.CurrentlyAtEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +@Slf4j +@Component +public class CurrentlyAtProcessor extends BaseProcessor { + + private final ObjectMapper objectMapper; + private final Long jobExecutionId; + + public CurrentlyAtProcessor( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, + ObjectMapper objectMapper) + { + this.jobExecutionId = jobExecutionId; + this.objectMapper = objectMapper; + } + + @Override + protected CurrentlyAtEntity processItem(CurrentlyAtDto dto) throws Exception { + log.debug("CurrentlyAt 정보 처리 시작: imoNumber={}, facilityName={}", + dto.getImolRorIHSNumber(), dto.getFacilityName()); + + JsonNode positionNode = null; + if (dto.getPosition() != null) { + // Position 객체를 JsonNode로 변환 + positionNode = objectMapper.valueToTree(dto.getPosition()); + } + + CurrentlyAtEntity entity = CurrentlyAtEntity.builder() + .movementType(dto.getMovementType()) + .imolRorIHSNumber(dto.getImolRorIHSNumber()) + .movementDate(LocalDateTime.parse(dto.getMovementDate())) + .portCallId(dto.getPortCallId()) + .facilityId(dto.getFacilityId()) + .facilityName(dto.getFacilityName()) + .facilityType(dto.getFacilityType()) + .subFacilityId(dto.getSubFacilityId()) + .subFacilityName(dto.getSubFacilityName()) + .subFacilityType(dto.getSubFacilityType()) + .parentFacilityId(dto.getParentFacilityId()) + .parentFacilityName(dto.getParentFacilityName()) + .parentFacilityType(dto.getParentFacilityType()) + .countryCode(dto.getCountryCode()) + .countryName(dto.getCountryName()) + .draught(dto.getDraught()) + .latitude(dto.getLatitude()) + .longitude(dto.getLongitude()) + .destination(dto.getDestination()) + .iso2(dto.getIso2()) + .position(positionNode) // JsonNode로 매핑 + .jobExecutionId(jobExecutionId) + .createdBy("SYSTEM") + .build(); + + return entity; + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/processor/DestinationProcessor.java b/src/main/java/com/snp/batch/jobs/batch/movement/processor/DestinationProcessor.java new file mode 100644 index 0000000..763c167 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/processor/DestinationProcessor.java @@ -0,0 +1,58 @@ +package com.snp.batch.jobs.batch.movement.processor; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.processor.BaseProcessor; +import com.snp.batch.jobs.batch.movement.dto.DestinationDto; +import com.snp.batch.jobs.batch.movement.entity.DestinationEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +@Slf4j +@Component +public class DestinationProcessor extends BaseProcessor { + + private final ObjectMapper objectMapper; + private final Long jobExecutionId; + public DestinationProcessor( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, + ObjectMapper objectMapper) + { + this.jobExecutionId = jobExecutionId; + this.objectMapper = objectMapper; + } + + @Override + protected DestinationEntity processItem(DestinationDto dto) throws Exception { + log.debug("Destinations 정보 처리 시작: imoNumber={}, facilityName={}", + dto.getImolRorIHSNumber(), dto.getFacilityName()); + + JsonNode positionNode = null; + if (dto.getPosition() != null) { + // Position 객체를 JsonNode로 변환 + positionNode = objectMapper.valueToTree(dto.getPosition()); + } + + DestinationEntity entity = DestinationEntity.builder() + .movementType(dto.getMovementType()) + .imolRorIHSNumber(dto.getImolRorIHSNumber()) + .movementDate(LocalDateTime.parse(dto.getMovementDate())) + .facilityId(dto.getFacilityId()) + .facilityName(dto.getFacilityName()) + .facilityType(dto.getFacilityType()) + .countryCode(dto.getCountryCode()) + .countryName(dto.getCountryName()) + .latitude(dto.getLatitude()) + .longitude(dto.getLongitude()) + .position(positionNode) // JsonNode로 매핑 + .iso2(dto.getIso2()) + .jobExecutionId(jobExecutionId) + .createdBy("SYSTEM") + .build(); + return entity; + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/processor/PortCallsProcessor.java b/src/main/java/com/snp/batch/jobs/batch/movement/processor/PortCallsProcessor.java new file mode 100644 index 0000000..c767899 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/processor/PortCallsProcessor.java @@ -0,0 +1,68 @@ +package com.snp.batch.jobs.batch.movement.processor; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.processor.BaseProcessor; +import com.snp.batch.jobs.batch.movement.dto.PortCallsDto; +import com.snp.batch.jobs.batch.movement.entity.PortCallsEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +@Slf4j +@Component +public class PortCallsProcessor extends BaseProcessor { + + private final ObjectMapper objectMapper; + private final Long jobExecutionId; + public PortCallsProcessor( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, + ObjectMapper objectMapper) + { + this.jobExecutionId = jobExecutionId; + this.objectMapper = objectMapper; + } + + @Override + protected PortCallsEntity processItem(PortCallsDto dto) throws Exception { + log.debug("PortCalls 정보 처리 시작: imoNumber={}, facilityName={}", + dto.getImolRorIHSNumber(), dto.getFacilityName()); + + JsonNode positionNode = null; + if (dto.getPosition() != null) { + // Position 객체를 JsonNode로 변환 + positionNode = objectMapper.valueToTree(dto.getPosition()); + } + + PortCallsEntity entity = PortCallsEntity.builder() + .movementType(dto.getMovementType()) + .imolRorIHSNumber(dto.getImolRorIHSNumber()) + .movementDate(LocalDateTime.parse(dto.getMovementDate())) + .portCallId(dto.getPortCallId()) + .facilityId(dto.getFacilityId()) + .facilityName(dto.getFacilityName()) + .facilityType(dto.getFacilityType()) + .subFacilityId(dto.getSubFacilityId()) + .subFacilityName(dto.getSubFacilityName()) + .subFacilityType(dto.getSubFacilityType()) + .parentFacilityId(dto.getParentFacilityId()) + .parentFacilityName(dto.getParentFacilityName()) + .parentFacilityType(dto.getParentFacilityType()) + .countryCode(dto.getCountryCode()) + .countryName(dto.getCountryName()) + .draught(dto.getDraught()) + .latitude(dto.getLatitude()) + .longitude(dto.getLongitude()) + .destination(dto.getDestination()) + .iso2(dto.getIso2()) + .position(positionNode) // JsonNode로 매핑 + .jobExecutionId(jobExecutionId) + .createdBy("SYSTEM") + .build(); + + return entity; + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/processor/StsOperationProcessor.java b/src/main/java/com/snp/batch/jobs/batch/movement/processor/StsOperationProcessor.java new file mode 100644 index 0000000..020f56d --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/processor/StsOperationProcessor.java @@ -0,0 +1,66 @@ +package com.snp.batch.jobs.batch.movement.processor; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.processor.BaseProcessor; +import com.snp.batch.jobs.batch.movement.dto.StsOperationDto; +import com.snp.batch.jobs.batch.movement.entity.StsOperationEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +@Slf4j +@Component +public class StsOperationProcessor extends BaseProcessor { + + private final ObjectMapper objectMapper; + private final Long jobExecutionId; + public StsOperationProcessor( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, + ObjectMapper objectMapper) + { + this.jobExecutionId = jobExecutionId; + this.objectMapper = objectMapper; + } + + @Override + protected StsOperationEntity processItem(StsOperationDto dto) throws Exception { + log.debug("StsOperations 정보 처리 시작: imoNumber={}, facilityName={}", + dto.getImolRorIHSNumber(), dto.getFacilityName()); + + JsonNode positionNode = null; + if (dto.getPosition() != null) { + // Position 객체를 JsonNode로 변환 + positionNode = objectMapper.valueToTree(dto.getPosition()); + } + + StsOperationEntity entity = StsOperationEntity.builder() + .movementType(dto.getMovementType()) + .imolRorIHSNumber(dto.getImolRorIHSNumber()) + .movementDate(LocalDateTime.parse(dto.getMovementDate())) + .facilityId(dto.getFacilityId()) + .facilityName(dto.getFacilityName()) + .facilityType(dto.getFacilityType()) + .parentFacilityId(dto.getParentFacilityId()) + .parentFacilityName(dto.getParentFacilityName()) + .parentFacilityType(dto.getParentFacilityType()) + .draught(dto.getDraught()) + .latitude(dto.getLatitude()) + .longitude(dto.getLongitude()) + .position(positionNode) // JsonNode로 매핑 + .parentCallId(dto.getParentCallId()) + .countryCode(dto.getCountryCode()) + .countryName(dto.getCountryName()) + .stsLocation(dto.getStsLocation()) + .stsType(dto.getStsType()) + .eventStartDate(LocalDateTime.parse(dto.getEventStartDate())) + .jobExecutionId(jobExecutionId) + .createdBy("SYSTEM") + .build(); + + return entity; + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/processor/TerminalCallsProcessor.java b/src/main/java/com/snp/batch/jobs/batch/movement/processor/TerminalCallsProcessor.java new file mode 100644 index 0000000..c8b686f --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/processor/TerminalCallsProcessor.java @@ -0,0 +1,69 @@ +package com.snp.batch.jobs.batch.movement.processor; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.processor.BaseProcessor; +import com.snp.batch.jobs.batch.movement.dto.TerminalCallsDto; +import com.snp.batch.jobs.batch.movement.entity.TerminalCallsEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +@Slf4j +@Component +public class TerminalCallsProcessor extends BaseProcessor { + + private final ObjectMapper objectMapper; + private final Long jobExecutionId; + + public TerminalCallsProcessor( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, + ObjectMapper objectMapper) + { + this.jobExecutionId = jobExecutionId; + this.objectMapper = objectMapper; + } + + @Override + protected TerminalCallsEntity processItem(TerminalCallsDto dto) throws Exception { + log.debug("TerminalCalls 정보 처리 시작: imoNumber={}, facilityName={}", + dto.getImolRorIHSNumber(), dto.getFacilityName()); + + JsonNode positionNode = null; + if (dto.getPosition() != null) { + // Position 객체를 JsonNode로 변환 + positionNode = objectMapper.valueToTree(dto.getPosition()); + } + + TerminalCallsEntity entity = TerminalCallsEntity.builder() + .movementType(dto.getMovementType()) + .imolRorIHSNumber(dto.getImolRorIHSNumber()) + .movementDate(LocalDateTime.parse(dto.getMovementDate())) + .facilityId(dto.getFacilityId()) + .facilityName(dto.getFacilityName()) + .facilityType(dto.getFacilityType()) + .parentFacilityId(dto.getParentFacilityId()) + .parentFacilityName(dto.getParentFacilityName()) + .parentFacilityType(dto.getParentFacilityType()) + .countryCode(dto.getCountryCode()) + .countryName(dto.getCountryName()) + .draught(dto.getDraught()) + .latitude(dto.getLatitude()) + .longitude(dto.getLongitude()) + .position(positionNode) // JsonNode로 매핑 + .parentCallId(dto.getParentCallId()) + .iso2(dto.getIso2()) + .eventStartDate(LocalDateTime.parse(dto.getEventStartDate())) + .subFacilityId(dto.getSubFacilityId()) + .subFacilityName(dto.getSubFacilityName()) + .subFacilityType(dto.getSubFacilityType()) + .jobExecutionId(jobExecutionId) + .createdBy("SYSTEM") + .build(); + + return entity; + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/processor/TransitsProcessor.java b/src/main/java/com/snp/batch/jobs/batch/movement/processor/TransitsProcessor.java new file mode 100644 index 0000000..059bbe9 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/processor/TransitsProcessor.java @@ -0,0 +1,43 @@ +package com.snp.batch.jobs.batch.movement.processor; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.processor.BaseProcessor; +import com.snp.batch.jobs.batch.movement.dto.TransitsDto; +import com.snp.batch.jobs.batch.movement.entity.TransitsEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +@Slf4j +@Component +public class TransitsProcessor extends BaseProcessor { + + private final Long jobExecutionId; + + public TransitsProcessor( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId) + { + this.jobExecutionId = jobExecutionId; + } + + @Override + protected TransitsEntity processItem(TransitsDto dto) throws Exception { + log.debug("Transits 정보 처리 시작: imoNumber={}, facilityName={}", + dto.getImolRorIHSNumber(), dto.getFacilityName()); + + TransitsEntity entity = TransitsEntity.builder() + .movementType(dto.getMovementType()) + .imolRorIHSNumber(dto.getImolRorIHSNumber()) + .movementDate(LocalDateTime.parse(dto.getMovementDate())) + .facilityName(dto.getFacilityName()) + .facilityType(dto.getFacilityType()) + .draught(dto.getDraught()) + .jobExecutionId(jobExecutionId) + .createdBy("SYSTEM") + .build(); + return entity; + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/reader/AnchorageCallsRangeReader.java b/src/main/java/com/snp/batch/jobs/batch/movement/reader/AnchorageCallsRangeReader.java new file mode 100644 index 0000000..82fc637 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/reader/AnchorageCallsRangeReader.java @@ -0,0 +1,113 @@ +package com.snp.batch.jobs.batch.movement.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.batch.movement.dto.AnchorageCallsDto; +import com.snp.batch.service.BatchApiLogService; +import com.snp.batch.service.BatchDateService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.List; +import java.util.Map; + +@Slf4j +@StepScope +public class AnchorageCallsRangeReader extends BaseApiReader { + private final BatchDateService batchDateService; // ✨ BatchDateService 필드 추가 + private final BatchApiLogService batchApiLogService; + private final String maritimeServiceApiUrl; + private List allData; + private int currentBatchIndex = 0; + private final int batchSize = 5000; + + public AnchorageCallsRangeReader(WebClient webClient, BatchDateService batchDateService, BatchApiLogService batchApiLogService, String maritimeServiceApiUrl) { + super(webClient); + this.batchDateService = batchDateService; + this.batchApiLogService = batchApiLogService; + this.maritimeServiceApiUrl = maritimeServiceApiUrl; + enableChunkMode(); + } + + @Override + protected String getReaderName() { + return "AnchorageCallsRangeReader"; + } + @Override + protected String getApiPath() { + return "/Movements/AnchorageCalls"; + } + + protected String getApiKey() { + return "ANCHORAGE_CALLS_IMPORT_API"; + } + + @Override + protected void resetCustomState() { + this.currentBatchIndex = 0; + this.allData = null; + } + + @Override + protected List fetchNextBatch() throws Exception { + // 1) 처음 호출이면 API 한 번 호출해서 전체 데이터를 가져온다 + if (allData == null) { + allData = callApiWithBatch(); + + if (allData == null || allData.isEmpty()) { + log.warn("[{}] 조회된 데이터 없음 → 종료", getReaderName()); + return null; + } + + log.info("[{}] 총 {}건 데이터 조회됨. batchSize = {}", getReaderName(), allData.size(), batchSize); + } + + // 2) 이미 끝까지 읽었으면 종료 + if (currentBatchIndex >= allData.size()) { + log.info("[{}] 모든 배치 처리 완료", getReaderName()); + return null; + } + + // 3) 이번 배치의 end 계산 + int end = Math.min(currentBatchIndex + batchSize, allData.size()); + + // 4) 현재 batch 리스트 잘라서 반환 + List batch = allData.subList(currentBatchIndex, end); + + int batchNum = (currentBatchIndex / batchSize) + 1; + int totalBatches = (int) Math.ceil((double) allData.size() / batchSize); + + log.info("[{}] 배치 {}/{} 처리 중: {}건", getReaderName(), batchNum, totalBatches, batch.size()); + + // 다음 batch 인덱스 이동 + currentBatchIndex = end; + updateApiCallStats(totalBatches, batchNum); + + return batch; + } + + /** + * Query Parameter를 사용한 API 호출 + * @return API 응답 + */ + private List callApiWithBatch() { + Map params = batchDateService.getDateRangeWithTimezoneParams(getApiKey(), "startDate", "stopDate"); + return executeListApiCall( + maritimeServiceApiUrl, + getApiPath(), + params, + new ParameterizedTypeReference>() {}, + batchApiLogService + ); + } + + @Override + protected void afterFetch(List data) { + if (data == null) { + int totalBatches = (int) Math.ceil((double) allData.size() / batchSize); + log.info("[{}] 전체 {} 개 배치 처리 완료", getReaderName(), totalBatches); + } + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/reader/BerthCallsRangeReader.java b/src/main/java/com/snp/batch/jobs/batch/movement/reader/BerthCallsRangeReader.java new file mode 100644 index 0000000..ecccd69 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/reader/BerthCallsRangeReader.java @@ -0,0 +1,112 @@ +package com.snp.batch.jobs.batch.movement.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.batch.movement.dto.BerthCallsDto; +import com.snp.batch.service.BatchApiLogService; +import com.snp.batch.service.BatchDateService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.List; +import java.util.Map; + +@Slf4j +@StepScope +public class BerthCallsRangeReader extends BaseApiReader { + private final BatchDateService batchDateService; // ✨ BatchDateService 필드 추가 + private final BatchApiLogService batchApiLogService; + private final String maritimeServiceApiUrl; + private List allData; + private int currentBatchIndex = 0; + private final int batchSize = 5000; + public BerthCallsRangeReader(WebClient webClient, BatchDateService batchDateService, BatchApiLogService batchApiLogService, String maritimeServiceApiUrl) { + super(webClient); + this.batchDateService = batchDateService; + this.batchApiLogService = batchApiLogService; + this.maritimeServiceApiUrl = maritimeServiceApiUrl; + enableChunkMode(); + } + + @Override + protected String getReaderName() { + return "BerthCallsRangeReader"; + } + @Override + protected String getApiPath() { + return "/Movements/BerthCalls"; + } + + protected String getApiKey() { + return "BERTH_CALLS_IMPORT_API"; + } + + @Override + protected void resetCustomState() { + this.currentBatchIndex = 0; + this.allData = null; + } + + @Override + protected List fetchNextBatch() throws Exception { + // 1) 처음 호출이면 API 한 번 호출해서 전체 데이터를 가져온다 + if (allData == null) { + allData = callApiWithBatch(); + + if (allData == null || allData.isEmpty()) { + log.warn("[{}] 조회된 데이터 없음 → 종료", getReaderName()); + return null; + } + + log.info("[{}] 총 {}건 데이터 조회됨. batchSize = {}", getReaderName(), allData.size(), batchSize); + } + + // 2) 이미 끝까지 읽었으면 종료 + if (currentBatchIndex >= allData.size()) { + log.info("[{}] 모든 배치 처리 완료", getReaderName()); + return null; + } + + // 3) 이번 배치의 end 계산 + int end = Math.min(currentBatchIndex + batchSize, allData.size()); + + // 4) 현재 batch 리스트 잘라서 반환 + List batch = allData.subList(currentBatchIndex, end); + + int currentBatchNumber = (currentBatchIndex / batchSize) + 1; + int totalBatches = (int) Math.ceil((double) allData.size() / batchSize); + + log.info("[{}] 배치 {}/{} 처리 중: {}건", getReaderName(), currentBatchNumber, totalBatches, batch.size()); + + // 다음 batch 인덱스 이동 + currentBatchIndex = end; + updateApiCallStats(totalBatches, currentBatchNumber); + return batch; + + } + + /** + * Query Parameter를 사용한 API 호출 + * @return API 응답 + */ + private List callApiWithBatch() { + Map params = batchDateService.getDateRangeWithTimezoneParams(getApiKey(), "startDate", "stopDate"); + return executeListApiCall( + maritimeServiceApiUrl, + getApiPath(), + params, + new ParameterizedTypeReference>() {}, + batchApiLogService + ); + } + + @Override + protected void afterFetch(List data) { + if (data == null) { + int totalBatches = (int) Math.ceil((double) allData.size() / batchSize); + log.info("[{}] 전체 {} 개 배치 처리 완료", getReaderName(), totalBatches); + } + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/reader/CurrentlyAtRangeReader.java b/src/main/java/com/snp/batch/jobs/batch/movement/reader/CurrentlyAtRangeReader.java new file mode 100644 index 0000000..bc28678 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/reader/CurrentlyAtRangeReader.java @@ -0,0 +1,111 @@ +package com.snp.batch.jobs.batch.movement.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.batch.movement.dto.CurrentlyAtDto; +import com.snp.batch.service.BatchApiLogService; +import com.snp.batch.service.BatchDateService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.List; +import java.util.Map; + +@Slf4j +@StepScope +public class CurrentlyAtRangeReader extends BaseApiReader { + private final BatchDateService batchDateService; // ✨ BatchDateService 필드 추가 + private final BatchApiLogService batchApiLogService; + private final String maritimeServiceApiUrl; + private List allData; + private int currentBatchIndex = 0; + private final int batchSize = 5000; + public CurrentlyAtRangeReader(WebClient webClient, BatchDateService batchDateService, BatchApiLogService batchApiLogService, String maritimeServiceApiUrl) { + super(webClient); + this.batchDateService = batchDateService; + this.batchApiLogService = batchApiLogService; + this.maritimeServiceApiUrl = maritimeServiceApiUrl; + enableChunkMode(); + } + @Override + protected String getReaderName() { + return "CurrentlyAtRangeReader"; + } + @Override + protected String getApiPath() { + return "/Movements/CurrentlyAt"; + } + + protected String getApiKey() { + return "CURRENTLY_AT_IMPORT_API"; + } + + @Override + protected void resetCustomState() { + this.currentBatchIndex = 0; + this.allData = null; + } + + @Override + protected List fetchNextBatch() throws Exception { + + // 모든 배치 처리 완료 확인 + if (allData == null ) { + allData = callApiWithBatch(); + + if (allData == null || allData.isEmpty()) { + log.warn("[{}] 조회된 데이터 없음 → 종료", getReaderName()); + return null; + } + + log.info("[{}] 총 {}건 데이터 조회됨. batchSize = {}", getReaderName(), allData.size(), batchSize); + } + + // 2) 이미 끝까지 읽었으면 종료 + if (currentBatchIndex >= allData.size()) { + log.info("[{}] 모든 배치 처리 완료", getReaderName()); + return null; + } + + // 3) 이번 배치의 end 계산 + int endIndex = Math.min(currentBatchIndex + batchSize, allData.size()); + + // 현재 배치의 IMO 번호 추출 (100개) + List batch = allData.subList(currentBatchIndex, endIndex); + + int currentBatchNumber = (currentBatchIndex / batchSize) + 1; + int totalBatches = (int) Math.ceil((double) allData.size() / batchSize); + + log.info("[{}] 배치 {}/{} 처리 중: {}건", getReaderName(), currentBatchNumber, totalBatches, batch.size()); + + currentBatchIndex = endIndex; + updateApiCallStats(totalBatches, currentBatchNumber); + return batch; + + } + + /** + * Query Parameter를 사용한 API 호출 + * @return API 응답 + */ + private List callApiWithBatch() { + Map params = batchDateService.getDateRangeWithTimezoneParams(getApiKey(), "startDate", "stopDate"); + return executeListApiCall( + maritimeServiceApiUrl, + getApiPath(), + params, + new ParameterizedTypeReference>() {}, + batchApiLogService + ); + } + + @Override + protected void afterFetch(List data) { + if (data == null) { + int totalBatches = (int) Math.ceil((double) allData.size() / batchSize); + log.info("[{}] 전체 {} 개 배치 처리 완료", getReaderName(), totalBatches); + } + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/reader/DestinationRangeReader.java b/src/main/java/com/snp/batch/jobs/batch/movement/reader/DestinationRangeReader.java new file mode 100644 index 0000000..de8f560 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/reader/DestinationRangeReader.java @@ -0,0 +1,109 @@ +package com.snp.batch.jobs.batch.movement.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.batch.movement.dto.DestinationDto; +import com.snp.batch.service.BatchApiLogService; +import com.snp.batch.service.BatchDateService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.List; +import java.util.Map; + +@Slf4j +@StepScope +public class DestinationRangeReader extends BaseApiReader { + private final BatchDateService batchDateService; // ✨ BatchDateService 필드 추가 + private final BatchApiLogService batchApiLogService; + private final String maritimeServiceApiUrl; + private List allData; + private int currentBatchIndex = 0; + private final int batchSize = 5000; + public DestinationRangeReader(WebClient webClient, BatchDateService batchDateService, BatchApiLogService batchApiLogService, String maritimeServiceApiUrl) { + super(webClient); + this.batchDateService = batchDateService; + this.batchApiLogService = batchApiLogService; + this.maritimeServiceApiUrl = maritimeServiceApiUrl; + enableChunkMode(); + } + @Override + protected String getReaderName() { + return "DestinationRangeReader"; + } + @Override + protected String getApiPath() { + return "/Movements/Destinations"; + } + + protected String getApiKey() { + return "DESTINATIONS_IMPORT_API"; + } + + @Override + protected void resetCustomState() { + this.currentBatchIndex = 0; + this.allData = null; + } + + @Override + protected List fetchNextBatch() throws Exception { + + if (allData == null) { + allData = callApiWithBatch(); + + if (allData == null || allData.isEmpty()) { + log.warn("[{}] 조회된 데이터 없음 → 종료", getReaderName()); + return null; + } + + log.info("[{}] 총 {}건 데이터 조회됨. batchSize = {}", getReaderName(), allData.size(), batchSize); + } + + // 2) 이미 끝까지 읽었으면 종료 + if (currentBatchIndex >= allData.size()) { + log.info("[{}] 모든 배치 처리 완료", getReaderName()); + return null; + } + + // 3) 이번 배치의 end 계산 + int endIndex = Math.min(currentBatchIndex + batchSize, allData.size()); + + // 현재 배치의 IMO 번호 추출 (100개) + List batch = allData.subList(currentBatchIndex, endIndex); + + int currentBatchNumber = (currentBatchIndex / batchSize) + 1; + int totalBatches = (int) Math.ceil((double) allData.size() / batchSize); + + log.info("[{}] 배치 {}/{} 처리 중: {}건", getReaderName(), currentBatchNumber, totalBatches, batch.size()); + + currentBatchIndex = endIndex; + updateApiCallStats(totalBatches, currentBatchNumber); + return batch; + } + + /** + * Query Parameter를 사용한 API 호출 + * @return API 응답 + */ + private List callApiWithBatch() { + Map params = batchDateService.getDateRangeWithTimezoneParams(getApiKey(), "startDate", "stopDate"); + return executeListApiCall( + maritimeServiceApiUrl, + getApiPath(), + params, + new ParameterizedTypeReference>() {}, + batchApiLogService + ); + } + + @Override + protected void afterFetch(List data) { + if (data == null) { + int totalBatches = (int) Math.ceil((double) allData.size() / batchSize); + log.info("[{}] 전체 {} 개 배치 처리 완료", getReaderName(), totalBatches); + } + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/reader/PortCallsRangeReader.java b/src/main/java/com/snp/batch/jobs/batch/movement/reader/PortCallsRangeReader.java new file mode 100644 index 0000000..9b76f32 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/reader/PortCallsRangeReader.java @@ -0,0 +1,113 @@ +package com.snp.batch.jobs.batch.movement.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.batch.movement.dto.PortCallsDto; +import com.snp.batch.service.BatchApiLogService; +import com.snp.batch.service.BatchDateService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.List; +import java.util.Map; + +@Slf4j +@StepScope +public class PortCallsRangeReader extends BaseApiReader { + private final BatchDateService batchDateService; // ✨ BatchDateService 필드 추가 + private final BatchApiLogService batchApiLogService; + private final String maritimeServiceApiUrl; + private List allData; + private int currentBatchIndex = 0; + private final int batchSize = 5000; + public PortCallsRangeReader(WebClient webClient, BatchDateService batchDateService, BatchApiLogService batchApiLogService, String maritimeServiceApiUrl) { + super(webClient); + this.batchDateService = batchDateService; + this.batchApiLogService = batchApiLogService; + this.maritimeServiceApiUrl = maritimeServiceApiUrl; + enableChunkMode(); + } + + @Override + protected String getReaderName() { + return "PortCallsRangeReader"; + } + @Override + protected String getApiPath() { + return "/Movements/PortCalls"; + } + + protected String getApiKey() { + return "PORT_CALLS_IMPORT_API"; + } + + @Override + protected void resetCustomState() { + this.currentBatchIndex = 0; + this.allData = null; + } + + @Override + protected List fetchNextBatch() throws Exception { + + // 모든 배치 처리 완료 확인 + if (allData == null) { + allData = callApiWithBatch(); + + if (allData == null || allData.isEmpty()) { + log.warn("[{}] 조회된 데이터 없음 → 종료", getReaderName()); + return null; + } + + log.info("[{}] 총 {}건 데이터 조회됨. batchSize = {}", getReaderName(), allData.size(), batchSize); + } + + // 2) 이미 끝까지 읽었으면 종료 + if (currentBatchIndex >= allData.size()) { + log.info("[{}] 모든 배치 처리 완료", getReaderName()); + return null; + } + + // 3) 이번 배치의 end 계산 + int end = Math.min(currentBatchIndex + batchSize, allData.size()); + + // 4) 현재 batch 리스트 잘라서 반환 + List batch = allData.subList(currentBatchIndex, end); + + int batchNum = (currentBatchIndex / batchSize) + 1; + int totalBatches = (int) Math.ceil((double) allData.size() / batchSize); + + log.info("[{}] 배치 {}/{} 처리 중: {}건", getReaderName(), batchNum, totalBatches, batch.size()); + + // 다음 batch 인덱스 이동 + currentBatchIndex = end; + updateApiCallStats(totalBatches, batchNum); + + return batch; + } + + /** + * Query Parameter를 사용한 API 호출 + * @return API 응답 + */ + private List callApiWithBatch() { + Map params = batchDateService.getDateRangeWithTimezoneParams(getApiKey(), "startDate", "stopDate"); + return executeListApiCall( + maritimeServiceApiUrl, + getApiPath(), + params, + new ParameterizedTypeReference>() {}, + batchApiLogService + ); + } + + @Override + protected void afterFetch(List data) { + if (data == null) { + int totalBatches = (int) Math.ceil((double) allData.size() / batchSize); + log.info("[{}] 전체 {} 개 배치 처리 완료", getReaderName(), totalBatches); + } + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/reader/StsOperationRangeReader.java b/src/main/java/com/snp/batch/jobs/batch/movement/reader/StsOperationRangeReader.java new file mode 100644 index 0000000..fd52d8b --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/reader/StsOperationRangeReader.java @@ -0,0 +1,116 @@ +package com.snp.batch.jobs.batch.movement.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.batch.movement.dto.StsOperationDto; +import com.snp.batch.service.BatchApiLogService; +import com.snp.batch.service.BatchDateService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.List; +import java.util.Map; + +@Slf4j +@StepScope +public class StsOperationRangeReader extends BaseApiReader { + private final BatchDateService batchDateService; // ✨ BatchDateService 필드 추가 + private final BatchApiLogService batchApiLogService; + private final String maritimeServiceApiUrl; + private List allData; + private int currentBatchIndex = 0; + private final int batchSize = 5000; + public StsOperationRangeReader(WebClient webClient, BatchDateService batchDateService, BatchApiLogService batchApiLogService, String maritimeServiceApiUrl) { + super(webClient); + this.batchDateService = batchDateService; + this.batchApiLogService = batchApiLogService; + this.maritimeServiceApiUrl = maritimeServiceApiUrl; + enableChunkMode(); + } + + @Override + protected String getReaderName() { + return "StsOperationRangeReader"; + } + @Override + protected String getApiPath() { + return "/Movements/StsOperations"; + } + + protected String getApiKey() { + return "STS_OPERATION_IMPORT_API"; + } + + @Override + protected void resetCustomState() { + this.currentBatchIndex = 0; + this.allData = null; + } + + @Override + protected String getApiBaseUrl() { + return "https://webservices.maritime.spglobal.com"; + } + + @Override + protected List fetchNextBatch() throws Exception { + + // 모든 배치 처리 완료 확인 + if (allData == null ) { + allData = callApiWithBatch(); + + if (allData == null || allData.isEmpty()) { + log.warn("[{}] 조회된 데이터 없음 → 종료", getReaderName()); + return null; + } + + log.info("[{}] 총 {}건 데이터 조회됨. batchSize = {}", getReaderName(), allData.size(), batchSize); + } + + // 2) 이미 끝까지 읽었으면 종료 + if (currentBatchIndex >= allData.size()) { + log.info("[{}] 모든 배치 처리 완료", getReaderName()); + return null; + } + + // 3) 이번 배치의 end 계산 + int endIndex = Math.min(currentBatchIndex + batchSize, allData.size()); + + // 현재 배치의 IMO 번호 추출 (100개) + List batch = allData.subList(currentBatchIndex, endIndex); + + int currentBatchNumber = (currentBatchIndex / batchSize) + 1; + int totalBatches = (int) Math.ceil((double) allData.size() / batchSize); + + log.info("[{}] 배치 {}/{} 처리 중: {}건", getReaderName(), currentBatchNumber, totalBatches, batch.size()); + + currentBatchIndex = endIndex; + updateApiCallStats(totalBatches, currentBatchNumber); + return batch; + } + + /** + * Query Parameter를 사용한 API 호출 + * @return API 응답 + */ + private List callApiWithBatch() { + Map params = batchDateService.getDateRangeWithTimezoneParams(getApiKey(), "startDate", "stopDate"); + return executeListApiCall( + maritimeServiceApiUrl, + getApiPath(), + params, + new ParameterizedTypeReference>() {}, + batchApiLogService + ); + } + + @Override + protected void afterFetch(List data) { + if (data == null) { + int totalBatches = (int) Math.ceil((double) allData.size() / batchSize); + log.info("[{}] 전체 {} 개 배치 처리 완료", getReaderName(), totalBatches); + } + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/reader/TerminalCallsRangeReader.java b/src/main/java/com/snp/batch/jobs/batch/movement/reader/TerminalCallsRangeReader.java new file mode 100644 index 0000000..c388b12 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/reader/TerminalCallsRangeReader.java @@ -0,0 +1,110 @@ +package com.snp.batch.jobs.batch.movement.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.batch.movement.dto.TerminalCallsDto; +import com.snp.batch.service.BatchApiLogService; +import com.snp.batch.service.BatchDateService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.List; +import java.util.Map; + +@Slf4j +@StepScope +public class TerminalCallsRangeReader extends BaseApiReader { + private final BatchDateService batchDateService; // ✨ BatchDateService 필드 추가 + private final BatchApiLogService batchApiLogService; + private final String maritimeServiceApiUrl; + private List allData; + private int currentBatchIndex = 0; + private final int batchSize = 5000; + public TerminalCallsRangeReader(WebClient webClient, BatchDateService batchDateService, BatchApiLogService batchApiLogService, String maritimeServiceApiUrl) { + super(webClient); + this.batchDateService = batchDateService; + this.batchApiLogService = batchApiLogService; + this.maritimeServiceApiUrl = maritimeServiceApiUrl; + enableChunkMode(); + } + @Override + protected String getReaderName() { + return "TerminalCallsRangeReader"; + } + @Override + protected String getApiPath() { + return "/Movements/TerminalCalls"; + } + + protected String getApiKey() { + return "TERMINAL_CALLS_IMPORT_API"; + } + + @Override + protected void resetCustomState() { + this.currentBatchIndex = 0; + this.allData = null; + } + + @Override + protected List fetchNextBatch() throws Exception { + + // 모든 배치 처리 완료 확인 + if (allData == null ) { + allData = callApiWithBatch(); + + if (allData == null || allData.isEmpty()) { + log.warn("[{}] 조회된 데이터 없음 → 종료", getReaderName()); + return null; + } + + log.info("[{}] 총 {}건 데이터 조회됨. batchSize = {}", getReaderName(), allData.size(), batchSize); + } + + // 2) 이미 끝까지 읽었으면 종료 + if (currentBatchIndex >= allData.size()) { + log.info("[{}] 모든 배치 처리 완료", getReaderName()); + return null; + } + + // 3) 이번 배치의 end 계산 + int endIndex = Math.min(currentBatchIndex + batchSize, allData.size()); + + // 현재 배치의 IMO 번호 추출 (100개) + List batch = allData.subList(currentBatchIndex, endIndex); + + int currentBatchNumber = (currentBatchIndex / batchSize) + 1; + int totalBatches = (int) Math.ceil((double) allData.size() / batchSize); + + log.info("[{}] 배치 {}/{} 처리 중: {}건", getReaderName(), currentBatchNumber, totalBatches, batch.size()); + + currentBatchIndex = endIndex; + updateApiCallStats(totalBatches, currentBatchNumber); + return batch; + } + + /** + * Query Parameter를 사용한 API 호출 + * @return API 응답 + */ + private List callApiWithBatch() { + Map params = batchDateService.getDateRangeWithTimezoneParams(getApiKey(), "startDate", "stopDate"); + return executeListApiCall( + maritimeServiceApiUrl, + getApiPath(), + params, + new ParameterizedTypeReference>() {}, + batchApiLogService + ); + } + + @Override + protected void afterFetch(List data) { + if (data == null) { + int totalBatches = (int) Math.ceil((double) allData.size() / batchSize); + log.info("[{}] 전체 {} 개 배치 처리 완료", getReaderName(), totalBatches); + } + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/reader/TransitsRangeReader.java b/src/main/java/com/snp/batch/jobs/batch/movement/reader/TransitsRangeReader.java new file mode 100644 index 0000000..def5cce --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/reader/TransitsRangeReader.java @@ -0,0 +1,118 @@ +package com.snp.batch.jobs.batch.movement.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.batch.movement.dto.AnchorageCallsDto; +import com.snp.batch.jobs.batch.movement.dto.TransitsDto; +import com.snp.batch.service.BatchApiLogService; +import com.snp.batch.service.BatchDateService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.http.HttpStatusCode; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; + +@Slf4j +@StepScope +public class TransitsRangeReader extends BaseApiReader { + private final BatchDateService batchDateService; // ✨ BatchDateService 필드 추가 + private final BatchApiLogService batchApiLogService; + private final String maritimeServiceApiUrl; + private List allData; + private int currentBatchIndex = 0; + private final int batchSize = 5000; + public TransitsRangeReader(WebClient webClient, BatchDateService batchDateService, BatchApiLogService batchApiLogService, String maritimeServiceApiUrl) { + super(webClient); + this.batchDateService = batchDateService; + this.batchApiLogService = batchApiLogService; + this.maritimeServiceApiUrl = maritimeServiceApiUrl; + enableChunkMode(); + } + @Override + protected String getReaderName() { + return "TransitsRangeReader"; + } + @Override + protected String getApiPath() { + return "/Movements/Transits"; + } + + protected String getApiKey() { + return "TRANSITS_IMPORT_API"; + } + + @Override + protected void resetCustomState() { + this.currentBatchIndex = 0; + this.allData = null; + } + + @Override + protected String getApiBaseUrl() { + return "https://webservices.maritime.spglobal.com"; + } + + @Override + protected List fetchNextBatch() throws Exception { + + // 모든 배치 처리 완료 확인 + if (allData == null ) { + allData = callApiWithBatch(); + + if (allData == null || allData.isEmpty()) { + log.warn("[{}] 조회된 데이터 없음 → 종료", getReaderName()); + return null; + } + + log.info("[{}] 총 {}건 데이터 조회됨. batchSize = {}", getReaderName(), allData.size(), batchSize); + } + + // 2) 이미 끝까지 읽었으면 종료 + if (currentBatchIndex >= allData.size()) { + log.info("[{}] 모든 배치 처리 완료", getReaderName()); + return null; + } + + // 3) 이번 배치의 end 계산 + int endIndex = Math.min(currentBatchIndex + batchSize, allData.size()); + + // 현재 배치의 IMO 번호 추출 (100개) + List batch = allData.subList(currentBatchIndex, endIndex); + + int currentBatchNumber = (currentBatchIndex / batchSize) + 1; + int totalBatches = (int) Math.ceil((double) allData.size() / batchSize); + + log.info("[{}] 배치 {}/{} 처리 중: {}건", getReaderName(), currentBatchNumber, totalBatches, batch.size()); + + currentBatchIndex = endIndex; + updateApiCallStats(totalBatches, currentBatchNumber); + return batch; + } + + /** + * Query Parameter를 사용한 API 호출 + * @return API 응답 + */ + private List callApiWithBatch() { + Map params = batchDateService.getDateRangeWithTimezoneParams(getApiKey(), "startDate", "stopDate"); + return executeListApiCall( + maritimeServiceApiUrl, + getApiPath(), + params, + new ParameterizedTypeReference>() {}, + batchApiLogService + ); + } + + @Override + protected void afterFetch(List data) { + if (data == null) { + int totalBatches = (int) Math.ceil((double) allData.size() / batchSize); + log.info("[{}] 전체 {} 개 배치 처리 완료", getReaderName(), totalBatches); + } + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/repository/AnchorageCallsRepository.java b/src/main/java/com/snp/batch/jobs/batch/movement/repository/AnchorageCallsRepository.java new file mode 100644 index 0000000..4a7d613 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/repository/AnchorageCallsRepository.java @@ -0,0 +1,13 @@ +package com.snp.batch.jobs.batch.movement.repository; + +import com.snp.batch.jobs.batch.movement.entity.AnchorageCallsEntity; + +import java.util.List; + +/** + * 선박 상세 정보 Repository 인터페이스 + */ + +public interface AnchorageCallsRepository { + void saveAll(List entities); +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/repository/AnchorageCallsRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/batch/movement/repository/AnchorageCallsRepositoryImpl.java new file mode 100644 index 0000000..8600f22 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/repository/AnchorageCallsRepositoryImpl.java @@ -0,0 +1,142 @@ +package com.snp.batch.jobs.batch.movement.repository; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.batch.movement.entity.AnchorageCallsEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.Timestamp; +import java.sql.Types; +import java.util.List; + +@Slf4j +@Repository("anchorageCallsRepository") +public class AnchorageCallsRepositoryImpl extends BaseJdbcRepository + implements AnchorageCallsRepository { + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + + @Value("${app.batch.target-schema.tables.movements-001}") + private String tableName; + + public AnchorageCallsRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + @Override + protected String getTargetSchema() { + return targetSchema; + } + + @Override + protected String getSimpleTableName() { + return tableName; + } + + @Override + protected String getEntityName() { + return "AnchorageCalls"; + } + + @Override + protected String extractId(AnchorageCallsEntity entity) { + return entity.getImolRorIHSNumber(); + } + + @Override + public String getInsertSql() { + return """ + INSERT INTO %s( + imo_no, + mvmn_type, + mvmn_dt, + prtcll_id, + facility_id, + facility_nm, + facility_type, + lwrnk_facility_id, + lwrnk_facility_desc, + lwrnk_facility_type, + country_cd, + country_nm, + draft, + lat, + lon, + dest, + iso_two_country_cd, + position_info, + job_execution_id, creatr_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + """.formatted(getTableName()); + } + + @Override + protected String getUpdateSql() { + return null; + } + + @Override + protected void setInsertParameters(PreparedStatement ps, AnchorageCallsEntity e) throws Exception { + int i = 1; + ps.setString(i++, e.getImolRorIHSNumber()); // imo + ps.setString(i++, e.getMovementType()); // mvmn_type + ps.setTimestamp(i++, e.getMovementDate() != null ? Timestamp.valueOf(e.getMovementDate()) : null); // mvmn_dt + ps.setObject(i++, e.getPortCallId()); // stpov_id + ps.setObject(i++, e.getFacilityId()); // fclty_id + ps.setString(i++, e.getFacilityName()); // fclty_nm + ps.setString(i++, e.getFacilityType()); // fclty_type + ps.setObject(i++, e.getSubFacilityId()); // lwrnk_fclty_id + ps.setString(i++, e.getSubFacilityName()); // lwrnk_fclty_nm + ps.setString(i++, e.getSubFacilityType()); // lwrnk_fclty_type + ps.setString(i++, e.getCountryCode()); // ntn_cd + ps.setString(i++, e.getCountryName()); // ntn_nm + setDoubleOrNull(ps, i++, e.getDraught()); // draft + setDoubleOrNull(ps, i++, e.getLatitude()); // lat + setDoubleOrNull(ps, i++, e.getLongitude());// lon + ps.setString(i++, e.getDestination()); // dstn + ps.setString(i++, e.getIso2()); // iso2_ntn_cd + + if (e.getPosition() != null) { + ps.setObject(i++, OBJECT_MAPPER.writeValueAsString(e.getPosition()), java.sql.Types.OTHER); // lcinfo (jsonb) + } else { + ps.setNull(i++, java.sql.Types.OTHER); + } + ps.setObject(i++, e.getJobExecutionId(), Types.INTEGER); + ps.setString(i++, e.getCreatedBy()); + + } + + private void setDoubleOrNull(PreparedStatement ps, int index, Double value) throws Exception { + if (value != null) { + ps.setDouble(index, value); + } else { + // java.sql.Types.DOUBLE을 사용하여 명시적으로 SQL NULL을 설정 + ps.setNull(index, java.sql.Types.DOUBLE); + } + } + + @Override + protected void setUpdateParameters(PreparedStatement ps, AnchorageCallsEntity entity) throws Exception { + + } + + @Override + protected RowMapper getRowMapper() { + return null; + } + + @Override + public void saveAll(List entities) { + if (entities == null || entities.isEmpty()) return; + + batchInsert(entities); + + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/repository/BerthCallsRepository.java b/src/main/java/com/snp/batch/jobs/batch/movement/repository/BerthCallsRepository.java new file mode 100644 index 0000000..8914f3a --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/repository/BerthCallsRepository.java @@ -0,0 +1,13 @@ +package com.snp.batch.jobs.batch.movement.repository; + +import com.snp.batch.jobs.batch.movement.entity.BerthCallsEntity; + +import java.util.List; + +/** + * 선박 상세 정보 Repository 인터페이스 + */ + +public interface BerthCallsRepository { + void saveAll(List entities); +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/repository/BerthCallsRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/batch/movement/repository/BerthCallsRepositoryImpl.java new file mode 100644 index 0000000..0c3a39d --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/repository/BerthCallsRepositoryImpl.java @@ -0,0 +1,141 @@ +package com.snp.batch.jobs.batch.movement.repository; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.batch.movement.entity.BerthCallsEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.Timestamp; +import java.sql.Types; +import java.util.List; + +@Slf4j +@Repository("BerthCallsRepository") +public class BerthCallsRepositoryImpl extends BaseJdbcRepository + implements BerthCallsRepository { + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + + @Value("${app.batch.target-schema.tables.movements-002}") + private String tableName; + + public BerthCallsRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + @Override + protected String getTargetSchema() { + return targetSchema; + } + + @Override + protected String getSimpleTableName() { + return tableName; + } + + @Override + protected String getEntityName() { + return "BerthCalls"; + } + + @Override + protected String extractId(BerthCallsEntity entity) { + return entity.getImolRorIHSNumber(); + } + + @Override + public String getInsertSql() { + return """ + INSERT INTO %s( + imo_no, + mvmn_type, + mvmn_dt, + facility_id, + facility_nm, + facility_type, + up_facility_id, + up_facility_nm, + up_facility_type, + country_cd, + country_nm, + draft, + lat, + lon, + up_clot_id, + iso_two_country_cd, + event_sta_dt, + position_info, + job_execution_id, creatr_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + """.formatted(getTableName()); + } + + @Override + protected String getUpdateSql() { + return null; + } + + @Override + protected void setInsertParameters(PreparedStatement ps, BerthCallsEntity e) throws Exception { + int i = 1; + ps.setString(i++, e.getImolRorIHSNumber()); // imo + ps.setString(i++, e.getMovementType()); // mvmn_type + ps.setTimestamp(i++, e.getMovementDate() != null ? Timestamp.valueOf(e.getMovementDate()) : null); // mvmn_dt + ps.setObject(i++, e.getFacilityId()); // fclty_id + ps.setString(i++, e.getFacilityName()); // fclty_nm + ps.setString(i++, e.getFacilityType()); // fclty_type + ps.setObject(i++, e.getParentFacilityId()); //up_fclty_id + ps.setString(i++, e.getParentFacilityName()); // up_fclty_nm + ps.setString(i++, e.getParentFacilityType()); //up_fclty_type + ps.setString(i++, e.getCountryCode()); // ntn_cd + ps.setString(i++, e.getCountryName()); // ntn_nm + setDoubleOrNull(ps, i++, e.getDraught()); // draft + setDoubleOrNull(ps, i++, e.getLatitude()); // lat + setDoubleOrNull(ps, i++, e.getLongitude());// lon + ps.setObject(i++, e.getParentCallId()); //prnt_call_id + ps.setString(i++, e.getIso2()); // iso2_ntn_cd + ps.setTimestamp(i++, e.getEventStartDate() != null ? Timestamp.valueOf(e.getEventStartDate()) : null); // evt_start_dt + + if (e.getPosition() != null) { + ps.setObject(i++, OBJECT_MAPPER.writeValueAsString(e.getPosition()), java.sql.Types.OTHER); // lcinfo (jsonb) + } else { + ps.setNull(i++, java.sql.Types.OTHER); + } + ps.setObject(i++, e.getJobExecutionId(), Types.INTEGER); + ps.setString(i++, e.getCreatedBy()); + } + + private void setDoubleOrNull(PreparedStatement ps, int index, Double value) throws Exception { + if (value != null) { + ps.setDouble(index, value); + } else { + // java.sql.Types.DOUBLE을 사용하여 명시적으로 SQL NULL을 설정 + ps.setNull(index, java.sql.Types.DOUBLE); + } + } + + @Override + protected void setUpdateParameters(PreparedStatement ps, BerthCallsEntity entity) throws Exception { + + } + + @Override + protected RowMapper getRowMapper() { + return null; + } + + @Override + public void saveAll(List entities) { + if (entities == null || entities.isEmpty()) return; + + batchInsert(entities); + + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/repository/CurrentlyAtRepository.java b/src/main/java/com/snp/batch/jobs/batch/movement/repository/CurrentlyAtRepository.java new file mode 100644 index 0000000..24489de --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/repository/CurrentlyAtRepository.java @@ -0,0 +1,13 @@ +package com.snp.batch.jobs.batch.movement.repository; + +import com.snp.batch.jobs.batch.movement.entity.CurrentlyAtEntity; + +import java.util.List; + +/** + * 선박 상세 정보 Repository 인터페이스 + */ + +public interface CurrentlyAtRepository { + void saveAll(List entities); +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/repository/CurrentlyAtRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/batch/movement/repository/CurrentlyAtRepositoryImpl.java new file mode 100644 index 0000000..b7702ff --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/repository/CurrentlyAtRepositoryImpl.java @@ -0,0 +1,149 @@ +package com.snp.batch.jobs.batch.movement.repository; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.batch.movement.entity.CurrentlyAtEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.Timestamp; +import java.sql.Types; +import java.util.List; + +@Slf4j +@Repository("CurrentlyAtRepository") +public class CurrentlyAtRepositoryImpl extends BaseJdbcRepository + implements CurrentlyAtRepository { + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + + @Value("${app.batch.target-schema.tables.movements-003}") + private String tableName; + + public CurrentlyAtRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + @Override + protected String getTargetSchema() { + return targetSchema; + } + + @Override + protected String getSimpleTableName() { + return tableName; + } + + @Override + protected String getEntityName() { + return "CurrentlyAt"; + } + + @Override + protected String extractId(CurrentlyAtEntity entity) { + return entity.getImolRorIHSNumber(); + } + + @Override + public String getInsertSql() { + return """ + INSERT INTO %s( + imo_no, + mvmn_type, + mvmn_dt, + prtcll_id, + facility_id, + facility_nm, + facility_type, + lwrnk_facility_id, + lwrnk_facility_desc, + lwrnk_facility_type, + up_facility_id, + up_facility_nm, + up_facility_type, + country_cd, + country_nm, + draft, + lat, + lon, + dest, + country_iso_two_cd, + position_info, + job_execution_id, creatr_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + """.formatted(getTableName()); + } + + @Override + protected String getUpdateSql() { + return null; + } + + @Override + protected void setInsertParameters(PreparedStatement ps, CurrentlyAtEntity e) throws Exception { + int i = 1; + ps.setString(i++, e.getImolRorIHSNumber()); // imo + ps.setString(i++, e.getMovementType()); // mvmn_type + ps.setTimestamp(i++, e.getMovementDate() != null ? Timestamp.valueOf(e.getMovementDate()) : null); // mvmn_dt + ps.setObject(i++, e.getPortCallId()); // stpov_id + ps.setObject(i++, e.getFacilityId()); // fclty_id + ps.setString(i++, e.getFacilityName()); // fclty_nm + ps.setString(i++, e.getFacilityType()); // fclty_type + ps.setObject(i++, e.getSubFacilityId()); // lwrnk_fclty_id + ps.setString(i++, e.getSubFacilityName()); // lwrnk_fclty_nm + ps.setString(i++, e.getSubFacilityType()); // lwrnk_fclty_type + ps.setObject(i++, e.getParentFacilityId()); // up_fclty_id + ps.setString(i++, e.getParentFacilityName()); // up_fclty_nm + ps.setString(i++, e.getParentFacilityType()); // up_fclty_type + ps.setString(i++, e.getCountryCode()); // ntn_cd + ps.setString(i++, e.getCountryName()); // ntn_nm + setDoubleOrNull(ps, i++, e.getDraught()); // draft + setDoubleOrNull(ps, i++, e.getLatitude()); // lat + setDoubleOrNull(ps, i++, e.getLongitude());// lon + ps.setString(i++, e.getDestination()); // dstn + ps.setString(i++, e.getIso2()); // iso2_ntn_cd + + if (e.getPosition() != null) { + ps.setObject(i++, OBJECT_MAPPER.writeValueAsString(e.getPosition()), java.sql.Types.OTHER); // lcinfo (jsonb) + } else { + ps.setNull(i++, java.sql.Types.OTHER); + } + ps.setObject(i++, e.getJobExecutionId(), Types.INTEGER); + ps.setString(i++, e.getCreatedBy()); + + } + + private void setDoubleOrNull(PreparedStatement ps, int index, Double value) throws Exception { + if (value != null) { + ps.setDouble(index, value); + } else { + // java.sql.Types.DOUBLE을 사용하여 명시적으로 SQL NULL을 설정 + ps.setNull(index, java.sql.Types.DOUBLE); + } + } + + @Override + protected void setUpdateParameters(PreparedStatement ps, CurrentlyAtEntity entity) throws Exception { + + } + + @Override + protected RowMapper getRowMapper() { + return null; + } + + @Override + public void saveAll(List entities) { + if (entities == null || entities.isEmpty()) return; + + batchInsert(entities); + + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/repository/DestinationRepository.java b/src/main/java/com/snp/batch/jobs/batch/movement/repository/DestinationRepository.java new file mode 100644 index 0000000..2b5d933 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/repository/DestinationRepository.java @@ -0,0 +1,13 @@ +package com.snp.batch.jobs.batch.movement.repository; + +import com.snp.batch.jobs.batch.movement.entity.DestinationEntity; + +import java.util.List; + +/** + * 선박 상세 정보 Repository 인터페이스 + */ + +public interface DestinationRepository { + void saveAll(List entities); +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/repository/DestinationRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/batch/movement/repository/DestinationRepositoryImpl.java new file mode 100644 index 0000000..432831f --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/repository/DestinationRepositoryImpl.java @@ -0,0 +1,130 @@ +package com.snp.batch.jobs.batch.movement.repository; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.batch.movement.entity.DestinationEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.Timestamp; +import java.sql.Types; +import java.util.List; + +@Slf4j +@Repository("DestinationRepository") +public class DestinationRepositoryImpl extends BaseJdbcRepository + implements DestinationRepository { + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + + @Value("${app.batch.target-schema.tables.movements-004}") + private String tableName; + + public DestinationRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + @Override + protected String getTargetSchema() { + return targetSchema; + } + + @Override + protected String getSimpleTableName() { + return tableName; + } + + @Override + protected String getEntityName() { + return "DestinationsRange"; + } + + @Override + protected String extractId(DestinationEntity entity) { + return entity.getImolRorIHSNumber(); + } + + @Override + public String getInsertSql() { + return """ + INSERT INTO %s( + imo_no, + mvmn_type, + mvmn_dt, + facility_id, + facility_nm, + facility_type, + country_cd, + country_nm, + lat, + lon, + country_iso_two_cd, + position_info, + job_execution_id, creatr_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + """.formatted(getTableName()); + } + + @Override + protected String getUpdateSql() { + return null; + } + + @Override + protected void setInsertParameters(PreparedStatement ps, DestinationEntity e) throws Exception { + int i = 1; + ps.setString(i++, e.getImolRorIHSNumber()); // imo + ps.setString(i++, e.getMovementType()); // mvmn_type + ps.setTimestamp(i++, e.getMovementDate() != null ? Timestamp.valueOf(e.getMovementDate()) : null); // mvmn_dt + ps.setObject(i++, e.getFacilityId()); // fclty_id + ps.setString(i++, e.getFacilityName()); // fclty_nm + ps.setString(i++, e.getFacilityType()); // fclty_type + ps.setString(i++, e.getCountryCode()); // ntn_cd + ps.setString(i++, e.getCountryName()); // ntn_nm + setDoubleOrNull(ps, i++, e.getLatitude()); // lat + setDoubleOrNull(ps, i++, e.getLongitude());// lon + ps.setString(i++, e.getIso2()); // iso2_ntn_cd + + if (e.getPosition() != null) { + ps.setObject(i++, OBJECT_MAPPER.writeValueAsString(e.getPosition()), java.sql.Types.OTHER); // lcinfo (jsonb) + } else { + ps.setNull(i++, java.sql.Types.OTHER); + } + ps.setObject(i++, e.getJobExecutionId(), Types.INTEGER); + ps.setString(i++, e.getCreatedBy()); + } + + private void setDoubleOrNull(PreparedStatement ps, int index, Double value) throws Exception { + if (value != null) { + ps.setDouble(index, value); + } else { + // java.sql.Types.DOUBLE을 사용하여 명시적으로 SQL NULL을 설정 + ps.setNull(index, java.sql.Types.DOUBLE); + } + } + + @Override + protected void setUpdateParameters(PreparedStatement ps, DestinationEntity entity) throws Exception { + + } + + @Override + protected RowMapper getRowMapper() { + return null; + } + + @Override + public void saveAll(List entities) { + if (entities == null || entities.isEmpty()) return; +// log.info("Destinations 저장 시작 = {}건", entities.size()); + batchInsert(entities); + + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/repository/PortCallsRepository.java b/src/main/java/com/snp/batch/jobs/batch/movement/repository/PortCallsRepository.java new file mode 100644 index 0000000..5e98851 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/repository/PortCallsRepository.java @@ -0,0 +1,12 @@ +package com.snp.batch.jobs.batch.movement.repository; + +import com.snp.batch.jobs.batch.movement.entity.PortCallsEntity; + +import java.util.List; + + +public interface PortCallsRepository { + + void saveAll(List entities); + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/repository/PortCallsRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/batch/movement/repository/PortCallsRepositoryImpl.java new file mode 100644 index 0000000..c1897d2 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/repository/PortCallsRepositoryImpl.java @@ -0,0 +1,149 @@ +package com.snp.batch.jobs.batch.movement.repository; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.batch.movement.entity.PortCallsEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.Timestamp; +import java.sql.Types; +import java.util.List; + +@Slf4j +@Repository("ShipMovementRepository") +public class PortCallsRepositoryImpl extends BaseJdbcRepository + implements PortCallsRepository { + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + + @Value("${app.batch.target-schema.tables.movements-005}") + private String tableName; + + public PortCallsRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + @Override + protected String getTargetSchema() { + return targetSchema; + } + + @Override + protected String getSimpleTableName() { + return tableName; + } + + @Override + protected String getEntityName() { + return "ShipMovement"; + } + + @Override + protected String extractId(PortCallsEntity entity) { + return entity.getImolRorIHSNumber(); + } + + @Override + public String getInsertSql() { + return """ + INSERT INTO %s( + imo_no, + mvmn_type, + mvmn_dt, + prtcll_id, + facility_id, + facility_nm, + facility_type, + lwrnk_facility_id, + lwrnk_facility_desc, + lwrnk_facility_type, + up_facility_id, + up_facility_nm, + up_facility_type, + country_cd, + country_nm, + draft, + lat, + lon, + dest, + country_iso_two_cd, + position_info, + job_execution_id, creatr_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + """.formatted(getTableName()); + } + + @Override + protected String getUpdateSql() { + return null; + } + + @Override + protected void setInsertParameters(PreparedStatement ps, PortCallsEntity e) throws Exception { + int i = 1; + ps.setString(i++, e.getImolRorIHSNumber()); // imo + ps.setString(i++, e.getMovementType()); // mvmn_type + ps.setTimestamp(i++, e.getMovementDate() != null ? Timestamp.valueOf(e.getMovementDate()) : null); // mvmn_dt + ps.setObject(i++, e.getPortCallId()); // stpov_id + ps.setObject(i++, e.getFacilityId()); // fclty_id + ps.setString(i++, e.getFacilityName()); // fclty_nm + ps.setString(i++, e.getFacilityType()); // fclty_type + ps.setObject(i++, e.getSubFacilityId()); // lwrnk_fclty_id + ps.setString(i++, e.getSubFacilityName()); // lwrnk_fclty_nm + ps.setString(i++, e.getSubFacilityType()); // lwrnk_fclty_type + ps.setObject(i++, e.getParentFacilityId()); // up_fclty_id + ps.setString(i++, e.getParentFacilityName()); // up_fclty_nm + ps.setString(i++, e.getParentFacilityType()); // up_fclty_type + ps.setString(i++, e.getCountryCode()); // ntn_cd + ps.setString(i++, e.getCountryName()); // ntn_nm + setDoubleOrNull(ps, i++, e.getDraught()); // draft + setDoubleOrNull(ps, i++, e.getLatitude()); // lat + setDoubleOrNull(ps, i++, e.getLongitude());// lon + ps.setString(i++, e.getDestination()); // dstn + ps.setString(i++, e.getIso2()); // iso2_ntn_cd + + if (e.getPosition() != null) { + ps.setObject(i++, OBJECT_MAPPER.writeValueAsString(e.getPosition()), java.sql.Types.OTHER); // lcinfo (jsonb) + } else { + ps.setNull(i++, java.sql.Types.OTHER); + } + ps.setObject(i++, e.getJobExecutionId(), Types.INTEGER); + ps.setString(i++, e.getCreatedBy()); + + } + + private void setDoubleOrNull(PreparedStatement ps, int index, Double value) throws Exception { + if (value != null) { + ps.setDouble(index, value); + } else { + // java.sql.Types.DOUBLE을 사용하여 명시적으로 SQL NULL을 설정 + ps.setNull(index, java.sql.Types.DOUBLE); + } + } + + @Override + protected void setUpdateParameters(PreparedStatement ps, PortCallsEntity entity) throws Exception { + + } + + @Override + protected RowMapper getRowMapper() { + return null; + } + + @Override + public void saveAll(List entities) { + if (entities == null || entities.isEmpty()) return; + +// log.info("ShipMovement 저장 시작 = {}건", entities.size()); + batchInsert(entities); + + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/repository/StsOperationRepository.java b/src/main/java/com/snp/batch/jobs/batch/movement/repository/StsOperationRepository.java new file mode 100644 index 0000000..a152598 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/repository/StsOperationRepository.java @@ -0,0 +1,12 @@ +package com.snp.batch.jobs.batch.movement.repository; + +import com.snp.batch.jobs.batch.movement.entity.StsOperationEntity; +import java.util.List; + +/** + * 선박 상세 정보 Repository 인터페이스 + */ + +public interface StsOperationRepository { + void saveAll(List entities); +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/repository/StsOperationRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/batch/movement/repository/StsOperationRepositoryImpl.java new file mode 100644 index 0000000..d549c3f --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/repository/StsOperationRepositoryImpl.java @@ -0,0 +1,152 @@ +package com.snp.batch.jobs.batch.movement.repository; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.batch.movement.entity.StsOperationEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.Timestamp; +import java.sql.Types; +import java.util.List; + +@Slf4j +@Repository("StsOperationRepository") +public class StsOperationRepositoryImpl extends BaseJdbcRepository + implements StsOperationRepository { + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + + @Value("${app.batch.target-schema.tables.movements-006}") + private String tableName; + + public StsOperationRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + @Override + protected String getTargetSchema() { + return targetSchema; + } + + @Override + protected String getSimpleTableName() { + return tableName; + } + + @Override + protected String getEntityName() { + return "StsOperation"; + } + + @Override + protected String extractId(StsOperationEntity entity) { + return entity.getImolRorIHSNumber(); + } + + @Override + public String getInsertSql() { + return """ + INSERT INTO %s( + imo_no, + mvmn_type, + mvmn_dt, + facility_id, + facility_nm, + facility_type, + up_facility_id, + up_facility_nm, + up_facility_type, + draft, + lat, + lon, + up_prtcll_id, + country_cd, + country_nm, + sts_position, + sts_type, + event_sta_dt, + position_info, + job_execution_id, creatr_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + """.formatted(getTableName()); + } + + @Override + protected String getUpdateSql() { + return null; + } + + @Override + protected void setInsertParameters(PreparedStatement ps, StsOperationEntity e) throws Exception { + int i = 1; + ps.setString(i++, safeString(e.getImolRorIHSNumber())); // imo + ps.setString(i++, safeString(e.getMovementType())); // mvmn_type + ps.setTimestamp(i++, e.getMovementDate() != null ? Timestamp.valueOf(e.getMovementDate()) : null); // mvmn_dt + ps.setObject(i++, e.getFacilityId()); // fclty_id + ps.setString(i++, safeString(e.getFacilityName())); // fclty_nm + ps.setString(i++, safeString(e.getFacilityType())); // fclty_type + ps.setObject(i++, e.getParentFacilityId()); //up_fclty_id + ps.setString(i++, safeString(e.getParentFacilityName())); // up_fclty_nm + ps.setString(i++, safeString(e.getParentFacilityType())); //up_fclty_type + setDoubleOrNull(ps, i++, e.getDraught()); // draft + setDoubleOrNull(ps, i++, e.getLatitude()); // lat + setDoubleOrNull(ps, i++, e.getLongitude());// lon + ps.setObject(i++, e.getParentCallId()); //prnt_call_id + ps.setString(i++, safeString(e.getCountryCode())); // ntn_cd + ps.setString(i++, safeString(e.getCountryName())); // ntn_nm + ps.setString(i++, safeString(e.getStsLocation())); // iso2_ntn_cd + ps.setString(i++, safeString(e.getStsType())); + ps.setTimestamp(i++, e.getEventStartDate() != null ? Timestamp.valueOf(e.getEventStartDate()) : null); // evt_start_dt + + if (e.getPosition() != null) { + ps.setObject(i++, OBJECT_MAPPER.writeValueAsString(e.getPosition()), java.sql.Types.OTHER); // lcinfo (jsonb) + } else { + ps.setNull(i++, java.sql.Types.OTHER); + } + ps.setObject(i++, e.getJobExecutionId(), Types.INTEGER); + ps.setString(i++, e.getCreatedBy()); + } + + private void setDoubleOrNull(PreparedStatement ps, int index, Double value) throws Exception { + if (value != null) { + ps.setDouble(index, value); + } else { + // java.sql.Types.DOUBLE을 사용하여 명시적으로 SQL NULL을 설정 + ps.setNull(index, java.sql.Types.DOUBLE); + } + } + + @Override + protected void setUpdateParameters(PreparedStatement ps, StsOperationEntity entity) throws Exception { + + } + + @Override + protected RowMapper getRowMapper() { + return null; + } + + @Override + public void saveAll(List entities) { + if (entities == null || entities.isEmpty()) return; + +// log.info("StsOperation 저장 시작 = {}건", entities.size()); + batchInsert(entities); + + } + private String safeString(String v) { + if (v == null) return null; + + v = v.trim(); + + return v.isEmpty() ? null : v; + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/repository/TerminalCallsRepository.java b/src/main/java/com/snp/batch/jobs/batch/movement/repository/TerminalCallsRepository.java new file mode 100644 index 0000000..c25a436 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/repository/TerminalCallsRepository.java @@ -0,0 +1,13 @@ +package com.snp.batch.jobs.batch.movement.repository; + +import com.snp.batch.jobs.batch.movement.entity.TerminalCallsEntity; + +import java.util.List; + +/** + * 선박 상세 정보 Repository 인터페이스 + */ + +public interface TerminalCallsRepository { + void saveAll(List entities); +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/repository/TerminalCallsRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/batch/movement/repository/TerminalCallsRepositoryImpl.java new file mode 100644 index 0000000..bd6f55c --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/repository/TerminalCallsRepositoryImpl.java @@ -0,0 +1,148 @@ +package com.snp.batch.jobs.batch.movement.repository; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.batch.movement.entity.TerminalCallsEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.Timestamp; +import java.sql.Types; +import java.util.List; + +@Slf4j +@Repository("TerminalCallsRepository") +public class TerminalCallsRepositoryImpl extends BaseJdbcRepository + implements TerminalCallsRepository { + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + + @Value("${app.batch.target-schema.tables.movements-007}") + private String tableName; + + public TerminalCallsRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + @Override + protected String getTargetSchema() { + return targetSchema; + } + + @Override + protected String getSimpleTableName() { + return tableName; + } + + @Override + protected String getEntityName() { + return "TerminallCalls"; + } + + @Override + protected String extractId(TerminalCallsEntity entity) { + return entity.getImolRorIHSNumber(); + } + + @Override + public String getInsertSql() { + return """ + INSERT INTO %s( + imo_no, + mvmn_type, + mvmn_dt, + facility_id, + facility_nm, + facility_type, + up_facility_id, + up_facility_nm, + up_facility_type, + country_cd, + country_nm, + draft, + lat, + lon, + up_prtcll_id, + country_iso_two_cd, + event_sta_dt, + position_info, + lwrnk_facility_id, + lwrnk_facility_desc, + lwrnk_facility_type, + job_execution_id, creatr_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + """.formatted(getTableName()); + } + + @Override + protected String getUpdateSql() { + return null; + } + + @Override + protected void setInsertParameters(PreparedStatement ps, TerminalCallsEntity e) throws Exception { + int i = 1; + ps.setString(i++, e.getImolRorIHSNumber()); // imo + ps.setString(i++, e.getMovementType()); // mvmn_type + ps.setTimestamp(i++, e.getMovementDate() != null ? Timestamp.valueOf(e.getMovementDate()) : null); // mvmn_dt + ps.setObject(i++, e.getFacilityId()); // fclty_id + ps.setString(i++, e.getFacilityName()); // fclty_nm + ps.setString(i++, e.getFacilityType()); // fclty_type + ps.setObject(i++, e.getParentFacilityId()); //up_fclty_id + ps.setString(i++, e.getParentFacilityName()); // up_fclty_nm + ps.setString(i++, e.getParentFacilityType()); //up_fclty_type + ps.setString(i++, e.getCountryCode()); // ntn_cd + ps.setString(i++, e.getCountryName()); // ntn_nm + setDoubleOrNull(ps, i++, e.getDraught()); // draft + setDoubleOrNull(ps, i++, e.getLatitude()); // lat + setDoubleOrNull(ps, i++, e.getLongitude());// lon + ps.setObject(i++, e.getParentCallId()); //prnt_call_id + ps.setString(i++, e.getIso2()); // iso2_ntn_cd + ps.setTimestamp(i++, e.getEventStartDate() != null ? Timestamp.valueOf(e.getEventStartDate()) : null); // evt_start_dt + + if (e.getPosition() != null) { + ps.setObject(i++, OBJECT_MAPPER.writeValueAsString(e.getPosition()), java.sql.Types.OTHER); // lcinfo (jsonb) + } else { + ps.setNull(i++, java.sql.Types.OTHER); + } + ps.setObject(i++, e.getSubFacilityId()); + ps.setString(i++, e.getSubFacilityName()); + ps.setString(i++, e.getSubFacilityType()); + ps.setObject(i++, e.getJobExecutionId(), Types.INTEGER); + ps.setString(i++, e.getCreatedBy()); + } + + private void setDoubleOrNull(PreparedStatement ps, int index, Double value) throws Exception { + if (value != null) { + ps.setDouble(index, value); + } else { + // java.sql.Types.DOUBLE을 사용하여 명시적으로 SQL NULL을 설정 + ps.setNull(index, java.sql.Types.DOUBLE); + } + } + + @Override + protected void setUpdateParameters(PreparedStatement ps, TerminalCallsEntity entity) throws Exception { + + } + + @Override + protected RowMapper getRowMapper() { + return null; + } + + @Override + public void saveAll(List entities) { + if (entities == null || entities.isEmpty()) return; + + batchInsert(entities); + + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/repository/TransitsRepository.java b/src/main/java/com/snp/batch/jobs/batch/movement/repository/TransitsRepository.java new file mode 100644 index 0000000..679e629 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/repository/TransitsRepository.java @@ -0,0 +1,13 @@ +package com.snp.batch.jobs.batch.movement.repository; + +import com.snp.batch.jobs.batch.movement.entity.TransitsEntity; + +import java.util.List; + +/** + * 선박 상세 정보 Repository 인터페이스 + */ + +public interface TransitsRepository { + void saveAll(List entities); +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/repository/TransitsRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/batch/movement/repository/TransitsRepositoryImpl.java new file mode 100644 index 0000000..24cc769 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/repository/TransitsRepositoryImpl.java @@ -0,0 +1,114 @@ +package com.snp.batch.jobs.batch.movement.repository; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.batch.movement.entity.TransitsEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.Timestamp; +import java.sql.Types; +import java.util.List; + +@Slf4j +@Repository("TransitsRepository") +public class TransitsRepositoryImpl extends BaseJdbcRepository + implements TransitsRepository { + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + + @Value("${app.batch.target-schema.tables.movements-008}") + private String tableName; + + public TransitsRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + @Override + protected String getTargetSchema() { + return targetSchema; + } + + @Override + protected String getSimpleTableName() { + return tableName; + } + + @Override + protected String getEntityName() { + return "Transit"; + } + + @Override + protected String extractId(TransitsEntity entity) { + return entity.getImolRorIHSNumber(); + } + + @Override + public String getInsertSql() { + return """ + INSERT INTO %s( + imo_no, + mvmn_type, + mvmn_dt, + facility_nm, + facility_type, + draft, + job_execution_id, creatr_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?); + """.formatted(getTableName()); + } + + @Override + protected String getUpdateSql() { + return null; + } + + @Override + protected void setInsertParameters(PreparedStatement ps, TransitsEntity e) throws Exception { + int i = 1; + ps.setString(i++, e.getImolRorIHSNumber()); // imo + ps.setString(i++, e.getMovementType()); // mvmn_type + ps.setTimestamp(i++, e.getMovementDate() != null ? Timestamp.valueOf(e.getMovementDate()) : null); // mvmn_dt + ps.setString(i++, e.getFacilityName()); // fclty_nm + ps.setString(i++, e.getFacilityType()); // fclty_type + setDoubleOrNull(ps, i++, e.getDraught()); // draft + ps.setObject(i++, e.getJobExecutionId(), Types.INTEGER); + ps.setString(i++, e.getCreatedBy()); + } + + private void setDoubleOrNull(PreparedStatement ps, int index, Double value) throws Exception { + if (value != null) { + ps.setDouble(index, value); + } else { + // java.sql.Types.DOUBLE을 사용하여 명시적으로 SQL NULL을 설정 + ps.setNull(index, java.sql.Types.DOUBLE); + } + } + + @Override + protected void setUpdateParameters(PreparedStatement ps, TransitsEntity entity) throws Exception { + + } + + @Override + protected RowMapper getRowMapper() { + return null; + } + + @Override + public void saveAll(List entities) { + if (entities == null || entities.isEmpty()) return; + +// log.info("Transits 저장 시작 = {}건", entities.size()); + batchInsert(entities); + + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/writer/AnchorageCallsWriter.java b/src/main/java/com/snp/batch/jobs/batch/movement/writer/AnchorageCallsWriter.java new file mode 100644 index 0000000..4c33f8f --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/writer/AnchorageCallsWriter.java @@ -0,0 +1,35 @@ +package com.snp.batch.jobs.batch.movement.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.batch.movement.repository.AnchorageCallsRepository; +import com.snp.batch.jobs.batch.movement.entity.AnchorageCallsEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 선박 상세 정보 Writer + */ +@Slf4j +@Component +public class AnchorageCallsWriter extends BaseWriter { + + private final AnchorageCallsRepository anchorageCallsRepository; + + + public AnchorageCallsWriter(AnchorageCallsRepository anchorageCallsRepository) { + super("AnchorageCalls"); + this.anchorageCallsRepository = anchorageCallsRepository; + } + + @Override + protected void writeItems(List items) throws Exception { + + if (items.isEmpty()) { return; } + + anchorageCallsRepository.saveAll(items); + log.info("AnchorageCalls 데이터 저장: {} 건", items.size()); + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/writer/BerthCallsWriter.java b/src/main/java/com/snp/batch/jobs/batch/movement/writer/BerthCallsWriter.java new file mode 100644 index 0000000..9152be2 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/writer/BerthCallsWriter.java @@ -0,0 +1,35 @@ +package com.snp.batch.jobs.batch.movement.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.batch.movement.repository.BerthCallsRepository; +import com.snp.batch.jobs.batch.movement.entity.BerthCallsEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 선박 상세 정보 Writer + */ +@Slf4j +@Component +public class BerthCallsWriter extends BaseWriter { + + private final BerthCallsRepository berthCallsRepository; + + + public BerthCallsWriter(BerthCallsRepository berthCallsRepository) { + super("BerthCalls"); + this.berthCallsRepository = berthCallsRepository; + } + + @Override + protected void writeItems(List items) throws Exception { + + if (items.isEmpty()) { return; } + + berthCallsRepository.saveAll(items); + log.info("BerthCalls 데이터 저장: {} 건", items.size()); + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/writer/CurrentlyAtWriter.java b/src/main/java/com/snp/batch/jobs/batch/movement/writer/CurrentlyAtWriter.java new file mode 100644 index 0000000..7a0b179 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/writer/CurrentlyAtWriter.java @@ -0,0 +1,36 @@ +package com.snp.batch.jobs.batch.movement.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.batch.movement.repository.CurrentlyAtRepository; +import com.snp.batch.jobs.batch.movement.entity.CurrentlyAtEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 선박 상세 정보 Writer + */ +@Slf4j +@Component +public class CurrentlyAtWriter extends BaseWriter { + + private final CurrentlyAtRepository currentlyAtRepository; + + + public CurrentlyAtWriter(CurrentlyAtRepository currentlyAtRepository) { + super("CurrentlyAt"); + this.currentlyAtRepository = currentlyAtRepository; + } + + @Override + protected void writeItems(List items) throws Exception { + + if (items.isEmpty()) { return; } + + currentlyAtRepository.saveAll(items); + log.info("CurrentlyAt 데이터 저장: {} 건", items.size()); + + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/writer/DestinationWriter.java b/src/main/java/com/snp/batch/jobs/batch/movement/writer/DestinationWriter.java new file mode 100644 index 0000000..fa00c12 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/writer/DestinationWriter.java @@ -0,0 +1,35 @@ +package com.snp.batch.jobs.batch.movement.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.batch.movement.repository.DestinationRepository; +import com.snp.batch.jobs.batch.movement.entity.DestinationEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 선박 상세 정보 Writer + */ +@Slf4j +@Component +public class DestinationWriter extends BaseWriter { + + private final DestinationRepository destinationRepository; + + + public DestinationWriter(DestinationRepository destinationRepository) { + super("Destinations"); + this.destinationRepository = destinationRepository; + } + + @Override + protected void writeItems(List items) throws Exception { + + if (items.isEmpty()) { return; } + + destinationRepository.saveAll(items); + log.info("Destinations 데이터 저장: {} 건", items.size()); + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/writer/PortCallsWriter.java b/src/main/java/com/snp/batch/jobs/batch/movement/writer/PortCallsWriter.java new file mode 100644 index 0000000..78d0b8b --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/writer/PortCallsWriter.java @@ -0,0 +1,36 @@ +package com.snp.batch.jobs.batch.movement.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.batch.movement.entity.PortCallsEntity; +import com.snp.batch.jobs.batch.movement.repository.PortCallsRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 선박 상세 정보 Writer + */ +@Slf4j +@Component +public class PortCallsWriter extends BaseWriter { + + private final PortCallsRepository shipMovementRepository; + + + public PortCallsWriter(PortCallsRepository shipMovementRepository) { + super("ShipPortCalls"); + this.shipMovementRepository = shipMovementRepository; + } + + @Override + protected void writeItems(List items) throws Exception { + + if (items.isEmpty()) { return; } + + shipMovementRepository.saveAll(items); + log.info("PortCalls 데이터 저장 완료: {} 건", items.size()); + + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/writer/StsOperationWriter.java b/src/main/java/com/snp/batch/jobs/batch/movement/writer/StsOperationWriter.java new file mode 100644 index 0000000..2c6a8f6 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/writer/StsOperationWriter.java @@ -0,0 +1,36 @@ +package com.snp.batch.jobs.batch.movement.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.batch.movement.repository.StsOperationRepository; +import com.snp.batch.jobs.batch.movement.entity.StsOperationEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 선박 상세 정보 Writer + */ +@Slf4j +@Component +public class StsOperationWriter extends BaseWriter { + + private final StsOperationRepository stsOperationRepository; + + + public StsOperationWriter(StsOperationRepository stsOperationRepository) { + super("StsOperation"); + this.stsOperationRepository = stsOperationRepository; + } + + @Override + protected void writeItems(List items) throws Exception { + + if (items.isEmpty()) { return; } + + stsOperationRepository.saveAll(items); + log.info("STS OPERATION 데이터 저장: {} 건", items.size()); + + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/writer/TerminalCallsWriter.java b/src/main/java/com/snp/batch/jobs/batch/movement/writer/TerminalCallsWriter.java new file mode 100644 index 0000000..4b8d62f --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/writer/TerminalCallsWriter.java @@ -0,0 +1,35 @@ +package com.snp.batch.jobs.batch.movement.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.batch.movement.repository.TerminalCallsRepository; +import com.snp.batch.jobs.batch.movement.entity.TerminalCallsEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 선박 상세 정보 Writer + */ +@Slf4j +@Component +public class TerminalCallsWriter extends BaseWriter { + + private final TerminalCallsRepository terminalCallsRepository; + + + public TerminalCallsWriter(TerminalCallsRepository terminalCallsRepository) { + super("TerminalCalls"); + this.terminalCallsRepository = terminalCallsRepository; + } + + @Override + protected void writeItems(List items) throws Exception { + + if (items.isEmpty()) { return; } + + terminalCallsRepository.saveAll(items); + log.info("TerminalCalls 데이터 저장: {} 건", items.size()); + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/movement/writer/TransitsWriter.java b/src/main/java/com/snp/batch/jobs/batch/movement/writer/TransitsWriter.java new file mode 100644 index 0000000..96b6910 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/movement/writer/TransitsWriter.java @@ -0,0 +1,35 @@ +package com.snp.batch.jobs.batch.movement.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.batch.movement.repository.TransitsRepository; +import com.snp.batch.jobs.batch.movement.entity.TransitsEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 선박 상세 정보 Writer + */ +@Slf4j +@Component +public class TransitsWriter extends BaseWriter { + + private final TransitsRepository transitsRepository; + + + public TransitsWriter(TransitsRepository transitsRepository) { + super("Transits"); + this.transitsRepository = transitsRepository; + } + + @Override + protected void writeItems(List items) throws Exception { + + if (items.isEmpty()) { return; } + + transitsRepository.saveAll(items); + log.info("Transits 데이터 저장: {} 건", items.size()); + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/pscInspection/config/PscInspectionJobConfig.java b/src/main/java/com/snp/batch/jobs/batch/pscInspection/config/PscInspectionJobConfig.java new file mode 100644 index 0000000..ebc0e8b --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/pscInspection/config/PscInspectionJobConfig.java @@ -0,0 +1,173 @@ +package com.snp.batch.jobs.batch.pscInspection.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.config.BaseMultiStepJobConfig; +import com.snp.batch.common.batch.tasklet.LastExecutionUpdateTasklet; +import com.snp.batch.jobs.batch.facility.processor.PortDataProcessor; +import com.snp.batch.jobs.batch.pscInspection.dto.PscInspectionDto; +import com.snp.batch.jobs.batch.pscInspection.entity.PscInspectionEntity; +import com.snp.batch.jobs.batch.pscInspection.processor.PscInspectionProcessor; +import com.snp.batch.jobs.batch.pscInspection.reader.PscApiReader; +import com.snp.batch.jobs.batch.pscInspection.writer.PscInspectionWriter; +import com.snp.batch.service.BatchApiLogService; +import com.snp.batch.service.BatchDateService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.job.flow.FlowExecutionStatus; +import org.springframework.batch.core.job.flow.JobExecutionDecider; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.reactive.function.client.WebClient; + +@Slf4j +@Configuration +public class PscInspectionJobConfig extends BaseMultiStepJobConfig { + private final PscInspectionProcessor pscInspectionProcessor; + private final PscInspectionWriter pscInspectionWriter; + private final PscApiReader pscApiReader; + private final JdbcTemplate jdbcTemplate; + private final WebClient maritimeApiWebClient; + private final BatchDateService batchDateService; + private final BatchApiLogService batchApiLogService; + + @Value("${app.batch.ship-api.url}") + private String maritimeApiUrl; + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + + @Value("${app.batch.last-execution-buffer-hours:24}") + private int lastExecutionBufferHours; + + protected String getApiKey() {return "PSC_IMPORT_API";} + + public PscInspectionJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + PscInspectionProcessor pscInspectionProcessor, + PscInspectionWriter pscInspectionWriter, + PscApiReader pscApiReader, + JdbcTemplate jdbcTemplate, + BatchDateService batchDateService, + @Qualifier("maritimeApiWebClient") WebClient maritimeApiWebClient, + BatchApiLogService batchApiLogService) { // ObjectMapper 주입 추가 + super(jobRepository, transactionManager); + this.pscInspectionProcessor = pscInspectionProcessor; + this.pscInspectionWriter = pscInspectionWriter; + this.pscApiReader = pscApiReader; + this.jdbcTemplate = jdbcTemplate; + this.batchDateService = batchDateService; + this.maritimeApiWebClient = maritimeApiWebClient; + this.batchApiLogService = batchApiLogService; + } + + + + @Override + protected String getJobName() { + return "PSCDetailImportJob"; + } + + @Override + protected String getStepName() { + return "PSCDetailImportStep"; + } + + @Override + protected Job createJobFlow(JobBuilder jobBuilder) { + return jobBuilder + .start(PSCDetailImportStep()) + .next(pscEmptyResponseDecider()) + .on("EMPTY_RESPONSE").end() + .from(pscEmptyResponseDecider()).on("*").to(pscLastExecutionUpdateStep()) + .end() + .build(); + } + + @Bean + public JobExecutionDecider pscEmptyResponseDecider() { + return (jobExecution, stepExecution) -> { + if (stepExecution != null && stepExecution.getReadCount() == 0) { + log.info("[PSCDetailImportJob] Decider: EMPTY_RESPONSE - 응답 데이터 0건으로 LAST_EXECUTION 업데이트 스킵"); + return new FlowExecutionStatus("EMPTY_RESPONSE"); + } + log.info("[PSCDetailImportJob] Decider: NORMAL - LAST_EXECUTION 업데이트 진행"); + return new FlowExecutionStatus("NORMAL"); + }; + } + + @Bean + @StepScope + public PscApiReader pscApiReader( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, // SpEL로 ID 추출 + @Value("#{stepExecution.id}") Long stepExecutionId + ) { + PscApiReader reader = new PscApiReader(maritimeApiWebClient, jdbcTemplate, batchDateService, batchApiLogService, maritimeApiUrl); + reader.setExecutionIds(jobExecutionId, stepExecutionId); // ID 세팅 + return reader; + } + + @Override + protected ItemReader createReader() { + return pscApiReader; + } + + @Override + protected ItemProcessor createProcessor() { + return pscInspectionProcessor; + } + + @Bean + @StepScope + public PscInspectionProcessor pscInspectionProcessor( + ObjectMapper objectMapper, + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId) { + return new PscInspectionProcessor(objectMapper, jobExecutionId); + } + + @Override + protected ItemWriter createWriter() { // 타입 변경 + return pscInspectionWriter; + } + + @Override + protected int getChunkSize() { + return 5000; + } + + @Bean(name = "PSCDetailImportJob") + public Job PSCDetailImportJob() { + return job(); + } + + @Bean(name = "PSCDetailImportStep") + public Step PSCDetailImportStep() { + return step(); + } + /** + * 2단계: 모든 스텝 성공 시 배치 실행 로그(날짜) 업데이트 + */ + @Bean + public Tasklet pscLastExecutionUpdateTasklet() { + return new LastExecutionUpdateTasklet(jdbcTemplate, targetSchema, getApiKey(), lastExecutionBufferHours); + } + @Bean(name = "PSCLastExecutionUpdateStep") + public Step pscLastExecutionUpdateStep() { + return new StepBuilder("PSCLastExecutionUpdateStep", jobRepository) + .tasklet(pscLastExecutionUpdateTasklet(), transactionManager) + .build(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/pscInspection/dto/PscAllCertificateDto.java b/src/main/java/com/snp/batch/jobs/batch/pscInspection/dto/PscAllCertificateDto.java new file mode 100644 index 0000000..1907919 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/pscInspection/dto/PscAllCertificateDto.java @@ -0,0 +1,75 @@ +package com.snp.batch.jobs.batch.pscInspection.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class PscAllCertificateDto { + + @JsonProperty("Type_Id") + private String typeId; + + @JsonProperty("DataSetVersion") + private PscDataSetVersionDto dataSetVersion; + + @JsonProperty("Certificate_ID") + private String certificateId; + + @JsonProperty("Inspection_ID") + private String inspectionId; + + @JsonProperty("Lrno") + private String lrno; + + @JsonProperty("Certificate_Title_Code") + private String certificateTitleCode; + + @JsonProperty("Certificate_Title") + private String certificateTitle; + + @JsonProperty("Issuing_Authority_Code") + private String issuingAuthorityCode; + + @JsonProperty("Issuing_Authority") + private String issuingAuthority; + + @JsonProperty("Class_Soc_of_Issuer") + private String classSocOfIssuer; + + @JsonProperty("Other_Issuing_Authority") + private String otherIssuingAuthority; + + @JsonProperty("Issue_Date") + private String issueDate; + + @JsonProperty("Expiry_Date") + private String expiryDate; + + @JsonProperty("Last_Survey_Date") + private String lastSurveyDate; + + @JsonProperty("Survey_Authority_Code") + private String surveyAuthorityCode; + + @JsonProperty("Survey_Authority") + private String surveyAuthority; + + @JsonProperty("Other_Survey_Authority") + private String otherSurveyAuthority; + + @JsonProperty("Latest_Survey_Place") + private String latestSurveyPlace; + + @JsonProperty("Latest_Survey_Place_Code") + private String latestSurveyPlaceCode; + + @JsonProperty("Survey_Authority_Type") + private String surveyAuthorityType; + + @JsonProperty("Inspection_Date") + private String inspectionDate; + + @JsonProperty("Inspected_By") + private String inspectedBy; + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/pscInspection/dto/PscApiResponseDto.java b/src/main/java/com/snp/batch/jobs/batch/pscInspection/dto/PscApiResponseDto.java new file mode 100644 index 0000000..5efb26d --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/pscInspection/dto/PscApiResponseDto.java @@ -0,0 +1,18 @@ +package com.snp.batch.jobs.batch.pscInspection.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; + +@Data +public class PscApiResponseDto { + + @JsonProperty("Inspections") + private List inspections; + @JsonProperty("inspectionCount") + private Integer inspectionCount; + + @JsonProperty("APSStatus") + private PscApsStatusDto apsStatus; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/pscInspection/dto/PscApsStatusDto.java b/src/main/java/com/snp/batch/jobs/batch/pscInspection/dto/PscApsStatusDto.java new file mode 100644 index 0000000..357b497 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/pscInspection/dto/PscApsStatusDto.java @@ -0,0 +1,31 @@ +package com.snp.batch.jobs.batch.pscInspection.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class PscApsStatusDto { + @JsonProperty("SystemVersion") + private String systemVersion; + + @JsonProperty("SystemDate") + private String systemDate; + + @JsonProperty("JobRunDate") + private String jobRunDate; + + @JsonProperty("CompletedOK") + private Boolean completedOK; + + @JsonProperty("ErrorLevel") + private String errorLevel; + + @JsonProperty("ErrorMessage") + private String errorMessage; + + @JsonProperty("RemedialAction") + private String remedialAction; + + @JsonProperty("Guid") + private String guid; +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/pscInspection/dto/PscDataSetVersionDto.java b/src/main/java/com/snp/batch/jobs/batch/pscInspection/dto/PscDataSetVersionDto.java new file mode 100644 index 0000000..5742f9f --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/pscInspection/dto/PscDataSetVersionDto.java @@ -0,0 +1,10 @@ +package com.snp.batch.jobs.batch.pscInspection.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class PscDataSetVersionDto { + @JsonProperty("DataSetVersion") + private String dataSetVersion; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/pscInspection/dto/PscDefectDto.java b/src/main/java/com/snp/batch/jobs/batch/pscInspection/dto/PscDefectDto.java new file mode 100644 index 0000000..7069b95 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/pscInspection/dto/PscDefectDto.java @@ -0,0 +1,91 @@ +package com.snp.batch.jobs.batch.pscInspection.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class PscDefectDto { + @JsonProperty("Type_Id") + private String typeId; + + @JsonProperty("DataSetVersion") + private PscDataSetVersionDto dataSetVersion; + + @JsonProperty("Action_1") + private String action1; + + @JsonProperty("Action_2") + private String action2; + + @JsonProperty("Action_3") + private String action3; + + @JsonProperty("Action_Code_1") + private String actionCode1; + + @JsonProperty("Action_Code_2") + private String actionCode2; + + @JsonProperty("Action_Code_3") + private String actionCode3; + + @JsonProperty("AmsA_Action_Code_1") + private String amsaActionCode1; + + @JsonProperty("AmsA_Action_Code_2") + private String amsaActionCode2; + + @JsonProperty("AmsA_Action_Code_3") + private String amsaActionCode3; + + @JsonProperty("Class_Is_Responsible") + private String classIsResponsible; + + @JsonProperty("Defect_Code") + private String defectCode; + + @JsonProperty("Defect_ID") + private String defectId; + + @JsonProperty("Defect_Text") + private String defectText; + + @JsonProperty("Defective_Item_Code") + private String defectiveItemCode; + + @JsonProperty("Detention_Reason_Deficiency") + private String detentionReasonDeficiency; + + @JsonProperty("Inspection_ID") + private String inspectionId; + + @JsonProperty("Main_Defect_Code") + private String mainDefectCode; + + @JsonProperty("Main_Defect_Text") + private String mainDefectText; + + @JsonProperty("Nature_Of_Defect_Code") + private String natureOfDefectCode; + + @JsonProperty("Nature_Of_Defect_DeCode") + private String natureOfDefectDecode; + + @JsonProperty("Other_Action") + private String otherAction; + + @JsonProperty("Other_Recognised_Org_Resp") + private String otherRecognisedOrgResp; + + @JsonProperty("Recognised_Org_Resp") + private String recognisedOrgResp; + + @JsonProperty("Recognised_Org_Resp_Code") + private String recognisedOrgRespCode; + + @JsonProperty("Recognised_Org_Resp_YN") + private String recognisedOrgRespYn; + + @JsonProperty("IsAccidentalDamage") + private String isAccidentalDamage; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/pscInspection/dto/PscInspectionDto.java b/src/main/java/com/snp/batch/jobs/batch/pscInspection/dto/PscInspectionDto.java new file mode 100644 index 0000000..2e810cd --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/pscInspection/dto/PscInspectionDto.java @@ -0,0 +1,119 @@ +package com.snp.batch.jobs.batch.pscInspection.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +@Data +public class PscInspectionDto { + + @JsonProperty("typeId") + private String typeId; + + @JsonProperty("DataSetVersion") + private PscDataSetVersionDto dataSetVersion; + + @JsonProperty("Authorisation") + private String authorisation; + + @JsonProperty("CallSign") + private String callSign; + + @JsonProperty("Cargo") + private String cargo; + + @JsonProperty("Charterer") + private String charterer; + + @JsonProperty("Class") + private String shipClass; + + @JsonProperty("Country") + private String country; + + @JsonProperty("Inspection_Date") + private String inspectionDate; + + @JsonProperty("Release_Date") + private String releaseDate; + + @JsonProperty("Ship_Detained") + private String shipDetained; + + @JsonProperty("Dead_Weight") + private String deadWeight; + + @JsonProperty("Expanded_Inspection") + private String expandedInspection; + + @JsonProperty("Flag") + private String flag; + + @JsonProperty("Follow_Up_Inspection") + private String followUpInspection; + + @JsonProperty("Gross_Tonnage") + private String grossTonnage; + + @JsonProperty("Inspection_ID") + private String inspectionId; + + @JsonProperty("Inspection_Port_Code") + private String inspectionPortCode; + + @JsonProperty("Inspection_Port_Decode") + private String inspectionPortDecode; + + @JsonProperty("Keel_Laid") + private String keelLaid; + + @JsonProperty("Last_Updated") + private String lastUpdated; + + @JsonProperty("IHSLR_or_IMO_Ship_No") + private String ihslrOrImoShipNo; + + @JsonProperty("Manager") + private String manager; + + @JsonProperty("Number_Of_Days_Detained") + private Integer numberOfDaysDetained; + + @JsonProperty("Number_Of_Defects") + private String numberOfDefects; + + @JsonProperty("Number_Of_Part_Days_Detained") + private BigDecimal numberOfPartDaysDetained; + + @JsonProperty("Other_Inspection_Type") + private String otherInspectionType; + + @JsonProperty("Owner") + private String owner; + + @JsonProperty("Ship_Name") + private String shipName; + + @JsonProperty("Ship_Type_Code") + private String shipTypeCode; + + @JsonProperty("Ship_Type_Decode") + private String shipTypeDecode; + + @JsonProperty("Source") + private String source; + + @JsonProperty("UNLOCODE") + private String unlocode; + + @JsonProperty("Year_Of_Build") + private String yearOfBuild; + + @JsonProperty("PSCDefects") + private List pscDefects; + + @JsonProperty("PSCAllCertificates") + private List pscAllCertificates; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/pscInspection/entity/PscAllCertificateEntity.java b/src/main/java/com/snp/batch/jobs/batch/pscInspection/entity/PscAllCertificateEntity.java new file mode 100644 index 0000000..c6c8a6b --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/pscInspection/entity/PscAllCertificateEntity.java @@ -0,0 +1,51 @@ +package com.snp.batch.jobs.batch.pscInspection.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDateTime; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class PscAllCertificateEntity extends BaseEntity { + + private String certificateId; + + private String typeId; + private String dataSetVersion; + + private String inspectionId; + private String lrno; + + private String certificateTitleCode; + private String certificateTitle; + + private String issuingAuthorityCode; + private String issuingAuthority; + + private String classSocOfIssuer; + private String otherIssuingAuthority; + + private LocalDateTime issueDate; + private LocalDateTime expiryDate; + private LocalDateTime lastSurveyDate; + + private String surveyAuthorityCode; + private String surveyAuthority; + private String otherSurveyAuthority; + + private String latestSurveyPlace; + private String latestSurveyPlaceCode; + + private String surveyAuthorityType; + + private String inspectionDate; + private String inspectedBy; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/pscInspection/entity/PscDefectEntity.java b/src/main/java/com/snp/batch/jobs/batch/pscInspection/entity/PscDefectEntity.java new file mode 100644 index 0000000..7054b84 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/pscInspection/entity/PscDefectEntity.java @@ -0,0 +1,56 @@ +package com.snp.batch.jobs.batch.pscInspection.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class PscDefectEntity extends BaseEntity { + + private String defectId; + + private String typeId; + private String dataSetVersion; + + private String action1; + private String action2; + private String action3; + private String actionCode1; + private String actionCode2; + private String actionCode3; + + private String amsaActionCode1; + private String amsaActionCode2; + private String amsaActionCode3; + + private String classIsResponsible; + + private String defectCode; + private String defectText; + + private String defectiveItemCode; + private String detentionReasonDeficiency; + + private String inspectionId; + + private String mainDefectCode; + private String mainDefectText; + + private String natureOfDefectCode; + private String natureOfDefectDecode; + + private String otherAction; + private String otherRecognisedOrgResp; + private String recognisedOrgResp; + private String recognisedOrgRespCode; + private String recognisedOrgRespYn; + + private String isAccidentalDamage; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/pscInspection/entity/PscInspectionEntity.java b/src/main/java/com/snp/batch/jobs/batch/pscInspection/entity/PscInspectionEntity.java new file mode 100644 index 0000000..abaf8d3 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/pscInspection/entity/PscInspectionEntity.java @@ -0,0 +1,63 @@ +package com.snp.batch.jobs.batch.pscInspection.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class PscInspectionEntity extends BaseEntity { + + private String typeId; + private String dataSetVersion; + private String authorisation; + private String callSign; + private String shipClass; + private String cargo; + private String charterer; + private String country; + private LocalDateTime inspectionDate; + private LocalDateTime releaseDate; + private String shipDetained; + private String deadWeight; + private String expandedInspection; + private String flag; + private String followUpInspection; + private String grossTonnage; + + private String inspectionId; + + private String inspectionPortCode; + private String inspectionPortDecode; + + private String keelLaid; + private LocalDateTime lastUpdated; + private String ihslrOrImoShipNo; + private String manager; + + private Integer numberOfDaysDetained; + private String numberOfDefects; + private BigDecimal numberOfPartDaysDetained; + + private String otherInspectionType; + private String owner; + private String shipName; + private String shipTypeCode; + private String shipTypeDecode; + private String source; + private String unlocode; + private String yearOfBuild; + + private List defects; + private List allCertificates; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/pscInspection/processor/PscInspectionProcessor.java b/src/main/java/com/snp/batch/jobs/batch/pscInspection/processor/PscInspectionProcessor.java new file mode 100644 index 0000000..ac0c5b6 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/pscInspection/processor/PscInspectionProcessor.java @@ -0,0 +1,252 @@ +package com.snp.batch.jobs.batch.pscInspection.processor; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.processor.BaseProcessor; +import com.snp.batch.jobs.batch.pscInspection.dto.PscAllCertificateDto; +import com.snp.batch.jobs.batch.pscInspection.dto.PscDefectDto; +import com.snp.batch.jobs.batch.pscInspection.dto.PscInspectionDto; +import com.snp.batch.jobs.batch.pscInspection.entity.PscAllCertificateEntity; +import com.snp.batch.jobs.batch.pscInspection.entity.PscDefectEntity; +import com.snp.batch.jobs.batch.pscInspection.entity.PscInspectionEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Component +public class PscInspectionProcessor extends BaseProcessor { + private static Long jobExecutionId; + private final ObjectMapper objectMapper; + public PscInspectionProcessor( + ObjectMapper objectMapper, + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId) { + this.objectMapper = objectMapper; + this.jobExecutionId = jobExecutionId; + } + @Override + public PscInspectionEntity processItem(PscInspectionDto item) throws Exception { + + PscInspectionEntity entity = new PscInspectionEntity(); + + entity.setTypeId(s(item.getTypeId())); + entity.setDataSetVersion(item.getDataSetVersion() != null ? s(item.getDataSetVersion().getDataSetVersion()) : null); + entity.setAuthorisation(s(item.getAuthorisation())); + entity.setCallSign(s(item.getCallSign())); + entity.setShipClass(s(item.getShipClass())); + entity.setCargo(s(item.getCargo())); + entity.setCharterer(s(item.getCharterer())); + entity.setCountry(s(item.getCountry())); + + entity.setInspectionDate(dt(item.getInspectionDate())); + entity.setReleaseDate(dt(item.getReleaseDate())); + entity.setShipDetained(s(item.getShipDetained())); + entity.setDeadWeight(s(item.getDeadWeight())); + + entity.setExpandedInspection(s(item.getExpandedInspection())); + entity.setFlag(s(item.getFlag())); + entity.setFollowUpInspection(s(item.getFollowUpInspection())); + entity.setGrossTonnage(s(item.getGrossTonnage())); + + entity.setInspectionId(s(item.getInspectionId())); + entity.setInspectionPortCode(s(item.getInspectionPortCode())); + entity.setInspectionPortDecode(s(item.getInspectionPortDecode())); + + entity.setKeelLaid(s(item.getKeelLaid())); + entity.setLastUpdated(dt(item.getLastUpdated())); + entity.setIhslrOrImoShipNo(s(item.getIhslrOrImoShipNo())); + entity.setManager(s(item.getManager())); + + entity.setNumberOfDaysDetained(i(item.getNumberOfDaysDetained())); + entity.setNumberOfDefects(s(item.getNumberOfDefects())); + entity.setNumberOfPartDaysDetained(bd(item.getNumberOfPartDaysDetained())); + + entity.setOtherInspectionType(s(item.getOtherInspectionType())); + entity.setOwner(s(item.getOwner())); + entity.setShipName(s(item.getShipName())); + entity.setShipTypeCode(s(item.getShipTypeCode())); + entity.setShipTypeDecode(s(item.getShipTypeDecode())); + entity.setSource(s(item.getSource())); + entity.setUnlocode(s(item.getUnlocode())); + entity.setYearOfBuild(s(item.getYearOfBuild())); + entity.setJobExecutionId(jobExecutionId); + entity.setCreatedBy("SYSTEM"); + + // 리스트 null-safe + entity.setDefects(item.getPscDefects() == null ? List.of() : convertDefectDtos(item.getPscDefects())); + entity.setAllCertificates(item.getPscAllCertificates() == null ? List.of() : convertAllCertificateDtos(item.getPscAllCertificates())); + + + return entity; + } + + + /** ----------------------- 공통 메서드 ----------------------- */ + + private String s(Object v) { + return (v == null) ? null : v.toString().trim(); + } + + private Boolean b(Object v) { + if (v == null) return null; + String s = v.toString().trim().toLowerCase(); + if (s.equals("true") || s.equals("t") || s.equals("1")) return true; + if (s.equals("false") || s.equals("f") || s.equals("0")) return false; + return null; + } + private BigDecimal bd(Object v) { + if (v == null) return null; + try { + return new BigDecimal(v.toString().trim()); + } catch (Exception e) { + return null; + } + } + private Integer i(Object v) { + if (v == null) return null; + try { + return Integer.parseInt(v.toString().trim()); + } catch (Exception e) { + return null; + } + } + + private Double d(Object v) { + if (v == null) return null; + try { + return Double.parseDouble(v.toString().trim()); + } catch (Exception e) { + return null; + } + } + + private LocalDateTime dt(String dateStr) { + if (dateStr == null || dateStr.isBlank()) return null; + + // 가장 흔한 ISO 형태 + try { + return LocalDateTime.parse(dateStr); + } catch (Exception ignored) {} + + // yyyy-MM-dd + try { + return LocalDate.parse(dateStr).atStartOfDay(); + } catch (Exception ignored) {} + + // yyyy-MM-dd HH:mm:ss + try { + return LocalDateTime.parse(dateStr, + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + } catch (Exception ignored) {} + + // yyyy-MM-ddTHH:mm:ssZ 형태 + try { + return OffsetDateTime.parse(dateStr).toLocalDateTime(); + } catch (Exception ignored) {} + + log.warn("⚠️ 날짜 변환 실패 → {}", dateStr); + return null; + } + + public static List convertDefectDtos(List dtos) { + if (dtos == null || dtos.isEmpty()) return List.of(); + + return dtos.stream() + .map(dto -> PscDefectEntity.builder() + .defectId(dto.getDefectId()) + .inspectionId(dto.getInspectionId()) + .typeId(dto.getTypeId()) + .dataSetVersion(dto.getDataSetVersion() != null ? dto.getDataSetVersion().getDataSetVersion() : null) + .action1(dto.getAction1()) + .action2(dto.getAction2()) + .action3(dto.getAction3()) + .actionCode1(dto.getActionCode1()) + .actionCode2(dto.getActionCode2()) + .actionCode3(dto.getActionCode3()) + .amsaActionCode1(dto.getAmsaActionCode1()) + .amsaActionCode2(dto.getAmsaActionCode2()) + .amsaActionCode3(dto.getAmsaActionCode3()) + .classIsResponsible(dto.getClassIsResponsible()) + .defectCode(dto.getDefectCode()) + .defectText(dto.getDefectText()) + .defectiveItemCode(dto.getDefectiveItemCode()) + .detentionReasonDeficiency(dto.getDetentionReasonDeficiency()) + .mainDefectCode(dto.getMainDefectCode()) + .mainDefectText(dto.getMainDefectText()) + .natureOfDefectCode(dto.getNatureOfDefectCode()) + .natureOfDefectDecode(dto.getNatureOfDefectDecode()) + .otherAction(dto.getOtherAction()) + .otherRecognisedOrgResp(dto.getOtherRecognisedOrgResp()) + .recognisedOrgResp(dto.getRecognisedOrgResp()) + .recognisedOrgRespCode(dto.getRecognisedOrgRespCode()) + .recognisedOrgRespYn(dto.getRecognisedOrgRespYn()) + .isAccidentalDamage(dto.getIsAccidentalDamage()) + .jobExecutionId(jobExecutionId) + .createdBy("SYSTEM") + .build()) + .collect(Collectors.toList()); + } + + public static List convertAllCertificateDtos(List dtos) { + if (dtos == null || dtos.isEmpty()) return List.of(); + + return dtos.stream() + .map(dto -> PscAllCertificateEntity.builder() + .certificateId(dto.getCertificateId()) + .typeId(dto.getTypeId()) + .dataSetVersion(dto.getDataSetVersion() != null ? dto.getDataSetVersion().getDataSetVersion() : null) + .inspectionId(dto.getInspectionId()) + .lrno(dto.getLrno()) + .certificateTitleCode(dto.getCertificateTitleCode()) + .certificateTitle(dto.getCertificateTitle()) + .issuingAuthorityCode(dto.getIssuingAuthorityCode()) + .issuingAuthority(dto.getIssuingAuthority()) + .classSocOfIssuer(dto.getClassSocOfIssuer()) + .otherIssuingAuthority(dto.getOtherIssuingAuthority()) + .issueDate(dto.getIssueDate() != null ? parseFlexible(dto.getIssueDate()) : null) + .expiryDate(dto.getExpiryDate() != null ? parseFlexible(dto.getExpiryDate()) : null) + .lastSurveyDate(dto.getLastSurveyDate() != null ? parseFlexible(dto.getLastSurveyDate()) : null) + .surveyAuthorityCode(dto.getSurveyAuthorityCode()) + .surveyAuthority(dto.getSurveyAuthority()) + .otherSurveyAuthority(dto.getOtherSurveyAuthority()) + .latestSurveyPlace(dto.getLatestSurveyPlace()) + .latestSurveyPlaceCode(dto.getLatestSurveyPlaceCode()) + .surveyAuthorityType(dto.getSurveyAuthorityType()) + .inspectionDate(dto.getInspectionDate()) + .inspectedBy(dto.getInspectedBy()) + .jobExecutionId(jobExecutionId) + .createdBy("SYSTEM") + .build()) + .collect(Collectors.toList()); + } + + private static final List FORMATTERS = Arrays.asList( + DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss"), + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"), + DateTimeFormatter.ISO_LOCAL_DATE_TIME + ); + + public static LocalDateTime parseFlexible(String dateStr) { + if (dateStr == null || dateStr.isEmpty()) return null; + + for (DateTimeFormatter formatter : FORMATTERS) { + try { + return LocalDateTime.parse(dateStr, formatter); + } catch (DateTimeParseException ignored) { + // 포맷 실패 시 다음 시도 + } + } + // 모두 실패 시 null 반환 + System.err.println("날짜 파싱 실패: " + dateStr); + return null; + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/pscInspection/reader/PscApiReader.java b/src/main/java/com/snp/batch/jobs/batch/pscInspection/reader/PscApiReader.java new file mode 100644 index 0000000..91a16d0 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/pscInspection/reader/PscApiReader.java @@ -0,0 +1,139 @@ +package com.snp.batch.jobs.batch.pscInspection.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.batch.pscInspection.dto.PscApiResponseDto; +import com.snp.batch.jobs.batch.pscInspection.dto.PscInspectionDto; +import com.snp.batch.service.BatchApiLogService; +import com.snp.batch.service.BatchDateService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.reactive.function.client.WebClient; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +@Slf4j +@StepScope +public class PscApiReader extends BaseApiReader { + private final BatchDateService batchDateService; // ✨ BatchDateService 필드 추가 + private final BatchApiLogService batchApiLogService; + private final String maritimeApiUrl; + private List allData; + private int currentBatchIndex = 0; + private final int batchSize = 5000; + + private final JdbcTemplate jdbcTemplate; + public PscApiReader(WebClient webClient, JdbcTemplate jdbcTemplate, BatchDateService batchDateService, BatchApiLogService batchApiLogService, String maritimeApiUrl) { + super(webClient); + this.jdbcTemplate = jdbcTemplate; + this.batchDateService = batchDateService; + this.batchApiLogService = batchApiLogService; + this.maritimeApiUrl = maritimeApiUrl; + enableChunkMode(); // ✨ Chunk 모드 활성화 + } + + @Override + protected String getReaderName() { + return "PscApiReader"; + } + @Override + protected String getApiPath() { + return "/MaritimeWCF/PSCService.svc/RESTFul/GetPSCDataByLastUpdateDateRange"; + } + + protected String getApiKey() { + return "PSC_IMPORT_API"; + } + + @Override + protected void resetCustomState() { + this.currentBatchIndex = 0; + this.allData = null; + } + + @Override + protected List fetchNextBatch() { + + // 1) 처음 호출이면 API 한 번 호출해서 전체 데이터를 가져온다 + if (allData == null) { + allData = callApiWithBatch(); + + if (allData == null || allData.isEmpty()) { + log.warn("[PSC] 조회된 데이터 없음 → 종료"); + return null; + } + + log.info("[PSC] 총 {}건 데이터 조회됨. batchSize = {}", allData.size(), batchSize); + } + + // 2) 이미 끝까지 읽었으면 종료 + if (currentBatchIndex >= allData.size()) { + log.info("[PSC] 모든 배치 처리 완료"); + return null; // Step 종료 신호 + } + + // 3) 이번 배치의 end 계산 + int end = Math.min(currentBatchIndex + batchSize, allData.size()); + + // 4) 현재 batch 리스트 잘라서 반환 + List batch = allData.subList(currentBatchIndex, end); + + int batchNum = (currentBatchIndex / batchSize) + 1; + int totalBatches = (int) Math.ceil((double) allData.size() / batchSize); + + log.info("[PSC] 배치 {}/{} 처리 중: {}건", batchNum, totalBatches, batch.size()); + + // 다음 batch 인덱스 이동 + currentBatchIndex = end; + + return batch; + } + + private List callApiWithBatch() { + Map params = batchDateService.getDateRangeWithoutTimeParams(getApiKey()); + // 1. 단일 객체 응답 API 호출 + PscApiResponseDto response = executeSingleApiCall( + maritimeApiUrl, + getApiPath(), + params, + new ParameterizedTypeReference() {}, + batchApiLogService, + res -> res.getInspections() != null ? (long) res.getInspections().size() : 0L // 람다 적용 + ); + // 2. Inspections Array 데이터 추출 + if (response != null && response.getInspections() != null) { + log.info("[{}] PSC 데이터 추출 성공 - 건수: {}", getReaderName(), response.getInspections().size()); + return response.getInspections(); + } + + return Collections.emptyList(); + } + + @Override + protected void afterFetch(List data) { + if (data == null) { + int totalBatches = (int) Math.ceil((double) allData.size() / batchSize); + log.info("[{}] 전체 {} 개 배치 처리 완료", getReaderName(), totalBatches); + } + } + + private LocalDateTime parseToDateTime(String value, boolean isStart) { + + // yyyy-MM-dd 인 경우 + if (value.length() == 10) { + LocalDate date = LocalDate.parse(value); + return isStart + ? date.atStartOfDay() + : date.plusDays(1).atStartOfDay(); + } + + // yyyy-MM-ddTHH:mm:ssZ 인 경우 + return OffsetDateTime.parse(value).toLocalDateTime(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/pscInspection/repository/PscAllCertificateRepository.java b/src/main/java/com/snp/batch/jobs/batch/pscInspection/repository/PscAllCertificateRepository.java new file mode 100644 index 0000000..415f17e --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/pscInspection/repository/PscAllCertificateRepository.java @@ -0,0 +1,9 @@ +package com.snp.batch.jobs.batch.pscInspection.repository; + +import com.snp.batch.jobs.batch.pscInspection.entity.PscAllCertificateEntity; + +import java.util.List; + +public interface PscAllCertificateRepository { + void saveAllCertificates(List certificates); +} diff --git a/src/main/java/com/snp/batch/jobs/batch/pscInspection/repository/PscAllCertificateRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/batch/pscInspection/repository/PscAllCertificateRepositoryImpl.java new file mode 100644 index 0000000..780847e --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/pscInspection/repository/PscAllCertificateRepositoryImpl.java @@ -0,0 +1,135 @@ +package com.snp.batch.jobs.batch.pscInspection.repository; + +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.batch.pscInspection.entity.PscAllCertificateEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.Timestamp; +import java.sql.Types; +import java.util.List; + +@Slf4j +@Repository +public class PscAllCertificateRepositoryImpl extends BaseJdbcRepository + implements PscAllCertificateRepository { + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + + @Value("${app.batch.target-schema.tables.psc-003}") + private String tableName; + + public PscAllCertificateRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + protected String getTargetSchema() { + return targetSchema; + } + + @Override + protected String getSimpleTableName() { + return tableName; + } + + @Override + protected RowMapper getRowMapper() { + return null; + } + + @Override + protected String getEntityName() { + return "PscAllCertificate"; + } + + @Override + protected String extractId(PscAllCertificateEntity entity) { + return entity.getCertificateId(); + } + + @Override + public String getInsertSql() { + return """ + INSERT INTO %s(""".formatted(getTableName()) + """ + cert_id, + dataset_ver, + inspection_id, + imo_no, + certf_nm_cd, + certf_nm, + issue_engines_cd, + issue_engines, + etc_issue_engines, + issue_ymd, + expry_ymd, + last_inspection_ymd, + inspection_engines_cd, + inspection_engines, + etc_inspection_engines, + recent_inspection_plc, + recent_inspection_plc_cd, + inspection_engines_type, + check_ymd, + insptr, + job_execution_id, creatr_id + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ? + ); + """; + + } + + @Override + protected String getUpdateSql() { + return null; + } + + @Override + protected void setInsertParameters(PreparedStatement ps, PscAllCertificateEntity e) throws Exception { + int i = 1; + + ps.setString(i++, e.getCertificateId()); + ps.setString(i++, e.getDataSetVersion()); + ps.setString(i++, e.getInspectionId()); + ps.setString(i++, e.getLrno()); + ps.setString(i++, e.getCertificateTitleCode()); + ps.setString(i++, e.getCertificateTitle()); + ps.setString(i++, e.getIssuingAuthorityCode()); + ps.setString(i++, e.getIssuingAuthority()); + ps.setString(i++, e.getOtherIssuingAuthority()); + ps.setTimestamp(i++, e.getIssueDate() != null ? Timestamp.valueOf(e.getIssueDate()) : null); + ps.setTimestamp(i++, e.getExpiryDate() != null ? Timestamp.valueOf(e.getExpiryDate()) : null); + ps.setTimestamp(i++, e.getLastSurveyDate() != null ? Timestamp.valueOf(e.getLastSurveyDate()) : null); + ps.setString(i++, e.getSurveyAuthorityCode()); + ps.setString(i++, e.getSurveyAuthority()); + ps.setString(i++, e.getOtherSurveyAuthority()); + ps.setString(i++, e.getLatestSurveyPlace()); + ps.setString(i++, e.getLatestSurveyPlaceCode()); + ps.setString(i++, e.getSurveyAuthorityType()); + ps.setString(i++, e.getInspectionDate()); + ps.setString(i++, e.getInspectedBy()); + ps.setObject(i++, e.getJobExecutionId(), Types.INTEGER); + ps.setString(i++, e.getCreatedBy()); + } + + @Override + protected void setUpdateParameters(PreparedStatement ps, PscAllCertificateEntity entity) throws Exception { + + } + + @Override + public void saveAllCertificates(List entities) { + if (entities == null || entities.isEmpty()) return; +// log.info("PSC AllCertificates 저장 시작 = {}건", entities.size()); + batchInsert(entities); + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/pscInspection/repository/PscDefectRepository.java b/src/main/java/com/snp/batch/jobs/batch/pscInspection/repository/PscDefectRepository.java new file mode 100644 index 0000000..c97eaeb --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/pscInspection/repository/PscDefectRepository.java @@ -0,0 +1,10 @@ +package com.snp.batch.jobs.batch.pscInspection.repository; + +import com.snp.batch.jobs.batch.pscInspection.entity.PscDefectEntity; +import com.snp.batch.jobs.batch.pscInspection.entity.PscInspectionEntity; + +import java.util.List; + +public interface PscDefectRepository { + void saveDefects(List defects); +} diff --git a/src/main/java/com/snp/batch/jobs/batch/pscInspection/repository/PscDefectRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/batch/pscInspection/repository/PscDefectRepositoryImpl.java new file mode 100644 index 0000000..7664742 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/pscInspection/repository/PscDefectRepositoryImpl.java @@ -0,0 +1,145 @@ +package com.snp.batch.jobs.batch.pscInspection.repository; + +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.batch.pscInspection.entity.PscDefectEntity; +import com.snp.batch.jobs.batch.pscInspection.entity.PscInspectionEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.Timestamp; +import java.sql.Types; +import java.util.List; + +@Slf4j +@Repository +public class PscDefectRepositoryImpl extends BaseJdbcRepository + implements PscDefectRepository { + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + + @Value("${app.batch.target-schema.tables.psc-002}") + private String tableName; + + public PscDefectRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + protected String getTargetSchema() { + return targetSchema; + } + + @Override + protected String getSimpleTableName() { + return tableName; + } + + @Override + protected RowMapper getRowMapper() { + return null; + } + + @Override + protected String getEntityName() { + return "PscDefect"; + } + + @Override + protected String extractId(PscDefectEntity entity) { + return entity.getInspectionId(); + } + + @Override + public String getInsertSql() { + return """ + INSERT INTO %s(""".formatted(getTableName()) + """ + defect_id, + inspection_id, + dataset_ver, + actn_one, + actn_two, + actn_thr, + actn_cd_one, + actn_cd_two, + actn_cd_thr, + clfic_respsb_yn, + defect_cd, + defect_cn, + defect_iem_cd, + detained_reason_defect, + main_defect_cd, + main_defect_cn, + defect_type_cd, + defect_type_nm, + etc_actn, + etc_pubc_engines_respsb, + pubc_engines_respsb, + pubc_engines_respsb_cd, + pubc_engines_respsb_yn, + acdnt_damg_yn, + job_execution_id, creatr_id + ) VALUES ( + ?,?,?,?,?,?,?,?,?,?, + ?,?,?,?,?,?,?,?,?,?, + ?,?,?,?, + ?,? + ); + """; + + } + + @Override + protected String getUpdateSql() { + return null; + } + + @Override + protected void setInsertParameters(PreparedStatement ps, PscDefectEntity e) throws Exception { + int i = 1; + + ps.setString(i++, e.getDefectId()); + ps.setString(i++, e.getInspectionId()); + ps.setString(i++, e.getDataSetVersion()); + ps.setString(i++, e.getAction1()); + ps.setString(i++, e.getAction2()); + ps.setString(i++, e.getAction3()); + ps.setString(i++, e.getActionCode1()); + ps.setString(i++, e.getActionCode2()); + ps.setString(i++, e.getActionCode3()); + ps.setString(i++, e.getClassIsResponsible()); + ps.setString(i++, e.getDefectCode()); + ps.setString(i++, e.getDefectText()); + ps.setString(i++, e.getDefectiveItemCode()); + ps.setString(i++, e.getDetentionReasonDeficiency()); + ps.setString(i++, e.getMainDefectCode()); + ps.setString(i++, e.getMainDefectText()); + ps.setString(i++, e.getNatureOfDefectCode()); + ps.setString(i++, e.getNatureOfDefectDecode()); + ps.setString(i++, e.getOtherAction()); + ps.setString(i++, e.getOtherRecognisedOrgResp()); + ps.setString(i++, e.getRecognisedOrgResp()); + ps.setString(i++, e.getRecognisedOrgRespCode()); + ps.setString(i++, e.getRecognisedOrgRespYn()); + ps.setString(i++, e.getIsAccidentalDamage()); + ps.setObject(i++, e.getJobExecutionId(), Types.INTEGER); + ps.setString(i++, e.getCreatedBy()); + } + + @Override + protected void setUpdateParameters(PreparedStatement ps, PscDefectEntity entity) throws Exception { + + } + + @Override + public void saveDefects(List entities) { + if (entities == null || entities.isEmpty()) return; +// log.info("PSC Defect 저장 시작 = {}건", entities.size()); + batchInsert(entities); + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/pscInspection/repository/PscInspectionRepository.java b/src/main/java/com/snp/batch/jobs/batch/pscInspection/repository/PscInspectionRepository.java new file mode 100644 index 0000000..16dc7af --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/pscInspection/repository/PscInspectionRepository.java @@ -0,0 +1,9 @@ +package com.snp.batch.jobs.batch.pscInspection.repository; + +import com.snp.batch.jobs.batch.pscInspection.entity.PscInspectionEntity; + +import java.util.List; + +public interface PscInspectionRepository { + void saveAll(List entities); +} diff --git a/src/main/java/com/snp/batch/jobs/batch/pscInspection/repository/PscInspectionRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/batch/pscInspection/repository/PscInspectionRepositoryImpl.java new file mode 100644 index 0000000..b649ae5 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/pscInspection/repository/PscInspectionRepositoryImpl.java @@ -0,0 +1,159 @@ +package com.snp.batch.jobs.batch.pscInspection.repository; + +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.batch.pscInspection.entity.PscInspectionEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.Timestamp; +import java.sql.Types; +import java.util.List; + +@Slf4j +@Repository +public class PscInspectionRepositoryImpl extends BaseJdbcRepository + implements PscInspectionRepository{ + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + + @Value("${app.batch.target-schema.tables.psc-001}") + private String tableName; + + public PscInspectionRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + protected String getTargetSchema() { + return targetSchema; + } + + @Override + protected String getSimpleTableName() { + return tableName; + } + + @Override + protected String getEntityName() { + return "PscInspection"; + } + + @Override + protected String extractId(PscInspectionEntity entity) { + return entity.getInspectionId(); + } + + @Override + public String getInsertSql() { + return """ + INSERT INTO %s(""".formatted(getTableName()) + """ + inspection_id, + dataset_ver, + type_id, + clsgn_no, + clfic, + chrter, + country, + inspection_ymd, + tkoff_prmt_ymd, + ship_detained_yn, + dwt, + expnd_inspection_yn, + flg, + folw_inspection_yn, + gt, + inspection_port_nm, + last_mdfcn_dt, + imo_no, + ship_mngr, + detained_days, + defect_cnt, + defect_cnt_days, + etc_inspection_type, + shponr, + ship_nm, + ship_type_cd, + ship_type_nm, + data_src, + un_port_cd, + build_yy, + job_execution_id, creatr_id + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ? + ); + """; + + } + + @Override + protected void setInsertParameters(PreparedStatement ps, PscInspectionEntity e) throws Exception { + int i = 1; + + ps.setString(i++, e.getInspectionId()); + ps.setString(i++, e.getDataSetVersion()); + ps.setString(i++, e.getAuthorisation()); + ps.setString(i++, e.getCallSign()); + ps.setString(i++, e.getShipClass()); + ps.setString(i++, e.getCharterer()); + ps.setString(i++, e.getCountry()); + ps.setTimestamp(i++, e.getInspectionDate() != null ? Timestamp.valueOf(e.getInspectionDate()) : null); + ps.setTimestamp(i++, e.getReleaseDate() != null ? Timestamp.valueOf(e.getReleaseDate()) : null); + ps.setString(i++, e.getShipDetained()); + ps.setString(i++, e.getDeadWeight()); + ps.setString(i++, e.getExpandedInspection()); + ps.setString(i++, e.getFlag()); + ps.setString(i++, e.getFollowUpInspection()); + ps.setString(i++, e.getGrossTonnage()); + ps.setString(i++, e.getInspectionPortDecode()); + ps.setTimestamp(i++, e.getLastUpdated() != null ? Timestamp.valueOf(e.getLastUpdated()) : null); + ps.setString(i++, e.getIhslrOrImoShipNo()); + ps.setString(i++, e.getManager()); + if (e.getNumberOfDaysDetained() != null) { + ps.setInt(i++, e.getNumberOfDaysDetained()); + } else { + ps.setNull(i++, Types.INTEGER); + } + ps.setString(i++, e.getNumberOfDefects()); + ps.setBigDecimal(i++, e.getNumberOfPartDaysDetained()); + ps.setString(i++, e.getOtherInspectionType()); + ps.setString(i++, e.getOwner()); + ps.setString(i++, e.getShipName()); + ps.setString(i++, e.getShipTypeCode()); + ps.setString(i++, e.getShipTypeDecode()); + ps.setString(i++, e.getSource()); + ps.setString(i++, e.getUnlocode()); + ps.setString(i++, e.getYearOfBuild()); + ps.setObject(i++, e.getJobExecutionId(), Types.INTEGER); + ps.setString(i++, e.getCreatedBy()); + } + + @Override + public void saveAll(List entities) { + if (entities == null || entities.isEmpty()) return; +// log.info("PSC Inspection 저장 시작 = {}건", entities.size()); + batchInsert(entities); + } + + @Override + protected void setUpdateParameters(PreparedStatement ps, PscInspectionEntity entity) throws Exception { + + } + + @Override + protected String getUpdateSql() { + return null; + } + + @Override + protected RowMapper getRowMapper() { + return null; + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/pscInspection/writer/PscInspectionWriter.java b/src/main/java/com/snp/batch/jobs/batch/pscInspection/writer/PscInspectionWriter.java new file mode 100644 index 0000000..2b442d7 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/pscInspection/writer/PscInspectionWriter.java @@ -0,0 +1,49 @@ +package com.snp.batch.jobs.batch.pscInspection.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.batch.pscInspection.entity.PscInspectionEntity; +import com.snp.batch.jobs.batch.pscInspection.repository.PscAllCertificateRepository; +import com.snp.batch.jobs.batch.pscInspection.repository.PscDefectRepository; +import com.snp.batch.jobs.batch.pscInspection.repository.PscInspectionRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Slf4j +@Component +public class PscInspectionWriter extends BaseWriter { + private final PscInspectionRepository pscInspectionRepository; + private final PscDefectRepository pscDefectRepository; + private final PscAllCertificateRepository pscAllCertificateRepository; + + public PscInspectionWriter(PscInspectionRepository pscInspectionRepository, + PscDefectRepository pscDefectRepository, + PscAllCertificateRepository pscAllCertificateRepository) { + super("PscInspection"); + this.pscInspectionRepository = pscInspectionRepository; + this.pscDefectRepository = pscDefectRepository; + this.pscAllCertificateRepository = pscAllCertificateRepository; + } + + @Override + protected void writeItems(List items) throws Exception { + + if (items == null || items.isEmpty()) return; + //pscInspectionRepository.saveAll(items); + log.info("PSC Inspection 저장: {} 건", items.size()); + + for (PscInspectionEntity entity : items) { + pscInspectionRepository.saveAll(List.of(entity)); + pscDefectRepository.saveDefects(entity.getDefects()); + pscAllCertificateRepository.saveAllCertificates(entity.getAllCertificates()); + + // 효율적으로 로그 + int defectCount = entity.getDefects() != null ? entity.getDefects().size() : 0; + int allCertificateCount = entity.getAllCertificates() != null ? entity.getAllCertificates().size() : 0; + + log.info("Inspection ID: {}, Defects: {}, AllCertificates: {}", + entity.getInspectionId(), defectCount, allCertificateCount); + } + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/risk/config/RiskDetailImoFetchTasklet.java b/src/main/java/com/snp/batch/jobs/batch/risk/config/RiskDetailImoFetchTasklet.java new file mode 100644 index 0000000..94ee1eb --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/risk/config/RiskDetailImoFetchTasklet.java @@ -0,0 +1,50 @@ +package com.snp.batch.jobs.batch.risk.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.List; + +/** + * Risk 상세 데이터 수집을 위한 IMO 목록 조회 Tasklet. + * std_snp_data.tb_ship_default_info 테이블에서 전체 imo_no를 오름차순으로 조회하여 + * JobExecutionContext에 저장. + */ +@Slf4j +public class RiskDetailImoFetchTasklet implements Tasklet { + + private final JdbcTemplate jdbcTemplate; + private final String targetSchema; + + public RiskDetailImoFetchTasklet(JdbcTemplate jdbcTemplate, String targetSchema) { + this.jdbcTemplate = jdbcTemplate; + this.targetSchema = targetSchema; + } + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + JobExecution jobExecution = chunkContext.getStepContext() + .getStepExecution().getJobExecution(); + + String sql = String.format( + "SELECT DISTINCT imo_no FROM %s.tb_ship_default_info WHERE imo_no IS NOT NULL ORDER BY imo_no ASC", + targetSchema); + + List imoNumbers = jdbcTemplate.queryForList(sql, String.class); + + jobExecution.getExecutionContext().putInt("totalImoCount", imoNumbers.size()); + if (!imoNumbers.isEmpty()) { + jobExecution.getExecutionContext().putString("allImoNumbers", String.join(",", imoNumbers)); + } + + log.info("[RiskDetailImoFetchTasklet] IMO {} 건 조회 완료 (from {}.tb_ship_default_info)", + imoNumbers.size(), targetSchema); + + return RepeatStatus.FINISHED; + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/risk/config/RiskDetailImportJobConfig.java b/src/main/java/com/snp/batch/jobs/batch/risk/config/RiskDetailImportJobConfig.java new file mode 100644 index 0000000..44768a5 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/risk/config/RiskDetailImportJobConfig.java @@ -0,0 +1,218 @@ +package com.snp.batch.jobs.batch.risk.config; + +import com.snp.batch.common.batch.config.BasePartitionedJobConfig; +import com.snp.batch.common.batch.partition.StringListPartitioner; +import com.snp.batch.common.batch.tasklet.LastExecutionUpdateTasklet; +import com.snp.batch.jobs.batch.risk.dto.RiskDto; +import com.snp.batch.jobs.batch.risk.entity.RiskEntity; +import com.snp.batch.jobs.batch.risk.processor.RiskDataProcessor; +import com.snp.batch.jobs.batch.risk.reader.RiskDetailDataReader; +import com.snp.batch.jobs.batch.risk.writer.RiskDetailDataWriter; +import com.snp.batch.service.BatchApiLogService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.job.flow.JobExecutionDecider; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.TaskExecutor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +@Slf4j +@Configuration +public class RiskDetailImportJobConfig extends BasePartitionedJobConfig { + + private final RiskDetailDataWriter riskDetailDataWriter; + private final JdbcTemplate jdbcTemplate; + private final WebClient maritimeServiceApiWebClient; + private final BatchApiLogService batchApiLogService; + private final TaskExecutor batchPartitionExecutor; + + @Value("${app.batch.webservice-api.url}") + private String apiUrl; + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + + @Value("${app.batch.risk-detail.partition-count:4}") + private int partitionCount; + + @Value("${app.batch.risk-detail.delay-on-success-ms:300}") + private long delayOnSuccessMs; + + @Value("${app.batch.risk-detail.delay-on-failure-ms:2000}") + private long delayOnFailureMs; + + @Value("${app.batch.last-execution-buffer-hours:24}") + private int lastExecutionBufferHours; + + public RiskDetailImportJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + RiskDetailDataWriter riskDetailDataWriter, + JdbcTemplate jdbcTemplate, + @Qualifier("maritimeServiceApiWebClient") WebClient maritimeServiceApiWebClient, + BatchApiLogService batchApiLogService, + @Qualifier("batchPartitionExecutor") TaskExecutor batchPartitionExecutor) { + super(jobRepository, transactionManager); + this.riskDetailDataWriter = riskDetailDataWriter; + this.jdbcTemplate = jdbcTemplate; + this.maritimeServiceApiWebClient = maritimeServiceApiWebClient; + this.batchApiLogService = batchApiLogService; + this.batchPartitionExecutor = batchPartitionExecutor; + } + + protected String getApiKey() { + return "RISK_DETAIL_IMPORT_API"; + } + + @Override + protected String getJobName() { + return "RiskDetailImportJob"; + } + + @Override + protected String getStepName() { + return "RiskDetailImportStep"; + } + + @Override + protected int getChunkSize() { + return 5000; + } + + // ======================================== + // Job Flow 정의 + // ======================================== + + @Override + protected Job createJobFlow(JobBuilder jobBuilder) { + return jobBuilder + .start(riskDetailImoFetchStep()) + .next(riskDetailImoCountDecider()) + .on("EMPTY_RESPONSE").end() + .from(riskDetailImoCountDecider()).on("NORMAL").to(riskDetailPartitionedStep()) + .next(riskDetailLastExecutionUpdateStep()) + .end() + .build(); + } + + // ======================================== + // Step 0: IMO 목록 조회 + // ======================================== + + @Bean + public Tasklet riskDetailImoFetchTasklet() { + return new RiskDetailImoFetchTasklet(jdbcTemplate, targetSchema); + } + + @Bean(name = "RiskDetailImoFetchStep") + public Step riskDetailImoFetchStep() { + return new StepBuilder("RiskDetailImoFetchStep", jobRepository) + .tasklet(riskDetailImoFetchTasklet(), transactionManager) + .build(); + } + + // ======================================== + // Decider: IMO 건수 확인 + // ======================================== + + @Bean + public JobExecutionDecider riskDetailImoCountDecider() { + return createKeyCountDecider("totalImoCount", getJobName()); + } + + // ======================================== + // Step 1: Partitioned Step (병렬 처리) + // ======================================== + + @Bean + @StepScope + public StringListPartitioner riskDetailPartitioner( + @Value("#{jobExecutionContext['allImoNumbers']}") String allImoNumbersStr) { + List allImoNumbers = (allImoNumbersStr != null && !allImoNumbersStr.isBlank()) + ? Arrays.asList(allImoNumbersStr.split(",")) + : Collections.emptyList(); + return new StringListPartitioner(allImoNumbers, partitionCount, "partitionImoNumbers"); + } + + @Bean(name = "RiskDetailPartitionedStep") + public Step riskDetailPartitionedStep() { + return createPartitionedStep( + "RiskDetailPartitionedStep", "RiskDetailWorkerStep", + riskDetailPartitioner(null), riskDetailWorkerStep(), + batchPartitionExecutor, partitionCount); + } + + @Bean + public Step riskDetailWorkerStep() { + return new StepBuilder("RiskDetailWorkerStep", jobRepository) + .chunk(getChunkSize(), transactionManager) + .reader(riskDetailDataReader(null, null, null)) + .processor(riskDetailDataProcessor(null)) + .writer(riskDetailDataWriter) + .build(); + } + + // ======================================== + // Reader / Processor (StepScope) + // ======================================== + + @Bean + @StepScope + public RiskDetailDataReader riskDetailDataReader( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, + @Value("#{stepExecution.id}") Long stepExecutionId, + @Value("#{stepExecutionContext['partitionImoNumbers']}") String partitionImoNumbers) { + RiskDetailDataReader reader = new RiskDetailDataReader( + maritimeServiceApiWebClient, batchApiLogService, apiUrl, + delayOnSuccessMs, delayOnFailureMs); + reader.setExecutionIds(jobExecutionId, stepExecutionId); + reader.setPartitionImoNumbers(partitionImoNumbers); + return reader; + } + + @Bean + @StepScope + public RiskDataProcessor riskDetailDataProcessor( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId) { + return new RiskDataProcessor(jobExecutionId); + } + + // ======================================== + // Job Bean 등록 + // ======================================== + + @Bean(name = "RiskDetailImportJob") + public Job riskDetailImportJob() { + return job(); + } + + // ======================================== + // Step 2: LastExecution 업데이트 + // ======================================== + + @Bean + public Tasklet riskDetailLastExecutionUpdateTasklet() { + return new LastExecutionUpdateTasklet(jdbcTemplate, targetSchema, getApiKey(), lastExecutionBufferHours); + } + + @Bean(name = "RiskDetailLastExecutionUpdateStep") + public Step riskDetailLastExecutionUpdateStep() { + return createLastExecutionUpdateStep("RiskDetailLastExecutionUpdateStep", + riskDetailLastExecutionUpdateTasklet()); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/risk/config/RiskImportRangeJobConfig.java b/src/main/java/com/snp/batch/jobs/batch/risk/config/RiskImportRangeJobConfig.java new file mode 100644 index 0000000..34de74e --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/risk/config/RiskImportRangeJobConfig.java @@ -0,0 +1,165 @@ +package com.snp.batch.jobs.batch.risk.config; + +import com.snp.batch.common.batch.config.BaseMultiStepJobConfig; +import com.snp.batch.common.batch.tasklet.LastExecutionUpdateTasklet; +import com.snp.batch.jobs.batch.risk.dto.RiskDto; +import com.snp.batch.jobs.batch.risk.entity.RiskEntity; +import com.snp.batch.jobs.batch.risk.processor.RiskDataProcessor; +import com.snp.batch.jobs.batch.risk.reader.RiskDataRangeReader; +import com.snp.batch.jobs.batch.risk.writer.RiskDetailDataWriter; +import com.snp.batch.service.BatchApiLogService; +import com.snp.batch.service.BatchDateService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.job.flow.FlowExecutionStatus; +import org.springframework.batch.core.job.flow.JobExecutionDecider; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.reactive.function.client.WebClient; + +@Slf4j +@Configuration +public class RiskImportRangeJobConfig extends BaseMultiStepJobConfig { + private final WebClient maritimeServiceApiWebClient; + private final RiskDataProcessor riskDataProcessor; + private final RiskDetailDataWriter riskDataWriter; + private final RiskDataRangeReader riskDataRangeReader; + private final JdbcTemplate jdbcTemplate; + private final BatchDateService batchDateService; + private final BatchApiLogService batchApiLogService; + + @Value("${app.batch.webservice-api.url}") + private String maritimeServiceApiUrl; + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + + @Value("${app.batch.last-execution-buffer-hours:24}") + private int lastExecutionBufferHours; + + protected String getApiKey() {return "RISK_IMPORT_API";} + + + @Override + protected int getChunkSize() { + return 5000; + } + public RiskImportRangeJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + RiskDataProcessor riskDataProcessor, + RiskDetailDataWriter riskDataWriter, + JdbcTemplate jdbcTemplate, + @Qualifier("maritimeServiceApiWebClient")WebClient maritimeServiceApiWebClient, + RiskDataRangeReader riskDataRangeReader, + BatchDateService batchDateService, + BatchApiLogService batchApiLogService) { + super(jobRepository, transactionManager); + this.maritimeServiceApiWebClient = maritimeServiceApiWebClient; + this.riskDataProcessor = riskDataProcessor; + this.riskDataWriter = riskDataWriter; + this.jdbcTemplate = jdbcTemplate; + this.riskDataRangeReader = riskDataRangeReader; + this.batchDateService = batchDateService; + this.batchApiLogService = batchApiLogService; + } + + @Override + protected String getJobName() { + return "RiskRangeImportJob"; + } + + @Override + protected String getStepName() { + return "RiskRangeImportStep"; + } + + @Override + protected Job createJobFlow(JobBuilder jobBuilder) { + return jobBuilder + .start(riskRangeImportStep()) + .next(riskEmptyResponseDecider()) + .on("EMPTY_RESPONSE").end() + .from(riskEmptyResponseDecider()).on("*").to(riskLastExecutionUpdateStep()) + .end() + .build(); + } + + @Bean + public JobExecutionDecider riskEmptyResponseDecider() { + return (jobExecution, stepExecution) -> { + if (stepExecution != null && stepExecution.getReadCount() == 0) { + log.info("[RiskRangeImportJob] Decider: EMPTY_RESPONSE - 응답 데이터 0건으로 LAST_EXECUTION 업데이트 스킵"); + return new FlowExecutionStatus("EMPTY_RESPONSE"); + } + log.info("[RiskRangeImportJob] Decider: NORMAL - LAST_EXECUTION 업데이트 진행"); + return new FlowExecutionStatus("NORMAL"); + }; + } + + @Override + protected ItemReader createReader() { + return riskDataRangeReader; + } + @Bean + @StepScope + public RiskDataRangeReader riskDataRangeReader( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, // SpEL로 ID 추출 + @Value("#{stepExecution.id}") Long stepExecutionId + ) { + RiskDataRangeReader reader = new RiskDataRangeReader(maritimeServiceApiWebClient, jdbcTemplate, batchDateService, batchApiLogService, maritimeServiceApiUrl); + reader.setExecutionIds(jobExecutionId, stepExecutionId); // ID 세팅 + return reader; + } + + @Override + protected ItemProcessor createProcessor() { + return riskDataProcessor; + } + @Bean + @StepScope + public RiskDataProcessor riskDataProcessor( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId) { + return new RiskDataProcessor(jobExecutionId); + } + + @Override + protected ItemWriter createWriter() { return riskDataWriter; } + + @Bean(name = "RiskRangeImportJob") + public Job riskRangeImportJob() { + return job(); + } + + @Bean(name = "RiskRangeImportStep") + public Step riskRangeImportStep() { + return step(); + } + + /** + * 2단계: 모든 스텝 성공 시 배치 실행 로그(날짜) 업데이트 + */ + @Bean + public Tasklet riskLastExecutionUpdateTasklet() { + return new LastExecutionUpdateTasklet(jdbcTemplate, targetSchema, getApiKey(), lastExecutionBufferHours); + } + @Bean(name = "RiskLastExecutionUpdateStep") + public Step riskLastExecutionUpdateStep() { + return new StepBuilder("RiskLastExecutionUpdateStep", jobRepository) + .tasklet(riskLastExecutionUpdateTasklet(), transactionManager) + .build(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/risk/dto/RiskDto.java b/src/main/java/com/snp/batch/jobs/batch/risk/dto/RiskDto.java new file mode 100644 index 0000000..2580eed --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/risk/dto/RiskDto.java @@ -0,0 +1,277 @@ +package com.snp.batch.jobs.batch.risk.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RiskDto { + // 1. Vessel and General Information + @JsonProperty("lrno") + private String lrno; + + @JsonProperty("lastUpdated") + private String lastUpdated; + + @JsonProperty("riskDataMaintained") + private Integer riskDataMaintained; + + // 2. AIS/Tracking Risk + @JsonProperty("daysSinceLastSeenOnAIS") + private Integer daysSinceLastSeenOnAIS; + + @JsonProperty("daysSinceLastSeenOnAISNarrative") + private String daysSinceLastSeenOnAISNarrative; + + @JsonProperty("daysUnderAIS") + private Integer daysUnderAIS; + + @JsonProperty("daysUnderAISNarrative") + private String daysUnderAISNarrative; + + @JsonProperty("imoCorrectOnAIS") + private Integer imoCorrectOnAIS; + + @JsonProperty("imoCorrectOnAISNarrative") + private String imoCorrectOnAISNarrative; + + @JsonProperty("sailingUnderName") + private Integer sailingUnderName; + + @JsonProperty("sailingUnderNameNarrative") + private String sailingUnderNameNarrative; + + @JsonProperty("anomalousMessagesFromMMSI") + private Integer anomalousMessagesFromMMSI; + + @JsonProperty("anomalousMessagesFromMMSINarrative") + private String anomalousMessagesFromMMSINarrative; + + @JsonProperty("mostRecentDarkActivity") + private Integer mostRecentDarkActivity; + + @JsonProperty("mostRecentDarkActivityNarrative") + private String mostRecentDarkActivityNarrative; + + // 3. Operational & History Risk + @JsonProperty("portCalls") + private Integer portCalls; + + @JsonProperty("portCallsNarrative") + private String portCallsNarrative; + + @JsonProperty("portRisk") + private Integer portRisk; + + @JsonProperty("portRiskNarrative") + private String portRiskNarrative; + + @JsonProperty("stsOperations") + private Integer stsOperations; + + @JsonProperty("stsOperationsNarrative") + private String stsOperationsNarrative; + + @JsonProperty("driftingHighSeas") + private Integer driftingHighSeas; + + @JsonProperty("driftingHighSeasNarrative") + private String driftingHighSeasNarrative; + + @JsonProperty("riskEvents") + private Integer riskEvents; + + @JsonProperty("riskEventNarrative") + private String riskEventNarrative; + + @JsonProperty("riskEventNarrativeExtended") + private String riskEventNarrativeExtended; + + @JsonProperty("flagChanges") + private Integer flagChanges; + + @JsonProperty("flagChangeNarrative") + private String flagChangeNarrative; + + // 4. PSC (Port State Control) & Flag Risk + @JsonProperty("flagParisMOUPerformance") + private Integer flagParisMOUPerformance; + + @JsonProperty("flagParisMOUPerformanceNarrative") + private String flagParisMOUPerformanceNarrative; + + @JsonProperty("flagTokyoMOUPeformance") + private Integer flagTokyoMOUPeformance; + + @JsonProperty("flagTokyoMOUPeformanceNarrative") + private String flagTokyoMOUPeformanceNarrative; + + @JsonProperty("flagUSCGMOUPerformance") + private Integer flagUSCGMOUPerformance; + + @JsonProperty("flagUSCGMOUPerformanceNarrative") + private String flagUSCGMOUPerformanceNarrative; + + @JsonProperty("uscgQualship21") + private Integer uscgQualship21; + + @JsonProperty("uscgQualship21Narrative") + private String uscgQualship21Narrative; + + @JsonProperty("timeSincePSCInspection") + private Integer timeSincePSCInspection; + + @JsonProperty("timeSincePSCInspectionNarrative") + private String timeSincePSCInspectionNarrative; + + @JsonProperty("pscInspections") + private Integer pscInspections; + + @JsonProperty("pscInspectionNarrative") + private String pscInspectionNarrative; + + @JsonProperty("pscDefects") + private Integer pscDefects; + + @JsonProperty("pscDefectsNarrative") + private String pscDefectsNarrative; + + @JsonProperty("pscDetentions") + private Integer pscDetentions; + + @JsonProperty("pscDetentionsNarrative") + private String pscDetentionsNarrative; + + // 5. Certification & Class Risk + @JsonProperty("currentSMCCertificate") + private Integer currentSMCCertificate; + + @JsonProperty("currentSMCCertificateNarrative") + private String currentSMCCertificateNarrative; + + @JsonProperty("docChanges") + private Integer docChanges; + + @JsonProperty("docChangesNarrative") + private String docChangesNarrative; + + @JsonProperty("currentClass") + private Integer currentClass; + + @JsonProperty("currentClassNarrative") + private String currentClassNarrative; + + @JsonProperty("currentClassNarrativeExtended") + private String currentClassNarrativeExtended; + + @JsonProperty("classStatusChanges") + private Integer classStatusChanges; + + @JsonProperty("classStatusChangesNarrative") + private String classStatusChangesNarrative; + + // 6. Ownership & Financial Risk + @JsonProperty("pandICoverage") + private Integer pandICoverage; + + @JsonProperty("pandICoverageNarrative") + private String pandICoverageNarrative; + + @JsonProperty("pandICoverageNarrativeExtended") + private String pandICoverageNarrativeExtended; + + @JsonProperty("nameChanges") + private Integer nameChanges; + + @JsonProperty("nameChangesNarrative") + private String nameChangesNarrative; + + @JsonProperty("gboChanges") + private Integer gboChanges; + + @JsonProperty("gboChangesNarrative") + private String gboChangesNarrative; + + @JsonProperty("ageOfShip") + private Integer ageOfShip; + + @JsonProperty("ageofShipNarrative") + private String ageofShipNarrative; + + // 7. Sanctions & Specialized Risk + @JsonProperty("iuuFishingViolation") + private Integer iuuFishingViolation; + + @JsonProperty("iuuFishingNarrative") + private String iuuFishingNarrative; // null 값 포함 + + @JsonProperty("draughtChanges") + private Integer draughtChanges; + + @JsonProperty("draughtChangesNarrative") + private String draughtChangesNarrative; + + @JsonProperty("mostRecentSanctionedPortCall") + private Integer mostRecentSanctionedPortCall; + + @JsonProperty("mostRecentSanctionedPortCallNarrative") + private String mostRecentSanctionedPortCallNarrative; // null 값 포함 + + @JsonProperty("singleShipOperation") + private Integer singleShipOperation; + + @JsonProperty("singleShipOperationNarrative") + private String singleShipOperationNarrative; + + @JsonProperty("fleetSafety") + private Integer fleetSafety; + + @JsonProperty("fleetSafetyNarrative") + private String fleetSafetyNarrative; + + @JsonProperty("fleetPSC") + private Integer fleetPSC; + + @JsonProperty("fleetPSCNarrative") + private String fleetPSCNarrative; + + // 8. Survey & Other Risk + @JsonProperty("specialSurveyOverdue") + private Integer specialSurveyOverdue; + + @JsonProperty("specialSurveyOverdueNarrative") + private String specialSurveyOverdueNarrative; + + @JsonProperty("ownerUnknown") + private Integer ownerUnknown; + + @JsonProperty("ownerUnknownNarrative") + private String ownerUnknownNarrative; + + @JsonProperty("russianPortCall") + private Integer russianPortCall; + + @JsonProperty("russianPortCallNarrative") + private String russianPortCallNarrative; + + @JsonProperty("russianOwnerRegistration") + private Integer russianOwnerRegistration; + + @JsonProperty("russianOwnerRegistrationNarrative") + private String russianOwnerRegistrationNarrative; + + @JsonProperty("russianSTS") + private Integer russianSTS; + + @JsonProperty("russianSTSNarrative") + private String russianSTSNarrative; + +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/risk/dto/RiskResponse.java b/src/main/java/com/snp/batch/jobs/batch/risk/dto/RiskResponse.java new file mode 100644 index 0000000..697b8b4 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/risk/dto/RiskResponse.java @@ -0,0 +1,16 @@ +package com.snp.batch.jobs.batch.risk.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RiskResponse { + private List riskDtoList; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/risk/entity/RiskEntity.java b/src/main/java/com/snp/batch/jobs/batch/risk/entity/RiskEntity.java new file mode 100644 index 0000000..4ca4822 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/risk/entity/RiskEntity.java @@ -0,0 +1,189 @@ +package com.snp.batch.jobs.batch.risk.entity; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDateTime; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class RiskEntity extends BaseEntity { + + private String lrno; + + private String lastUpdated; + + private Integer riskDataMaintained; + + private Integer daysSinceLastSeenOnAIS; + + private String daysSinceLastSeenOnAISNarrative; + + private Integer daysUnderAIS; + + private String daysUnderAISNarrative; + + private Integer imoCorrectOnAIS; + + private String imoCorrectOnAISNarrative; + + private Integer sailingUnderName; + + private String sailingUnderNameNarrative; + + private Integer anomalousMessagesFromMMSI; + + private String anomalousMessagesFromMMSINarrative; + + private Integer mostRecentDarkActivity; + + private String mostRecentDarkActivityNarrative; + + private Integer portCalls; + + private String portCallsNarrative; + + private Integer portRisk; + + private String portRiskNarrative; + + private Integer stsOperations; + + private String stsOperationsNarrative; + + private Integer driftingHighSeas; + + private String driftingHighSeasNarrative; + + private Integer riskEvents; + + private String riskEventNarrative; + + private String riskEventNarrativeExtended; + + private Integer flagChanges; + + private String flagChangeNarrative; + + private Integer flagParisMOUPerformance; + + private String flagParisMOUPerformanceNarrative; + + private Integer flagTokyoMOUPeformance; + + private String flagTokyoMOUPeformanceNarrative; + + private Integer flagUSCGMOUPerformance; + + private String flagUSCGMOUPerformanceNarrative; + + private Integer uscgQualship21; + + private String uscgQualship21Narrative; + + private Integer timeSincePSCInspection; + + private String timeSincePSCInspectionNarrative; + + private Integer pscInspections; + + private String pscInspectionNarrative; + + private Integer pscDefects; + + private String pscDefectsNarrative; + + private Integer pscDetentions; + + private String pscDetentionsNarrative; + + private Integer currentSMCCertificate; + + private String currentSMCCertificateNarrative; + + private Integer docChanges; + + private String docChangesNarrative; + + private Integer currentClass; + + private String currentClassNarrative; + + private String currentClassNarrativeExtended; + + private Integer classStatusChanges; + + private String classStatusChangesNarrative; + + private Integer pandICoverage; + + private String pandICoverageNarrative; + + private String pandICoverageNarrativeExtended; + + private Integer nameChanges; + + private String nameChangesNarrative; + + private Integer gboChanges; + + private String gboChangesNarrative; + + private Integer ageOfShip; + + private String ageofShipNarrative; + + private Integer iuuFishingViolation; + + private String iuuFishingNarrative; // null 값 포함 + + private Integer draughtChanges; + + private String draughtChangesNarrative; + + private Integer mostRecentSanctionedPortCall; + + private String mostRecentSanctionedPortCallNarrative; // null 값 포함 + + private Integer singleShipOperation; + + private String singleShipOperationNarrative; + + private Integer fleetSafety; + + private String fleetSafetyNarrative; + + private Integer fleetPSC; + + private String fleetPSCNarrative; + + private Integer specialSurveyOverdue; + + private String specialSurveyOverdueNarrative; + + private Integer ownerUnknown; + + private String ownerUnknownNarrative; + + private Integer russianPortCall; + + private String russianPortCallNarrative; + + private Integer russianOwnerRegistration; + + private String russianOwnerRegistrationNarrative; + + private Integer russianSTS; + + private String russianSTSNarrative; + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/risk/processor/RiskDataProcessor.java b/src/main/java/com/snp/batch/jobs/batch/risk/processor/RiskDataProcessor.java new file mode 100644 index 0000000..4f5415d --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/risk/processor/RiskDataProcessor.java @@ -0,0 +1,129 @@ +package com.snp.batch.jobs.batch.risk.processor; + +import com.snp.batch.common.batch.processor.BaseProcessor; +import com.snp.batch.jobs.batch.risk.dto.RiskDto; +import com.snp.batch.jobs.batch.risk.entity.RiskEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class RiskDataProcessor extends BaseProcessor { + private final Long jobExecutionId; + public RiskDataProcessor(@Value("#{stepExecution.jobExecution.id}") Long jobExecutionId) { + this.jobExecutionId = jobExecutionId; + } + @Override + protected RiskEntity processItem(RiskDto dto) throws Exception { + log.debug("Risk 데이터 처리 시작: imoNumber={}", dto.getLrno()); + + RiskEntity entity = RiskEntity.builder() + // 1. Vessel and General Information + .lrno(dto.getLrno()) + .lastUpdated(dto.getLastUpdated()) + .riskDataMaintained(dto.getRiskDataMaintained()) + + // 2. AIS/Tracking Risk + .daysSinceLastSeenOnAIS(dto.getDaysSinceLastSeenOnAIS()) + .daysSinceLastSeenOnAISNarrative(dto.getDaysSinceLastSeenOnAISNarrative()) + .daysUnderAIS(dto.getDaysUnderAIS()) + .daysUnderAISNarrative(dto.getDaysUnderAISNarrative()) + .imoCorrectOnAIS(dto.getImoCorrectOnAIS()) + .imoCorrectOnAISNarrative(dto.getImoCorrectOnAISNarrative()) + .sailingUnderName(dto.getSailingUnderName()) + .sailingUnderNameNarrative(dto.getSailingUnderNameNarrative()) + .anomalousMessagesFromMMSI(dto.getAnomalousMessagesFromMMSI()) + .anomalousMessagesFromMMSINarrative(dto.getAnomalousMessagesFromMMSINarrative()) + .mostRecentDarkActivity(dto.getMostRecentDarkActivity()) + .mostRecentDarkActivityNarrative(dto.getMostRecentDarkActivityNarrative()) + + // 3. Operational & History Risk + .portCalls(dto.getPortCalls()) + .portCallsNarrative(dto.getPortCallsNarrative()) + .portRisk(dto.getPortRisk()) + .portRiskNarrative(dto.getPortRiskNarrative()) + .stsOperations(dto.getStsOperations()) + .stsOperationsNarrative(dto.getStsOperationsNarrative()) + .driftingHighSeas(dto.getDriftingHighSeas()) + .driftingHighSeasNarrative(dto.getDriftingHighSeasNarrative()) + .riskEvents(dto.getRiskEvents()) + .riskEventNarrative(dto.getRiskEventNarrative()) + .riskEventNarrativeExtended(dto.getRiskEventNarrativeExtended()) + .flagChanges(dto.getFlagChanges()) + .flagChangeNarrative(dto.getFlagChangeNarrative()) + + // 4. PSC (Port State Control) & Flag Risk + .flagParisMOUPerformance(dto.getFlagParisMOUPerformance()) + .flagParisMOUPerformanceNarrative(dto.getFlagParisMOUPerformanceNarrative()) + .flagTokyoMOUPeformance(dto.getFlagTokyoMOUPeformance()) + .flagTokyoMOUPeformanceNarrative(dto.getFlagTokyoMOUPeformanceNarrative()) + .flagUSCGMOUPerformance(dto.getFlagUSCGMOUPerformance()) + .flagUSCGMOUPerformanceNarrative(dto.getFlagUSCGMOUPerformanceNarrative()) + .uscgQualship21(dto.getUscgQualship21()) + .uscgQualship21Narrative(dto.getUscgQualship21Narrative()) + .timeSincePSCInspection(dto.getTimeSincePSCInspection()) + .timeSincePSCInspectionNarrative(dto.getTimeSincePSCInspectionNarrative()) + .pscInspections(dto.getPscInspections()) + .pscInspectionNarrative(dto.getPscInspectionNarrative()) + .pscDefects(dto.getPscDefects()) + .pscDefectsNarrative(dto.getPscDefectsNarrative()) + .pscDetentions(dto.getPscDetentions()) + .pscDetentionsNarrative(dto.getPscDetentionsNarrative()) + + // 5. Certification & Class Risk + .currentSMCCertificate(dto.getCurrentSMCCertificate()) + .currentSMCCertificateNarrative(dto.getCurrentSMCCertificateNarrative()) + .docChanges(dto.getDocChanges()) + .docChangesNarrative(dto.getDocChangesNarrative()) + .currentClass(dto.getCurrentClass()) + .currentClassNarrative(dto.getCurrentClassNarrative()) + .currentClassNarrativeExtended(dto.getCurrentClassNarrativeExtended()) + .classStatusChanges(dto.getClassStatusChanges()) + .classStatusChangesNarrative(dto.getClassStatusChangesNarrative()) + + // 6. Ownership & Financial Risk + .pandICoverage(dto.getPandICoverage()) + .pandICoverageNarrative(dto.getPandICoverageNarrative()) + .pandICoverageNarrativeExtended(dto.getPandICoverageNarrativeExtended()) + .nameChanges(dto.getNameChanges()) + .nameChangesNarrative(dto.getNameChangesNarrative()) + .gboChanges(dto.getGboChanges()) + .gboChangesNarrative(dto.getGboChangesNarrative()) + .ageOfShip(dto.getAgeOfShip()) + .ageofShipNarrative(dto.getAgeofShipNarrative()) + + // 7. Sanctions & Specialized Risk + .iuuFishingViolation(dto.getIuuFishingViolation()) + .iuuFishingNarrative(dto.getIuuFishingNarrative()) + .draughtChanges(dto.getDraughtChanges()) + .draughtChangesNarrative(dto.getDraughtChangesNarrative()) + .mostRecentSanctionedPortCall(dto.getMostRecentSanctionedPortCall()) + .mostRecentSanctionedPortCallNarrative(dto.getMostRecentSanctionedPortCallNarrative()) + .singleShipOperation(dto.getSingleShipOperation()) + .singleShipOperationNarrative(dto.getSingleShipOperationNarrative()) + .fleetSafety(dto.getFleetSafety()) + .fleetSafetyNarrative(dto.getFleetSafetyNarrative()) + .fleetPSC(dto.getFleetPSC()) + .fleetPSCNarrative(dto.getFleetPSCNarrative()) + + // 8. Survey & Other Risk + .specialSurveyOverdue(dto.getSpecialSurveyOverdue()) + .specialSurveyOverdueNarrative(dto.getSpecialSurveyOverdueNarrative()) + .ownerUnknown(dto.getOwnerUnknown()) + .ownerUnknownNarrative(dto.getOwnerUnknownNarrative()) + .russianPortCall(dto.getRussianPortCall()) + .russianPortCallNarrative(dto.getRussianPortCallNarrative()) + .russianOwnerRegistration(dto.getRussianOwnerRegistration()) + .russianOwnerRegistrationNarrative(dto.getRussianOwnerRegistrationNarrative()) + .russianSTS(dto.getRussianSTS()) + .russianSTSNarrative(dto.getRussianSTSNarrative()) + .jobExecutionId(jobExecutionId) + .createdBy("SYSTEM") + .build(); + + log.debug("Risk 데이터 처리 완료: imoNumber={}", dto.getLrno()); + + return entity; + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/risk/reader/RiskDataRangeReader.java b/src/main/java/com/snp/batch/jobs/batch/risk/reader/RiskDataRangeReader.java new file mode 100644 index 0000000..ed75282 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/risk/reader/RiskDataRangeReader.java @@ -0,0 +1,117 @@ +package com.snp.batch.jobs.batch.risk.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.batch.risk.dto.RiskDto; +import com.snp.batch.service.BatchApiLogService; +import com.snp.batch.service.BatchDateService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.List; +import java.util.Map; + +@Slf4j +public class RiskDataRangeReader extends BaseApiReader { + + private final JdbcTemplate jdbcTemplate; + private final BatchDateService batchDateService; // ✨ BatchDateService 필드 추가 + private final BatchApiLogService batchApiLogService; + private List allData; + private int currentBatchIndex = 0; + private final int batchSize = 5000; + private String fromDate; + private String toDate; + String maritimeServiceApiUrl; + public RiskDataRangeReader(WebClient webClient, JdbcTemplate jdbcTemplate, BatchDateService batchDateService, BatchApiLogService batchApiLogService, String maritimeServiceApiUrl) { + super(webClient); + this.jdbcTemplate = jdbcTemplate; + this.batchDateService = batchDateService; + this.batchApiLogService = batchApiLogService; + this.maritimeServiceApiUrl = maritimeServiceApiUrl; + enableChunkMode(); + } + + @Override + protected String getReaderName() { + return "RiskDataRangeReader"; + } + + @Override + protected void resetCustomState() { + this.currentBatchIndex = 0; + this.allData = null; + } + + @Override + protected String getApiPath() { + return "/RiskAndCompliance/UpdatedRiskWithNarrativesList"; + } + protected String getApiKey() { + return "RISK_IMPORT_API"; + } + + @Override + protected List fetchNextBatch() throws Exception { + // 모든 배치 처리 완료 확인 + if (allData == null) { + allData = callApiWithBatch(); + + if (allData == null || allData.isEmpty()) { + log.warn("[{}] 조회된 데이터 없음 → 종료", getReaderName()); + return null; + } + + log.info("[{}] 총 {}건 데이터 조회됨. batchSize = {}", getReaderName(), allData.size(), batchSize); + } + + // 2) 이미 끝까지 읽었으면 종료 + if (currentBatchIndex >= allData.size()) { + log.info("[{}] 모든 배치 처리 완료", getReaderName()); + return null; + } + + // 3) 이번 배치의 end 계산 + int end = Math.min(currentBatchIndex + batchSize, allData.size()); + + // 4) 현재 batch 리스트 잘라서 반환 + List batch = allData.subList(currentBatchIndex, end); + + int batchNum = (currentBatchIndex / batchSize) + 1; + int totalBatches = (int) Math.ceil((double) allData.size() / batchSize); + + log.info("[{}] 배치 {}/{} 처리 중: {}건", getReaderName(), batchNum, totalBatches, batch.size()); + + // 다음 batch 인덱스 이동 + currentBatchIndex = end; + updateApiCallStats(totalBatches, batchNum); + + return batch; + } + @Override + protected void afterFetch(List data) { + try{ + if (data == null) { + log.info("[{}] 배치 처리 성공", getReaderName()); + } + }catch (Exception e){ + log.info("[{}] 배치 처리 실패", getReaderName()); + log.info("[{}] API 호출 종료", getReaderName()); + } + } + + private List callApiWithBatch() { + Map params = batchDateService.getDateRangeWithTimezoneParams(getApiKey()); + // 부모 클래스의 공통 모듈 호출 (단 한 줄로 처리 가능) + return executeListApiCall( + maritimeServiceApiUrl, + getApiPath(), + params, + new ParameterizedTypeReference>() {}, + batchApiLogService + ); + } + + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/risk/reader/RiskDetailDataReader.java b/src/main/java/com/snp/batch/jobs/batch/risk/reader/RiskDetailDataReader.java new file mode 100644 index 0000000..31efaae --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/risk/reader/RiskDetailDataReader.java @@ -0,0 +1,148 @@ +package com.snp.batch.jobs.batch.risk.reader; + +import com.snp.batch.global.model.BatchApiLog; +import com.snp.batch.jobs.batch.risk.dto.RiskDto; +import com.snp.batch.service.BatchApiLogService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.ItemReader; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.web.reactive.function.client.WebClient; + +import java.time.LocalDateTime; +import java.util.*; + +@Slf4j +public class RiskDetailDataReader implements ItemReader { + + private static final String API_PATH = "/RiskAndCompliance/RisksByImos"; + private static final int API_BATCH_SIZE = 100; // API 1회 최대 100개 IMO + + private final WebClient maritimeServiceApiWebClient; + private final BatchApiLogService batchApiLogService; + private final String apiUrl; + private final long delayOnSuccessMs; + private final long delayOnFailureMs; + + // 파티션에서 받은 IMO 목록 + private List partitionImoNumbers; + private int currentBatchIndex = 0; + + // 현재 배치 응답 데이터 (청크 내에서 하나씩 반환) + private Iterator currentIterator; + + // 실행 ID + private Long jobExecutionId; + private Long stepExecutionId; + + public RiskDetailDataReader(WebClient maritimeServiceApiWebClient, + BatchApiLogService batchApiLogService, + String apiUrl, + long delayOnSuccessMs, + long delayOnFailureMs) { + this.maritimeServiceApiWebClient = maritimeServiceApiWebClient; + this.batchApiLogService = batchApiLogService; + this.apiUrl = apiUrl; + this.delayOnSuccessMs = delayOnSuccessMs; + this.delayOnFailureMs = delayOnFailureMs; + } + + public void setPartitionImoNumbers(String partitionImoNumbersCsv) { + if (partitionImoNumbersCsv != null && !partitionImoNumbersCsv.isBlank()) { + this.partitionImoNumbers = new ArrayList<>(Arrays.asList(partitionImoNumbersCsv.split(","))); + } else { + this.partitionImoNumbers = Collections.emptyList(); + } + } + + public void setExecutionIds(Long jobExecutionId, Long stepExecutionId) { + this.jobExecutionId = jobExecutionId; + this.stepExecutionId = stepExecutionId; + } + + @Override + public RiskDto read() throws Exception { + // 현재 이터레이터에 남은 항목 반환 + if (currentIterator != null && currentIterator.hasNext()) { + return currentIterator.next(); + } + + // 모든 배치 처리 완료 + if (currentBatchIndex >= partitionImoNumbers.size()) { + return null; + } + + // 다음 배치 (100개씩) API 호출 + int fromIndex = currentBatchIndex; + int toIndex = Math.min(fromIndex + API_BATCH_SIZE, partitionImoNumbers.size()); + List batchImos = partitionImoNumbers.subList(fromIndex, toIndex); + currentBatchIndex = toIndex; + + String imosParam = String.join(",", batchImos); + String fullUri = apiUrl + API_PATH + "?imos=" + imosParam; + + long startTime = System.currentTimeMillis(); + int statusCode = 200; + String errorMessage = null; + long responseCount = 0; + + try { + log.info("[RiskDetailDataReader] API 호출: IMO {} 건 (index {}-{})", + batchImos.size(), fromIndex, toIndex - 1); + + List response = maritimeServiceApiWebClient.get() + .uri(uriBuilder -> uriBuilder + .path(API_PATH) + .queryParam("imos", imosParam) + .build()) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() {}) + .block(); + + if (response == null || response.isEmpty()) { + log.info("[RiskDetailDataReader] API 응답 없음 (index {}-{})", fromIndex, toIndex - 1); + if (delayOnSuccessMs > 0) Thread.sleep(delayOnSuccessMs); + return read(); // 다음 배치 시도 + } + + responseCount = response.size(); + log.info("[RiskDetailDataReader] API 응답: {} 건", responseCount); + + if (delayOnSuccessMs > 0) Thread.sleep(delayOnSuccessMs); + + currentIterator = response.iterator(); + return currentIterator.hasNext() ? currentIterator.next() : read(); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw e; + } catch (Exception e) { + statusCode = 500; + errorMessage = e.getMessage(); + log.error("[RiskDetailDataReader] API 호출 실패: {}", e.getMessage(), e); + + if (delayOnFailureMs > 0) { + try { + Thread.sleep(delayOnFailureMs); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + // 실패한 배치 건너뛰고 다음 배치로 + return read(); + } finally { + long duration = System.currentTimeMillis() - startTime; + batchApiLogService.saveLog(BatchApiLog.builder() + .apiRequestLocation("RiskDetailDataReader") + .jobExecutionId(jobExecutionId) + .stepExecutionId(stepExecutionId) + .requestUri(fullUri) + .httpMethod("GET") + .statusCode(statusCode) + .responseTimeMs(duration) + .responseCount(responseCount) + .errorMessage(errorMessage) + .createdAt(LocalDateTime.now()) + .build()); + } + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/risk/repository/RiskRepository.java b/src/main/java/com/snp/batch/jobs/batch/risk/repository/RiskRepository.java new file mode 100644 index 0000000..2cda84a --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/risk/repository/RiskRepository.java @@ -0,0 +1,11 @@ +package com.snp.batch.jobs.batch.risk.repository; + +import com.snp.batch.jobs.batch.risk.entity.RiskEntity; + +import java.util.List; + +public interface RiskRepository { + void saveRiskAll(List items); +// void saveRiskHistoryAll(List items); + void saveRiskDetailAll(List items); +} diff --git a/src/main/java/com/snp/batch/jobs/batch/risk/repository/RiskRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/batch/risk/repository/RiskRepositoryImpl.java new file mode 100644 index 0000000..72fc1fd --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/risk/repository/RiskRepositoryImpl.java @@ -0,0 +1,363 @@ +package com.snp.batch.jobs.batch.risk.repository; + +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.batch.risk.entity.RiskEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.Types; +import java.util.List; + +@Slf4j +@Repository("riskRepository") +public class RiskRepositoryImpl extends BaseJdbcRepository implements RiskRepository { + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + + @Value("${app.batch.target-schema.tables.risk-compliance-001}") + private String tableName; + + + public RiskRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + protected String getTargetSchema() { + return targetSchema; + } + + @Override + protected String getSimpleTableName() { + return tableName; + } + + @Override + protected RowMapper getRowMapper() { + return null; + } + + @Override + protected Long extractId(RiskEntity entity) { + return null; + } + + @Override + protected String getInsertSql() { + return null; + } + + @Override + protected String getUpdateSql() { + return """ + INSERT INTO %s( + imo_no, last_mdfcn_dt, + risk_data_maint, ais_notrcv_elps_days, ais_lwrnk_days, ais_up_imo_desc, othr_ship_nm_voy_yn, + mmsi_anom_message, recent_dark_actv, port_prtcll, port_risk, sts_job, + drift_chg, risk_event, ntnlty_chg, ntnlty_prs_mou_perf, ntnlty_tky_mou_perf, + ntnlty_uscg_mou_perf, uscg_excl_ship_cert, psc_inspection_elps_hr, psc_inspection, psc_defect, + psc_detained, now_smgrc_evdc, docc_chg, now_clfic, clfic_status_chg, + pni_insrnc, ship_nm_chg, gbo_chg, vslage, ilgl_fshr_viol, + draft_chg, recent_sanction_prtcll, sngl_ship_voy, fltsfty, flt_psc, + spc_inspection_ovdue, ownr_unk, rss_port_call, rss_ownr_reg, rss_sts, + job_execution_id, creatr_id + ) + VALUES ( + ?, ?::timestamptz, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ? + ); + """.formatted(getTableName()); + } + + @Override + protected void setInsertParameters(PreparedStatement ps, RiskEntity entity) throws Exception { + + } + + @Override + protected void setUpdateParameters(PreparedStatement ps, RiskEntity entity) throws Exception { + int idx = 1; + ps.setString(idx++, entity.getLrno()); + ps.setString(idx++, entity.getLastUpdated()); + ps.setObject(idx++, entity.getRiskDataMaintained(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getDaysSinceLastSeenOnAIS(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getDaysUnderAIS(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getImoCorrectOnAIS(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getSailingUnderName(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getAnomalousMessagesFromMMSI(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getMostRecentDarkActivity(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getPortCalls(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getPortRisk(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getStsOperations(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getDriftingHighSeas(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getRiskEvents(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getFlagChanges(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getFlagParisMOUPerformance(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getFlagTokyoMOUPeformance(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getFlagUSCGMOUPerformance(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getUscgQualship21(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getTimeSincePSCInspection(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getPscInspections(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getPscDefects(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getPscDetentions(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getCurrentSMCCertificate(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getDocChanges(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getCurrentClass(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getClassStatusChanges(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getPandICoverage(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getNameChanges(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getGboChanges(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getAgeOfShip(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getIuuFishingViolation(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getDraughtChanges(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getMostRecentSanctionedPortCall(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getSingleShipOperation(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getFleetSafety(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getFleetPSC(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getSpecialSurveyOverdue(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getOwnerUnknown(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getRussianPortCall(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getRussianOwnerRegistration(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getRussianSTS(), java.sql.Types.INTEGER); + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + + @Override + protected String getEntityName() { + return "RiskEntity"; + } + + private String getRiskDetailInsertSql() { + return """ + INSERT INTO %s.%s( + imo_no, last_mdfcn_dt, + risk_data_maint, + ais_notrcv_elps_days, ais_notrcv_elps_days_desc, + ais_lwrnk_days, ais_lwrnk_days_desc, + ais_up_imo_desc, ais_up_imo_desc_val, + othr_ship_nm_voy_yn, othr_ship_nm_voy_yn_desc, + mmsi_anom_message, mmsi_anom_message_desc, + recent_dark_actv, recent_dark_actv_desc, + port_prtcll, port_prtcll_desc, + port_risk, port_risk_desc, + sts_job, sts_job_desc, + drift_chg, drift_chg_desc, + risk_event, risk_event_desc, risk_event_desc_ext, + ntnlty_chg, ntnlty_chg_desc, + ntnlty_prs_mou_perf, ntnlty_prs_mou_perf_desc, + ntnlty_tky_mou_perf, ntnlty_tky_mou_perf_desc, + ntnlty_uscg_mou_perf, ntnlty_uscg_mou_perf_desc, + uscg_excl_ship_cert, uscg_excl_ship_cert_desc, + psc_inspection_elps_hr, psc_inspection_elps_hr_desc, + psc_inspection, psc_inspection_desc, + psc_defect, psc_defect_desc, + psc_detained, psc_detained_desc, + now_smgrc_evdc, now_smgrc_evdc_desc, + docc_chg, docc_chg_desc, + now_clfic, now_clfic_desc, now_clfic_desc_ext, + clfic_status_chg, clfic_status_chg_desc, + pni_insrnc, pni_insrnc_desc, pni_insrnc_desc_ext, + ship_nm_chg, ship_nm_chg_desc, + gbo_chg, gbo_chg_desc, + vslage, vslage_desc, + ilgl_fshr_viol, ilgl_fshr_viol_desc, + draft_chg, draft_chg_desc, + recent_sanction_prtcll, recent_sanction_prtcll_desc, + sngl_ship_voy, sngl_ship_voy_desc, + fltsfty, fltsfty_desc, + flt_psc, flt_psc_desc, + spc_inspection_ovdue, spc_inspection_ovdue_desc, + ownr_unk, ownr_unk_desc, + rss_port_call, rss_port_call_desc, + rss_ownr_reg, rss_ownr_reg_desc, + rss_sts, rss_sts_desc, + job_execution_id, creatr_id + ) + VALUES ( + ?, ?::timestamptz, -- 1-2: imo_no, last_mdfcn_dt + ?, -- 3: risk_data_maint + ?, ?, -- 4-5: ais_notrcv_elps_days + ?, ?, -- 6-7: ais_lwrnk_days + ?, ?, -- 8-9: ais_up_imo_desc + ?, ?, -- 10-11: othr_ship_nm_voy_yn + ?, ?, -- 12-13: mmsi_anom_message + ?, ?, -- 14-15: recent_dark_actv + ?, ?, -- 16-17: port_prtcll + ?, ?, -- 18-19: port_risk + ?, ?, -- 20-21: sts_job + ?, ?, -- 22-23: drift_chg + ?, ?, ?, -- 24-26: risk_event (+ext) + ?, ?, -- 27-28: ntnlty_chg + ?, ?, -- 29-30: ntnlty_prs_mou_perf + ?, ?, -- 31-32: ntnlty_tky_mou_perf + ?, ?, -- 33-34: ntnlty_uscg_mou_perf + ?, ?, -- 35-36: uscg_excl_ship_cert + ?, ?, -- 37-38: psc_inspection_elps_hr + ?, ?, -- 39-40: psc_inspection + ?, ?, -- 41-42: psc_defect + ?, ?, -- 43-44: psc_detained + ?, ?, -- 45-46: now_smgrc_evdc + ?, ?, -- 47-48: docc_chg + ?, ?, ?, -- 49-51: now_clfic (+ext) + ?, ?, -- 52-53: clfic_status_chg + ?, ?, ?, -- 54-56: pni_insrnc (+ext) + ?, ?, -- 57-58: ship_nm_chg + ?, ?, -- 59-60: gbo_chg + ?, ?, -- 61-62: vslage + ?, ?, -- 63-64: ilgl_fshr_viol + ?, ?, -- 65-66: draft_chg + ?, ?, -- 67-68: recent_sanction_prtcll + ?, ?, -- 69-70: sngl_ship_voy + ?, ?, -- 71-72: fltsfty + ?, ?, -- 73-74: flt_psc + ?, ?, -- 75-76: spc_inspection_ovdue + ?, ?, -- 77-78: ownr_unk + ?, ?, -- 79-80: rss_port_call + ?, ?, -- 81-82: rss_ownr_reg + ?, ?, -- 83-84: rss_sts + ?, ? -- 85-86: job_execution_id, creatr_id + ) + """.formatted(targetSchema, tableName); + } + + private void setRiskDetailParameters(PreparedStatement ps, RiskEntity entity) throws Exception { + int idx = 1; + ps.setString(idx++, entity.getLrno()); + ps.setString(idx++, entity.getLastUpdated()); + ps.setObject(idx++, entity.getRiskDataMaintained(), Types.INTEGER); + ps.setObject(idx++, entity.getDaysSinceLastSeenOnAIS(), Types.INTEGER); + ps.setString(idx++, entity.getDaysSinceLastSeenOnAISNarrative()); + ps.setObject(idx++, entity.getDaysUnderAIS(), Types.INTEGER); + ps.setString(idx++, entity.getDaysUnderAISNarrative()); + ps.setObject(idx++, entity.getImoCorrectOnAIS(), Types.INTEGER); + ps.setString(idx++, entity.getImoCorrectOnAISNarrative()); + ps.setObject(idx++, entity.getSailingUnderName(), Types.INTEGER); + ps.setString(idx++, entity.getSailingUnderNameNarrative()); + ps.setObject(idx++, entity.getAnomalousMessagesFromMMSI(), Types.INTEGER); + ps.setString(idx++, entity.getAnomalousMessagesFromMMSINarrative()); + ps.setObject(idx++, entity.getMostRecentDarkActivity(), Types.INTEGER); + ps.setString(idx++, entity.getMostRecentDarkActivityNarrative()); + ps.setObject(idx++, entity.getPortCalls(), Types.INTEGER); + ps.setString(idx++, entity.getPortCallsNarrative()); + ps.setObject(idx++, entity.getPortRisk(), Types.INTEGER); + ps.setString(idx++, entity.getPortRiskNarrative()); + ps.setObject(idx++, entity.getStsOperations(), Types.INTEGER); + ps.setString(idx++, entity.getStsOperationsNarrative()); + ps.setObject(idx++, entity.getDriftingHighSeas(), Types.INTEGER); + ps.setString(idx++, entity.getDriftingHighSeasNarrative()); + ps.setObject(idx++, entity.getRiskEvents(), Types.INTEGER); + ps.setString(idx++, entity.getRiskEventNarrative()); + ps.setString(idx++, entity.getRiskEventNarrativeExtended()); + ps.setObject(idx++, entity.getFlagChanges(), Types.INTEGER); + ps.setString(idx++, entity.getFlagChangeNarrative()); + ps.setObject(idx++, entity.getFlagParisMOUPerformance(), Types.INTEGER); + ps.setString(idx++, entity.getFlagParisMOUPerformanceNarrative()); + ps.setObject(idx++, entity.getFlagTokyoMOUPeformance(), Types.INTEGER); + ps.setString(idx++, entity.getFlagTokyoMOUPeformanceNarrative()); + ps.setObject(idx++, entity.getFlagUSCGMOUPerformance(), Types.INTEGER); + ps.setString(idx++, entity.getFlagUSCGMOUPerformanceNarrative()); + ps.setObject(idx++, entity.getUscgQualship21(), Types.INTEGER); + ps.setString(idx++, entity.getUscgQualship21Narrative()); + ps.setObject(idx++, entity.getTimeSincePSCInspection(), Types.INTEGER); + ps.setString(idx++, entity.getTimeSincePSCInspectionNarrative()); + ps.setObject(idx++, entity.getPscInspections(), Types.INTEGER); + ps.setString(idx++, entity.getPscInspectionNarrative()); + ps.setObject(idx++, entity.getPscDefects(), Types.INTEGER); + ps.setString(idx++, entity.getPscDefectsNarrative()); + ps.setObject(idx++, entity.getPscDetentions(), Types.INTEGER); + ps.setString(idx++, entity.getPscDetentionsNarrative()); + ps.setObject(idx++, entity.getCurrentSMCCertificate(), Types.INTEGER); + ps.setString(idx++, entity.getCurrentSMCCertificateNarrative()); + ps.setObject(idx++, entity.getDocChanges(), Types.INTEGER); + ps.setString(idx++, entity.getDocChangesNarrative()); + ps.setObject(idx++, entity.getCurrentClass(), Types.INTEGER); + ps.setString(idx++, entity.getCurrentClassNarrative()); + ps.setString(idx++, entity.getCurrentClassNarrativeExtended()); + ps.setObject(idx++, entity.getClassStatusChanges(), Types.INTEGER); + ps.setString(idx++, entity.getClassStatusChangesNarrative()); + ps.setObject(idx++, entity.getPandICoverage(), Types.INTEGER); + ps.setString(idx++, entity.getPandICoverageNarrative()); + ps.setString(idx++, entity.getPandICoverageNarrativeExtended()); + ps.setObject(idx++, entity.getNameChanges(), Types.INTEGER); + ps.setString(idx++, entity.getNameChangesNarrative()); + ps.setObject(idx++, entity.getGboChanges(), Types.INTEGER); + ps.setString(idx++, entity.getGboChangesNarrative()); + ps.setObject(idx++, entity.getAgeOfShip(), Types.INTEGER); + ps.setString(idx++, entity.getAgeofShipNarrative()); + ps.setObject(idx++, entity.getIuuFishingViolation(), Types.INTEGER); + ps.setString(idx++, entity.getIuuFishingNarrative()); + ps.setObject(idx++, entity.getDraughtChanges(), Types.INTEGER); + ps.setString(idx++, entity.getDraughtChangesNarrative()); + ps.setObject(idx++, entity.getMostRecentSanctionedPortCall(), Types.INTEGER); + ps.setString(idx++, entity.getMostRecentSanctionedPortCallNarrative()); + ps.setObject(idx++, entity.getSingleShipOperation(), Types.INTEGER); + ps.setString(idx++, entity.getSingleShipOperationNarrative()); + ps.setObject(idx++, entity.getFleetSafety(), Types.INTEGER); + ps.setString(idx++, entity.getFleetSafetyNarrative()); + ps.setObject(idx++, entity.getFleetPSC(), Types.INTEGER); + ps.setString(idx++, entity.getFleetPSCNarrative()); + ps.setObject(idx++, entity.getSpecialSurveyOverdue(), Types.INTEGER); + ps.setString(idx++, entity.getSpecialSurveyOverdueNarrative()); + ps.setObject(idx++, entity.getOwnerUnknown(), Types.INTEGER); + ps.setString(idx++, entity.getOwnerUnknownNarrative()); + ps.setObject(idx++, entity.getRussianPortCall(), Types.INTEGER); + ps.setString(idx++, entity.getRussianPortCallNarrative()); + ps.setObject(idx++, entity.getRussianOwnerRegistration(), Types.INTEGER); + ps.setString(idx++, entity.getRussianOwnerRegistrationNarrative()); + ps.setObject(idx++, entity.getRussianSTS(), Types.INTEGER); + ps.setString(idx++, entity.getRussianSTSNarrative()); + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + + @Override + public void saveRiskAll(List items) { + if (items == null || items.isEmpty()) { + return; + } + jdbcTemplate.batchUpdate(getUpdateSql(), items, items.size(), + (ps, entity) -> { + try { + setUpdateParameters(ps, entity); + } catch (Exception e) { + log.error("배치 수정 파라미터 설정 실패", e); + throw new RuntimeException(e); + } + }); + + log.info("{} 전체 저장 완료: 수정={} 건", getEntityName(), items.size()); + } + + @Override + public void saveRiskDetailAll(List items) { + if (items == null || items.isEmpty()) { + return; + } + jdbcTemplate.batchUpdate(getRiskDetailInsertSql(), items, items.size(), + (ps, entity) -> { + try { + setRiskDetailParameters(ps, entity); + } catch (Exception e) { + log.error("RiskDetail 배치 파라미터 설정 실패", e); + throw new RuntimeException(e); + } + }); + + log.info("RiskDetail 전체 저장 완료: 수정={} 건", items.size()); + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/risk/writer/RiskDataWriter.java b/src/main/java/com/snp/batch/jobs/batch/risk/writer/RiskDataWriter.java new file mode 100644 index 0000000..64b01b5 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/risk/writer/RiskDataWriter.java @@ -0,0 +1,24 @@ +package com.snp.batch.jobs.batch.risk.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.batch.risk.entity.RiskEntity; +import com.snp.batch.jobs.batch.risk.repository.RiskRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Slf4j +@Component +public class RiskDataWriter extends BaseWriter { + private final RiskRepository riskRepository; + public RiskDataWriter(RiskRepository riskRepository) { + super("riskRepository"); + this.riskRepository = riskRepository; + } + @Override + protected void writeItems(List items) throws Exception { + riskRepository.saveRiskAll(items); +// riskRepository.saveRiskHistoryAll(items); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/risk/writer/RiskDetailDataWriter.java b/src/main/java/com/snp/batch/jobs/batch/risk/writer/RiskDetailDataWriter.java new file mode 100644 index 0000000..293873f --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/risk/writer/RiskDetailDataWriter.java @@ -0,0 +1,26 @@ +package com.snp.batch.jobs.batch.risk.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.batch.risk.entity.RiskEntity; +import com.snp.batch.jobs.batch.risk.repository.RiskRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Slf4j +@Component +public class RiskDetailDataWriter extends BaseWriter { + + private final RiskRepository riskRepository; + + public RiskDetailDataWriter(RiskRepository riskRepository) { + super("riskDetailRepository"); + this.riskRepository = riskRepository; + } + + @Override + protected void writeItems(List items) throws Exception { + riskRepository.saveRiskDetailAll(items); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/config/ShipDetailImoFetchTasklet.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/config/ShipDetailImoFetchTasklet.java new file mode 100644 index 0000000..84f250b --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/config/ShipDetailImoFetchTasklet.java @@ -0,0 +1,179 @@ +package com.snp.batch.jobs.batch.shipdetail.config; + +import com.snp.batch.global.model.BatchApiLog; +import com.snp.batch.global.repository.BatchFailedRecordRepository; +import com.snp.batch.jobs.batch.shipdetail.dto.ShipDto; +import com.snp.batch.jobs.batch.shipdetail.dto.ShipUpdateApiResponse; +import com.snp.batch.service.BatchApiLogService; +import com.snp.batch.service.BatchDateService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.util.UriComponentsBuilder; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * 선박제원정보 변경 IMO 목록 조회 Tasklet. + * NORMAL 모드: Maritime API에서 변경된 IMO 번호 조회 + * RECOLLECT + 실패건 재수집(AUTO_RETRY/MANUAL_RETRY): DB에서 실패 IMO 번호 조회 + * RECOLLECT + 기간 재수집(MANUAL 등): Maritime API에서 설정된 기간의 IMO 번호 조회 + * 조회 결과를 JobExecutionContext에 저장하여 Partitioner가 사용할 수 있게 함. + */ +@Slf4j +public class ShipDetailImoFetchTasklet implements Tasklet { + + private static final String API_KEY = "SHIP_DETAIL_UPDATE_API"; + private static final String SHIP_UPDATE_API_PATH = "/MaritimeWCF/APSShipService.svc/RESTFul/GetShipChangesByLastUpdateDateRange"; + private static final String JOB_NAME = "ShipDetailUpdateJob"; + + private final WebClient maritimeApiWebClient; + private final BatchDateService batchDateService; + private final BatchApiLogService batchApiLogService; + private final BatchFailedRecordRepository batchFailedRecordRepository; + private final String maritimeApiUrl; + + public ShipDetailImoFetchTasklet( + WebClient maritimeApiWebClient, + BatchDateService batchDateService, + BatchApiLogService batchApiLogService, + BatchFailedRecordRepository batchFailedRecordRepository, + String maritimeApiUrl) { + this.maritimeApiWebClient = maritimeApiWebClient; + this.batchDateService = batchDateService; + this.batchApiLogService = batchApiLogService; + this.batchFailedRecordRepository = batchFailedRecordRepository; + this.maritimeApiUrl = maritimeApiUrl; + } + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { + JobExecution jobExecution = chunkContext.getStepContext() + .getStepExecution().getJobExecution(); + String executionMode = jobExecution.getJobParameters() + .getString("executionMode", "NORMAL"); + + String executor = jobExecution.getJobParameters().getString("executor", ""); + boolean isFailedRecordRetry = "AUTO_RETRY".equals(executor) || "MANUAL_RETRY".equals(executor); + + List imoNumbers; + + if ("RECOLLECT".equals(executionMode) && isFailedRecordRetry) { + // 실패건 재수집: DB에서 실패 IMO 조회 + imoNumbers = fetchRecollectImoNumbers(jobExecution); + } else { + // NORMAL 모드 또는 기간 재수집: Maritime API에서 IMO 조회 + // BatchDateService가 executionMode에 따라 적절한 날짜 범위를 결정 + imoNumbers = fetchChangedImoNumbers(jobExecution); + } + + // JobExecutionContext에 저장 + jobExecution.getExecutionContext().putInt("totalImoCount", imoNumbers.size()); + + if (!imoNumbers.isEmpty()) { + jobExecution.getExecutionContext().putString("allImoNumbers", String.join(",", imoNumbers)); + } + + log.info("[ShipDetailImoFetchTasklet] {} 모드: IMO {} 건 조회 완료", + executionMode, imoNumbers.size()); + + return RepeatStatus.FINISHED; + } + + private List fetchRecollectImoNumbers(JobExecution jobExecution) { + String sourceJobExecutionIdParam = jobExecution.getJobParameters() + .getString("sourceJobExecutionId"); + + if (sourceJobExecutionIdParam == null || sourceJobExecutionIdParam.isBlank()) { + log.warn("[ShipDetailImoFetchTasklet] RECOLLECT 모드이나 sourceJobExecutionId가 없음"); + return Collections.emptyList(); + } + + Long sourceJobExecutionId = Long.parseLong(sourceJobExecutionIdParam); + List retryKeys = batchFailedRecordRepository.findFailedRecordKeysByJobExecutionId( + JOB_NAME, sourceJobExecutionId); + + log.info("[ShipDetailImoFetchTasklet] RECOLLECT: DB에서 {} 건의 실패 키 조회 (sourceJobExecutionId: {})", + retryKeys.size(), sourceJobExecutionId); + + return retryKeys; + } + + private List fetchChangedImoNumbers(JobExecution jobExecution) { + Map params = batchDateService.getDateRangeWithoutTimeParams(API_KEY); + + MultiValueMap multiValueParams = new LinkedMultiValueMap<>(); + params.forEach((key, value) -> + multiValueParams.put(key, Collections.singletonList(value))); + + String fullUri = UriComponentsBuilder.fromHttpUrl(maritimeApiUrl) + .path(SHIP_UPDATE_API_PATH) + .queryParams(multiValueParams) + .build() + .toUriString(); + + long startTime = System.currentTimeMillis(); + int statusCode = 200; + String errorMessage = null; + Long responseSize = 0L; + + try { + log.info("[ShipDetailImoFetchTasklet] 변경 IMO 조회 API 호출: {}", fullUri); + + ShipUpdateApiResponse response = maritimeApiWebClient.get() + .uri(uriBuilder -> { + uriBuilder.path(SHIP_UPDATE_API_PATH); + params.forEach(uriBuilder::queryParam); + return uriBuilder.build(); + }) + .retrieve() + .bodyToMono(new ParameterizedTypeReference() {}) + .block(); + + if (response == null || response.getShips() == null) { + return Collections.emptyList(); + } + + List imoNumbers = response.getShips().stream() + .map(ShipDto::getImoNumber) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + responseSize = (long) imoNumbers.size(); + return imoNumbers; + + } catch (Exception e) { + statusCode = 500; + errorMessage = e.getMessage(); + log.error("[ShipDetailImoFetchTasklet] API 호출 실패: {}", e.getMessage(), e); + throw e; + } finally { + long duration = System.currentTimeMillis() - startTime; + batchApiLogService.saveLog(BatchApiLog.builder() + .apiRequestLocation("ShipDetailImoFetchTasklet") + .jobExecutionId(jobExecution.getId()) + .stepExecutionId(jobExecution.getStepExecutions().stream() + .findFirst().map(se -> se.getId()).orElse(null)) + .requestUri(fullUri) + .httpMethod("GET") + .statusCode(statusCode) + .responseTimeMs(duration) + .responseCount(responseSize) + .errorMessage(errorMessage) + .createdAt(LocalDateTime.now()) + .build()); + } + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/config/ShipDetailPartitioner.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/config/ShipDetailPartitioner.java new file mode 100644 index 0000000..1b1f7bd --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/config/ShipDetailPartitioner.java @@ -0,0 +1,18 @@ +package com.snp.batch.jobs.batch.shipdetail.config; + +import com.snp.batch.common.batch.partition.StringListPartitioner; + +import java.util.List; + +/** + * 선박제원정보 IMO 목록을 N개 파티션으로 분할하는 Partitioner. + * 공통 {@link StringListPartitioner}에 위임하며, ExecutionContext 키로 "partitionImoNumbers"를 사용. + */ +public class ShipDetailPartitioner extends StringListPartitioner { + + public static final String CONTEXT_KEY = "partitionImoNumbers"; + + public ShipDetailPartitioner(List allImoNumbers, int partitionCount) { + super(allImoNumbers, partitionCount, CONTEXT_KEY); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/config/ShipDetailUpdateJobConfig.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/config/ShipDetailUpdateJobConfig.java new file mode 100644 index 0000000..4e2298a --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/config/ShipDetailUpdateJobConfig.java @@ -0,0 +1,265 @@ +package com.snp.batch.jobs.batch.shipdetail.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.config.BasePartitionedJobConfig; +import com.snp.batch.common.batch.tasklet.LastExecutionUpdateTasklet; +import com.snp.batch.global.repository.BatchFailedRecordRepository; +import com.snp.batch.jobs.batch.shipdetail.dto.ShipDetailDto; +import com.snp.batch.jobs.batch.shipdetail.entity.ShipDetailEntity; +import com.snp.batch.jobs.batch.shipdetail.processor.ShipDetailDataProcessor; +import com.snp.batch.jobs.batch.shipdetail.reader.ShipDetailUpdateDataReader; +import com.snp.batch.jobs.batch.shipdetail.writer.ShipDetailDataWriter; +import com.snp.batch.service.BatchApiLogService; +import com.snp.batch.service.BatchDateService; +import com.snp.batch.service.BatchFailedRecordService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecutionListener; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.job.flow.JobExecutionDecider; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.TaskExecutor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +@Slf4j +@Configuration +public class ShipDetailUpdateJobConfig extends BasePartitionedJobConfig { + + private final ShipDetailDataProcessor shipDetailDataProcessor; + private final ShipDetailDataWriter shipDetailDataWriter; + private final ShipDetailUpdateDataReader shipDetailUpdateDataReader; + private final JdbcTemplate jdbcTemplate; + private final WebClient maritimeApiWebClient; + private final ObjectMapper objectMapper; + private final BatchDateService batchDateService; + private final BatchApiLogService batchApiLogService; + private final BatchFailedRecordService batchFailedRecordService; + private final BatchFailedRecordRepository batchFailedRecordRepository; + private final JobExecutionListener autoRetryJobExecutionListener; + private final TaskExecutor batchPartitionExecutor; + + @Value("${app.batch.ship-api.url}") + private String maritimeApiUrl; + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + + @Value("${app.batch.ship-detail-update.batch-size:10}") + private int shipDetailBatchSize; + + @Value("${app.batch.ship-detail-update.delay-on-success-ms:300}") + private long delayOnSuccessMs; + + @Value("${app.batch.ship-detail-update.delay-on-failure-ms:2000}") + private long delayOnFailureMs; + + @Value("${app.batch.ship-detail-update.max-retry-count:3}") + private int maxRetryCount; + + @Value("${app.batch.last-execution-buffer-hours:24}") + private int lastExecutionBufferHours; + + @Value("${app.batch.ship-detail-update.partition-count:4}") + private int partitionCount; + + protected String getApiKey() {return "SHIP_DETAIL_UPDATE_API";} + + public ShipDetailUpdateJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + ShipDetailDataProcessor shipDetailDataProcessor, + ShipDetailDataWriter shipDetailDataWriter, + ShipDetailUpdateDataReader shipDetailUpdateDataReader, + JdbcTemplate jdbcTemplate, + @Qualifier("maritimeApiWebClient") WebClient maritimeApiWebClient, + ObjectMapper objectMapper, + BatchDateService batchDateService, + BatchApiLogService batchApiLogService, + BatchFailedRecordService batchFailedRecordService, + BatchFailedRecordRepository batchFailedRecordRepository, + @Qualifier("autoRetryJobExecutionListener") JobExecutionListener autoRetryJobExecutionListener, + @Qualifier("batchPartitionExecutor") TaskExecutor batchPartitionExecutor) { + super(jobRepository, transactionManager); + this.shipDetailDataProcessor = shipDetailDataProcessor; + this.shipDetailDataWriter = shipDetailDataWriter; + this.shipDetailUpdateDataReader = shipDetailUpdateDataReader; + this.jdbcTemplate = jdbcTemplate; + this.maritimeApiWebClient = maritimeApiWebClient; + this.objectMapper = objectMapper; + this.batchDateService = batchDateService; + this.batchApiLogService = batchApiLogService; + this.batchFailedRecordService = batchFailedRecordService; + this.batchFailedRecordRepository = batchFailedRecordRepository; + this.autoRetryJobExecutionListener = autoRetryJobExecutionListener; + this.batchPartitionExecutor = batchPartitionExecutor; + } + + @Override + protected void configureJob(JobBuilder jobBuilder) { + jobBuilder.listener(autoRetryJobExecutionListener); + } + + @Override + protected String getJobName() { + return "ShipDetailUpdateJob"; + } + + @Override + protected String getStepName() { + return "ShipDetailUpdateStep"; + } + + // ======================================== + // Job Flow 정의 + // ======================================== + + @Override + protected Job createJobFlow(JobBuilder jobBuilder) { + return jobBuilder + .start(shipDetailImoFetchStep()) + .next(imoCountDecider()) + .on("EMPTY_RESPONSE").end() + .from(imoCountDecider()).on("NORMAL").to(shipDetailUpdatePartitionedStep()) + .next(shipDetailLastExecutionUpdateStep()) + .end() + .build(); + } + + // ======================================== + // Step 0: IMO 목록 조회 + // ======================================== + + @Bean + public Tasklet shipDetailImoFetchTasklet() { + return new ShipDetailImoFetchTasklet( + maritimeApiWebClient, batchDateService, batchApiLogService, + batchFailedRecordRepository, maritimeApiUrl); + } + + @Bean(name = "ShipDetailImoFetchStep") + public Step shipDetailImoFetchStep() { + return new StepBuilder("ShipDetailImoFetchStep", jobRepository) + .tasklet(shipDetailImoFetchTasklet(), transactionManager) + .build(); + } + + // ======================================== + // Decider: IMO 건수 확인 + // ======================================== + + @Bean + public JobExecutionDecider imoCountDecider() { + return createKeyCountDecider("totalImoCount", getJobName()); + } + + // ======================================== + // Step 1: Partitioned Step (병렬 처리) + // ======================================== + + @Bean + @StepScope + public ShipDetailPartitioner shipDetailPartitioner( + @Value("#{jobExecutionContext['allImoNumbers']}") String allImoNumbersStr + ) { + List allImoNumbers = (allImoNumbersStr != null && !allImoNumbersStr.isBlank()) + ? Arrays.asList(allImoNumbersStr.split(",")) + : Collections.emptyList(); + return new ShipDetailPartitioner(allImoNumbers, partitionCount); + } + + @Bean(name = "ShipDetailUpdatePartitionedStep") + public Step shipDetailUpdatePartitionedStep() { + return createPartitionedStep( + "ShipDetailUpdatePartitionedStep", "ShipDetailUpdateStep", + shipDetailPartitioner(null), shipDetailUpdateWorkerStep(), + batchPartitionExecutor, partitionCount); + } + + @Bean + public Step shipDetailUpdateWorkerStep() { + return new StepBuilder("ShipDetailUpdateStep", jobRepository) + .chunk(getChunkSize(), transactionManager) + .reader(shipDetailUpdateDataReader(null, null, null, null)) + .processor(shipDetailDataProcessor(null)) + .writer(shipDetailDataWriter) + .build(); + } + + // ======================================== + // Reader / Processor (StepScope) + // ======================================== + + @Bean + @StepScope + public ShipDetailUpdateDataReader shipDetailUpdateDataReader( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, + @Value("#{stepExecution.id}") Long stepExecutionId, + @Value("#{jobParameters['executionMode']}") String executionMode, + @Value("#{stepExecutionContext['partitionImoNumbers']}") String partitionImoNumbers + ) { + ShipDetailUpdateDataReader reader = new ShipDetailUpdateDataReader( + maritimeApiWebClient, jdbcTemplate, objectMapper, + batchDateService, batchApiLogService, batchFailedRecordService, maritimeApiUrl, + shipDetailBatchSize, delayOnSuccessMs, delayOnFailureMs, maxRetryCount + ); + reader.setExecutionIds(jobExecutionId, stepExecutionId); + reader.setPartitionImoNumbers(partitionImoNumbers); + + if ("RECOLLECT".equals(executionMode)) { + reader.setRecollectMode(true); + } + + return reader; + } + + @Bean + @StepScope + public ShipDetailDataProcessor shipDetailDataProcessor( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId + ) { + return new ShipDetailDataProcessor(jobExecutionId); + } + + @Override + protected int getChunkSize() { + return 20; + } + + // ======================================== + // Job / Step Bean 등록 + // ======================================== + + @Bean(name = "ShipDetailUpdateJob") + public Job shipDetailUpdateJob() { + return job(); + } + + // ======================================== + // Step 2: LastExecution 업데이트 + // ======================================== + + @Bean + public Tasklet shipDetailLastExecutionUpdateTasklet() { + return new LastExecutionUpdateTasklet(jdbcTemplate, targetSchema, getApiKey(), lastExecutionBufferHours); + } + + @Bean(name = "ShipDetailLastExecutionUpdateStep") + public Step shipDetailLastExecutionUpdateStep() { + return createLastExecutionUpdateStep("ShipDetailLastExecutionUpdateStep", + shipDetailLastExecutionUpdateTasklet()); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/AdditionalInformationDto.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/AdditionalInformationDto.java new file mode 100644 index 0000000..aea8f1d --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/AdditionalInformationDto.java @@ -0,0 +1,66 @@ +package com.snp.batch.jobs.batch.shipdetail.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.snp.batch.jobs.batch.shipdetail.entity.AdditionalInformationEntity; +import lombok.*; + +@Getter +@Setter +@ToString +@NoArgsConstructor +public class AdditionalInformationDto { + @JsonProperty("DataSetVersion") + private DataSetVersion dataSetVersion; + + @Data + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class DataSetVersion { + @JsonProperty("DataSetVersion") + private String version; + } + @JsonProperty("LRNO") + private String lrno; + @JsonProperty("ShipEmail") + private String shipEmail; + @JsonProperty("WaterDepthMax") + private String waterDepthMax; + @JsonProperty("DrillDepthMax") + private String drillDepthMax; + @JsonProperty("DrillBargeInd") + private String drillBargeInd; + @JsonProperty("ProductionVesselInd") + private String productionVesselInd; + @JsonProperty("DeckHeatExchangerInd") + private String deckHeatExchangerInd; + @JsonProperty("DeckHeatExchangerMaterial") + private String deckHeatExchangerMaterial; + @JsonProperty("TweenDeckPortable") + private String tweenDeckPortable; + @JsonProperty("TweenDeckFixed") + private String tweenDeckFixed; + @JsonProperty("SatComID") + private String satComID; + @JsonProperty("SatComAnsBack") + private String satComAnsBack; + + public AdditionalInformationEntity toEntity() { + return AdditionalInformationEntity.builder() + .lrno(this.lrno) + .shipemail(this.shipEmail) + .waterdepthmax(this.waterDepthMax) + .drilldepthmax(this.drillDepthMax) + .drillbargeind(this.drillBargeInd) + .productionvesselind(this.productionVesselInd) + .deckheatexchangerind(this.deckHeatExchangerInd) + .deckheatexchangermaterial(this.deckHeatExchangerMaterial) + .tweendeckportable(this.tweenDeckPortable) + .tweendeckfixed(this.tweenDeckFixed) + .satcomid(this.satComID) + .satcomansback(this.satComAnsBack) + .dataSetVersion(this.dataSetVersion != null ? this.dataSetVersion.getVersion() : null) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/BareBoatCharterHistoryDto.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/BareBoatCharterHistoryDto.java new file mode 100644 index 0000000..08031ea --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/BareBoatCharterHistoryDto.java @@ -0,0 +1,42 @@ +package com.snp.batch.jobs.batch.shipdetail.dto; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.snp.batch.jobs.batch.shipdetail.entity.BareBoatCharterHistoryEntity; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@NoArgsConstructor +public class BareBoatCharterHistoryDto { + @Getter @Setter @ToString @NoArgsConstructor + public static class DataSetVersionDto { + @JsonProperty("DataSetVersion") + private String dataSetVersion; + } + @JsonProperty("DataSetVersion") + private DataSetVersionDto dataSetVersion; + @JsonProperty("LRNO") + private String lrno; + @JsonProperty("Sequence") + private String sequence; + @JsonProperty("EffectiveDate") + private String effectiveDate; + @JsonProperty("BBChartererCode") + private String bbChartererCode; + @JsonProperty("BBCharterer") + private String bbCharterer; + + public BareBoatCharterHistoryEntity toEntity() { + return BareBoatCharterHistoryEntity.builder() + .dataSetVersion(this.dataSetVersion != null ? this.dataSetVersion.getDataSetVersion() : null) + .lrno(this.lrno) + .sequence(this.sequence) + .effectiveDate(this.effectiveDate) + .bbChartererCode(this.bbChartererCode) + .bbCharterer(this.bbCharterer) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/CallSignAndMmsiHistoryDto.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/CallSignAndMmsiHistoryDto.java new file mode 100644 index 0000000..2493f3d --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/CallSignAndMmsiHistoryDto.java @@ -0,0 +1,43 @@ +package com.snp.batch.jobs.batch.shipdetail.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.snp.batch.jobs.batch.shipdetail.entity.CallSignAndMmsiHistoryEntity; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@NoArgsConstructor +public class CallSignAndMmsiHistoryDto { + @Getter @Setter @ToString @NoArgsConstructor + public static class DataSetVersionDto { + @JsonProperty("DataSetVersion") + private String dataSetVersion; + } + @JsonProperty("DataSetVersion") + private DataSetVersionDto dataSetVersion; + @JsonProperty("Lrno") + private String lrno; + @JsonProperty("SeqNo") + private String seqNo; + @JsonProperty("CallSign") + private String callSign; + @JsonProperty("Mmsi") + private String mmsi; + @JsonProperty("EffectiveDate") + private String effectiveDate; + + public CallSignAndMmsiHistoryEntity toEntity() { + return CallSignAndMmsiHistoryEntity.builder() + .dataSetVersion(this.dataSetVersion != null ? this.dataSetVersion.getDataSetVersion() : null) + .lrno(this.lrno) + .sequence(this.seqNo) // SeqNo -> sequence 매핑 + .callsign(this.callSign) + .mmsi(this.mmsi) // DTO에 정의된 mmsi 필드 사용 + .effectiveDate(this.effectiveDate) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/ClassHistoryDto.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/ClassHistoryDto.java new file mode 100644 index 0000000..0b806c4 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/ClassHistoryDto.java @@ -0,0 +1,52 @@ +package com.snp.batch.jobs.batch.shipdetail.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.snp.batch.jobs.batch.shipdetail.entity.ClassHistoryEntity; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@NoArgsConstructor +public class ClassHistoryDto { + @Getter @Setter @ToString @NoArgsConstructor + public static class DataSetVersionDto { + @JsonProperty("DataSetVersion") + private String dataSetVersion; + } + @JsonProperty("DataSetVersion") + private DataSetVersionDto dataSetVersion; + @JsonProperty("Class") + private String _class; // 'class' is a reserved keyword in Java + @JsonProperty("ClassCode") + private String classCode; + @JsonProperty("ClassIndicator") + private String classIndicator; + @JsonProperty("ClassID") + private String classID; + @JsonProperty("CurrentIndicator") + private String currentIndicator; + @JsonProperty("EffectiveDate") + private String effectiveDate; + @JsonProperty("LRNO") + private String lrno; + @JsonProperty("Sequence") + private String sequence; + + public ClassHistoryEntity toEntity() { + return ClassHistoryEntity.builder() + .dataSetVersion(this.dataSetVersion != null ? this.dataSetVersion.getDataSetVersion() : null) + ._class(this._class) + .classCode(this.classCode) + .classIndicator(this.classIndicator) + .classID(this.classID) + .currentIndicator(this.currentIndicator) + .effectiveDate(this.effectiveDate) + .lrno(this.lrno) + .sequence(this.sequence) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/CompanyDetailDto.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/CompanyDetailDto.java new file mode 100644 index 0000000..4d9cef3 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/CompanyDetailDto.java @@ -0,0 +1,118 @@ +package com.snp.batch.jobs.batch.shipdetail.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.snp.batch.jobs.batch.shipdetail.entity.CompanyDetailEntity; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@NoArgsConstructor +public class CompanyDetailDto { + + @JsonProperty("DataSetVersion") + private DataSetVersionDto dataSetVersion; + + @JsonProperty("CompanyStatus") + private String companyStatus; + @JsonProperty("CountryName") + private String countryName; + @JsonProperty("Emailaddress") + private String emailaddress; + @JsonProperty("FoundedDate") + private String foundedDate; + @JsonProperty("FullAddress") + private String fullAddress; + @JsonProperty("FullName") + private String fullName; + @JsonProperty("LastChangeDate") + private String lastChangeDate; + @JsonProperty("LocationCode") + private String locationCode; + @JsonProperty("NationalityofControl") + private String nationalityofControl; + @JsonProperty("NationalityofControlCode") + private String nationalityofControlCode; + @JsonProperty("NationalityofRegistration") + private String nationalityofRegistration; + @JsonProperty("NationalityofRegistrationCode") + private String nationalityofRegistrationCode; + @JsonProperty("OWCODE") + private String owcode; + @JsonProperty("ParentCompany") + private String parentCompany; + @JsonProperty("RoomFloorBuilding1") + private String roomFloorBuilding1; + @JsonProperty("RoomFloorBuilding2") + private String roomFloorBuilding2; + @JsonProperty("RoomFloorBuilding3") + private String roomFloorBuilding3; + @JsonProperty("ShortCompanyName") + private String shortCompanyName; + @JsonProperty("Street") + private String street; + @JsonProperty("StreetNumber") + private String streetNumber; + @JsonProperty("Telephone") + private String telephone; + @JsonProperty("TownName") + private String townName; + @JsonProperty("Website") + private String website; + @JsonProperty("Facsimile") + private String facsimile; + @JsonProperty("Telex") + private String telex; + @JsonProperty("CareOfCode") + private String careOfCode; + @JsonProperty("POBox") + private String poBox; + @JsonProperty("PrePostcode") + private String prePostcode; + @JsonProperty("PostPostcode") + private String postPostcode; + + @Getter @Setter @ToString @NoArgsConstructor + public static class DataSetVersionDto { + @JsonProperty("DataSetVersion") + private String dataSetVersion; + } + + public CompanyDetailEntity toEntity() { + return CompanyDetailEntity.builder() + .dataSetVersion(this.dataSetVersion != null ? this.dataSetVersion.getDataSetVersion() : null) + .owcode(this.owcode) + .shortcompanyname(this.shortCompanyName) + .countryname(this.countryName) + .townname(this.townName) + .telephone(this.telephone) + .telex(this.telex) + .emailaddress(this.emailaddress) + .website(this.website) + .fullname(this.fullName) + .careofcode(this.careOfCode) + .roomfloorbuilding1(this.roomFloorBuilding1) + .roomfloorbuilding2(this.roomFloorBuilding2) + .roomfloorbuilding3(this.roomFloorBuilding3) + .pobox(this.poBox) + .streetnumber(this.streetNumber) + .street(this.street) + .prepostcode(this.prePostcode) + .postpostcode(this.postPostcode) + .nationalityofregistration(this.nationalityofRegistration) + .nationalityofcontrol(this.nationalityofControl) + .locationcode(this.locationCode) + .nationalityofregistrationcode(this.nationalityofRegistrationCode) + .nationalityofcontrolcode(this.nationalityofControlCode) + .lastchangedate(this.lastChangeDate) + .parentcompany(this.parentCompany) + .companystatus(this.companyStatus) + .fulladdress(this.fullAddress) + .facsimile(this.facsimile) + .foundeddate(this.foundedDate) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/CompanyVesselRelationshipDto.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/CompanyVesselRelationshipDto.java new file mode 100644 index 0000000..cfd5f10 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/CompanyVesselRelationshipDto.java @@ -0,0 +1,92 @@ +package com.snp.batch.jobs.batch.shipdetail.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.snp.batch.jobs.batch.shipdetail.entity.CompanyVesselRelationshipEntity; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@NoArgsConstructor +public class CompanyVesselRelationshipDto { + @Getter @Setter @ToString @NoArgsConstructor + public static class DataSetVersionDto { + @JsonProperty("DataSetVersion") + private String dataSetVersion; + } + @JsonProperty("DataSetVersion") + private DataSetVersionDto dataSetVersion; + + @JsonProperty("DOCCode") + private String docCode; + @JsonProperty("DOCCompany") + private String docCompany; + @JsonProperty("DOCGroup") + private String docGroup; + @JsonProperty("DOCGroupCode") + private String docGroupCode; + @JsonProperty("GroupBeneficialOwner") + private String groupBeneficialOwner; + @JsonProperty("GroupBeneficialOwnerCode") + private String groupBeneficialOwnerCode; + @JsonProperty("LRNO") + private String lrno; + @JsonProperty("Operator") + private String operator; + @JsonProperty("OperatorCode") + private String operatorCode; + @JsonProperty("OperatorGroup") + private String operatorGroup; + @JsonProperty("OperatorGroupCode") + private String operatorGroupCode; + @JsonProperty("RegisteredOwner") + private String registeredOwner; + @JsonProperty("RegisteredOwnerCode") + private String registeredOwnerCode; + @JsonProperty("ShipManager") + private String shipManager; + @JsonProperty("ShipManagerCode") + private String shipManagerCode; + @JsonProperty("ShipManagerGroup") + private String shipManagerGroup; + @JsonProperty("ShipManagerGroupCode") + private String shipManagerGroupCode; + @JsonProperty("TechnicalManager") + private String technicalManager; + @JsonProperty("TechnicalManagerCode") + private String technicalManagerCode; + @JsonProperty("TechnicalManagerGroup") + private String technicalManagerGroup; + @JsonProperty("TechnicalManagerGroupCode") + private String technicalManagerGroupCode; + + public CompanyVesselRelationshipEntity toEntity() { + return CompanyVesselRelationshipEntity.builder() + .datasetversion(this.dataSetVersion != null ? this.dataSetVersion.getDataSetVersion() : null) + .doccode(this.docCode) + .doccompany(this.docCompany) + .docgroup(this.docGroup) + .docgroupcode(this.docGroupCode) + .groupbeneficialowner(this.groupBeneficialOwner) + .groupbeneficialownercode(this.groupBeneficialOwnerCode) + .lrno(this.lrno) + .operator(this.operator) + .operatorcode(this.operatorCode) + .operatorgroup(this.operatorGroup) + .operatorgroupcode(this.operatorGroupCode) + .registeredowner(this.registeredOwner) + .registeredownercode(this.registeredOwnerCode) + .shipmanager(this.shipManager) + .shipmanagercode(this.shipManagerCode) + .shipmanagergroup(this.shipManagerGroup) + .shipmanagergroupcode(this.shipManagerGroupCode) + .technicalmanager(this.technicalManager) + .technicalmanagercode(this.technicalManagerCode) + .technicalmanagergroup(this.technicalManagerGroup) + .technicalmanagergroupcode(this.technicalManagerGroupCode) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/CrewListDto.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/CrewListDto.java new file mode 100644 index 0000000..ca185f1 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/CrewListDto.java @@ -0,0 +1,80 @@ +package com.snp.batch.jobs.batch.shipdetail.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.snp.batch.jobs.batch.shipdetail.entity.CrewListEntity; +import lombok.*; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class CrewListDto { + // Nested class for DataSetVersion object + @Getter + @Setter + @ToString + @NoArgsConstructor + public static class DataSetVersionDto { + @JsonProperty("DataSetVersion") + private String dataSetVersion; + } + + @JsonProperty("DataSetVersion") + private DataSetVersionDto dataSetVersion; + + @JsonProperty("ID") + private String id; + + @JsonProperty("LRNO") + private String lrno; + + @JsonProperty("Shipname") + private String shipname; + + @JsonProperty("CrewListDate") + private String crewListDate; + + @JsonProperty("Nationality") + private String nationality; + + @JsonProperty("TotalCrew") + private String totalCrew; + + @JsonProperty("TotalRatings") + private String totalRatings; + + @JsonProperty("TotalOfficers") + private String totalOfficers; + + @JsonProperty("TotalCadets") + private String totalCadets; + + @JsonProperty("TotalTrainees") + private String totalTrainees; + + @JsonProperty("TotalRidingSquad") + private String totalRidingSquad; + + @JsonProperty("TotalUndeclared") + private String totalUndeclared; + + public CrewListEntity toEntity() { + return CrewListEntity.builder() + .dataSetVersion(this.dataSetVersion != null ? this.dataSetVersion.getDataSetVersion() : null) + .id(this.id) + .lrno(this.lrno) + .shipname(this.shipname) + .crewlistdate(this.crewListDate) + .nationality(this.nationality) + .totalcrew(this.totalCrew) + .totalratings(this.totalRatings) + .totalofficers(this.totalOfficers) + .totalcadets(this.totalCadets) + .totaltrainees(this.totalTrainees) + .totalridingsquad(this.totalRidingSquad) + .totalundeclared(this.totalUndeclared) + .build(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/DarkActivityConfirmedDto.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/DarkActivityConfirmedDto.java new file mode 100644 index 0000000..888047c --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/DarkActivityConfirmedDto.java @@ -0,0 +1,81 @@ +package com.snp.batch.jobs.batch.shipdetail.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.snp.batch.jobs.batch.shipdetail.entity.DarkActivityConfirmedEntity; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@NoArgsConstructor +public class DarkActivityConfirmedDto { + @Getter @Setter @ToString @NoArgsConstructor + public static class DataSetVersionDto { + @JsonProperty("DataSetVersion") + private String dataSetVersion; + } + @JsonProperty("DataSetVersion") + private DataSetVersionDto dataSetVersion; + + @JsonProperty("Lrno") private String lrno; + @JsonProperty("Mmsi") private String mmsi; + @JsonProperty("Vessel_Name") private String vesselName; + @JsonProperty("Dark_Hours") private String darkHours; + @JsonProperty("Dark_Activity") private String darkActivity; + @JsonProperty("Dark_Status") private String darkStatus; + @JsonProperty("Area_Id") private String areaId; + @JsonProperty("Area_Name") private String areaName; + @JsonProperty("Area_Country") private String areaCountry; + @JsonProperty("Dark_Time") private String darkTime; + @JsonProperty("Dark_Latitude") private String darkLatitude; + @JsonProperty("Dark_Longitude") private String darkLongitude; + @JsonProperty("Dark_Speed") private String darkSpeed; + @JsonProperty("Dark_Heading") private String darkHeading; + @JsonProperty("Dark_Draught") private String darkDraught; + @JsonProperty("NextSeen") private String nextSeen; + @JsonProperty("NextSeen_Latitude") private String nextSeenLatitude; + @JsonProperty("NextSeen_Longitude") private String nextSeenLongitude; + @JsonProperty("NextSeen_Speed") private String nextSeenSpeed; + @JsonProperty("NextSeen_Draught") private String nextSeenDraught; + @JsonProperty("NextSeen_Heading") private String nextSeenHeading; + @JsonProperty("Dark_Reported_Destination") private String darkReportedDestination; + @JsonProperty("NextSeen_Reported_Destination") private String nextSeenReportedDestination; + @JsonProperty("Last_Port_of_Call") private String lastPortOfCall; + @JsonProperty("Last_Port_Country_Code") private String lastPortCountryCode; + @JsonProperty("Last_Port_Country") private String lastPortCountry; + + public DarkActivityConfirmedEntity toEntity() { + return DarkActivityConfirmedEntity.builder() + .datasetversion(this.dataSetVersion != null ? this.dataSetVersion.getDataSetVersion() : null) + .lrno(this.lrno) + .mmsi(this.mmsi) + .vessel_name(this.vesselName) + .dark_hours(this.darkHours) + .dark_activity(this.darkActivity) + .dark_status(this.darkStatus) + .area_id(this.areaId) + .area_name(this.areaName) + .area_country(this.areaCountry) + .dark_time(this.darkTime) + .dark_latitude(this.darkLatitude) + .dark_longitude(this.darkLongitude) + .dark_speed(this.darkSpeed) + .dark_heading(this.darkHeading) + .dark_draught(this.darkDraught) + .nextseen(this.nextSeen) + .nextseen_latitude(this.nextSeenLatitude) + .nextseen_longitude(this.nextSeenLongitude) + .nextseen_speed(this.nextSeenSpeed) + .nextseen_draught(this.nextSeenDraught) + .nextseen_heading(this.nextSeenHeading) + .dark_reported_destination(this.darkReportedDestination) + .nextseen_reported_destination(this.nextSeenReportedDestination) + .last_port_of_call(this.lastPortOfCall) + .last_port_country_code(this.lastPortCountryCode) + .last_port_country(this.lastPortCountry) + .build(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/FlagHistoryDto.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/FlagHistoryDto.java new file mode 100644 index 0000000..39fac3d --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/FlagHistoryDto.java @@ -0,0 +1,43 @@ +package com.snp.batch.jobs.batch.shipdetail.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.snp.batch.jobs.batch.shipdetail.entity.FlagHistoryEntity; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@NoArgsConstructor +public class FlagHistoryDto { + @Getter @Setter @ToString @NoArgsConstructor + public static class DataSetVersionDto { + @JsonProperty("DataSetVersion") + private String dataSetVersion; + } + @JsonProperty("DataSetVersion") + private DataSetVersionDto dataSetVersion; + @JsonProperty("EffectiveDate") + private String effectiveDate; + @JsonProperty("Flag") + private String flag; + @JsonProperty("FlagCode") + private String flagCode; + @JsonProperty("LRNO") + private String lrno; + @JsonProperty("Sequence") + private String sequence; + + public FlagHistoryEntity toEntity() { + return FlagHistoryEntity.builder() + .dataSetVersion(this.dataSetVersion != null ? this.dataSetVersion.getDataSetVersion() : null) + .effectiveDate(this.effectiveDate) + .flag(this.flag) + .flagCode(this.flagCode) + .lrno(this.lrno) + .sequence(this.sequence) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/GroupBeneficialOwnerHistoryDto.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/GroupBeneficialOwnerHistoryDto.java new file mode 100644 index 0000000..4f7e101 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/GroupBeneficialOwnerHistoryDto.java @@ -0,0 +1,57 @@ +package com.snp.batch.jobs.batch.shipdetail.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.snp.batch.jobs.batch.shipdetail.entity.GroupBeneficialOwnerHistoryEntity; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@NoArgsConstructor +public class GroupBeneficialOwnerHistoryDto { + + @Getter + @Setter + @ToString + @NoArgsConstructor + public static class DataSetVersionDto { + @JsonProperty("DataSetVersion") + private String dataSetVersion; + } + + @JsonProperty("DataSetVersion") + private DataSetVersionDto dataSetVersion; // 데이터셋버전 + + @JsonProperty("CompanyStatus") + private String companyStatus; // 회사상태 + + @JsonProperty("EffectiveDate") + private String effectiveDate; // 효력일 + + @JsonProperty("GroupBeneficialOwner") + private String groupBeneficialOwner; // 그룹실질소유자 + + @JsonProperty("GroupBeneficialOwnerCode") + private String groupBeneficialOwnerCode; // 그룹실질소유자코드 + + @JsonProperty("LRNO") + private String lrno; // LR/IMO번호 + + @JsonProperty("Sequence") + private String sequence; // 순번 + + public GroupBeneficialOwnerHistoryEntity toEntity() { + return GroupBeneficialOwnerHistoryEntity.builder() + .dataSetVersion(this.dataSetVersion != null ? this.dataSetVersion.getDataSetVersion() : null) + .companyStatus(this.companyStatus) + .effectiveDate(this.effectiveDate) + .groupBeneficialOwner(this.groupBeneficialOwner) + .groupBeneficialOwnerCode(this.groupBeneficialOwnerCode) + .lrno(this.lrno) + .sequence(this.sequence) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/IceClassDto.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/IceClassDto.java new file mode 100644 index 0000000..55ec590 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/IceClassDto.java @@ -0,0 +1,37 @@ +package com.snp.batch.jobs.batch.shipdetail.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.snp.batch.jobs.batch.shipdetail.entity.IceClassEntity; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@NoArgsConstructor +public class IceClassDto { + @Getter @Setter @ToString @NoArgsConstructor + public static class DataSetVersionDto { + @JsonProperty("DataSetVersion") + private String dataSetVersion; + } + @JsonProperty("DataSetVersion") + private DataSetVersionDto dataSetVersion; + @JsonProperty("IceClass") + private String iceClass; + @JsonProperty("IceClassCode") + private String iceClassCode; + @JsonProperty("LRNO") + private String lrno; + + public IceClassEntity toEntity() { + return IceClassEntity.builder() + .dataSetVersion(this.dataSetVersion != null ? this.dataSetVersion.getDataSetVersion() : null) + .iceClass(this.iceClass) + .iceClassCode(this.iceClassCode) + .lrno(this.lrno) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/NameHistoryDto.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/NameHistoryDto.java new file mode 100644 index 0000000..da1d2d0 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/NameHistoryDto.java @@ -0,0 +1,40 @@ +package com.snp.batch.jobs.batch.shipdetail.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.snp.batch.jobs.batch.shipdetail.entity.NameHistoryEntity; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@NoArgsConstructor +public class NameHistoryDto { + @Getter @Setter @ToString @NoArgsConstructor + public static class DataSetVersionDto { + @JsonProperty("DataSetVersion") + private String dataSetVersion; + } + @JsonProperty("DataSetVersion") + private DataSetVersionDto dataSetVersion; + @JsonProperty("Effective_Date") + private String effectiveDate; + @JsonProperty("LRNO") + private String lrno; + @JsonProperty("Sequence") + private String sequence; + @JsonProperty("VesselName") + private String vesselName; + + public NameHistoryEntity toEntity() { + return NameHistoryEntity.builder() + .dataSetVersion(this.dataSetVersion != null ? this.dataSetVersion.getDataSetVersion() : null) + .effectiveDate(this.effectiveDate) + .lrno(this.lrno) + .sequence(this.sequence) + .vesselName(this.vesselName) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/OperatorHistoryDto.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/OperatorHistoryDto.java new file mode 100644 index 0000000..106250f --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/OperatorHistoryDto.java @@ -0,0 +1,56 @@ +package com.snp.batch.jobs.batch.shipdetail.dto; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.snp.batch.jobs.batch.shipdetail.entity.OperatorHistoryEntity; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@NoArgsConstructor +public class OperatorHistoryDto { + + @Getter + @Setter + @ToString + @NoArgsConstructor + public static class DataSetVersionDto { + @JsonProperty("DataSetVersion") + private String dataSetVersion; + } + + @JsonProperty("DataSetVersion") + private DataSetVersionDto dataSetVersion; + + @JsonProperty("CompanyStatus") + private String companyStatus; + + @JsonProperty("EffectiveDate") + private String effectiveDate; + + @JsonProperty("LRNO") + private String lrno; + + @JsonProperty("Operator") + private String operator; + + @JsonProperty("OperatorCode") + private String operatorCode; + + @JsonProperty("Sequence") + private String sequence; + + public OperatorHistoryEntity toEntity() { + return OperatorHistoryEntity.builder() + .dataSetVersion(this.dataSetVersion != null ? this.dataSetVersion.getDataSetVersion() : null) + .companyStatus(this.companyStatus) + .effectiveDate(this.effectiveDate) + .lrno(this.lrno) + .operator(this.operator) + .operatorCode(this.operatorCode) + .sequence(this.sequence) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/OwnerHistoryDto.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/OwnerHistoryDto.java new file mode 100644 index 0000000..42d8f69 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/OwnerHistoryDto.java @@ -0,0 +1,77 @@ +package com.snp.batch.jobs.batch.shipdetail.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.snp.batch.jobs.batch.shipdetail.entity.OwnerHistoryEntity; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class OwnerHistoryDto { + /** + * 회사 상태 + * API: CompanyStatus + */ + @JsonProperty("CompanyStatus") + private String CompanyStatus; + /** + * 효력 일자 + * API: EffectiveDate + */ + @JsonProperty("EffectiveDate") + private String EffectiveDate; + /** + * IMO 번호 + * API: LRNO + */ + @JsonProperty("LRNO") + private String LRNO; + /** + * 소유주 + * API: Owner + */ + @JsonProperty("Owner") + private String Owner; + /** + * 소유주 코드 + * API: OwnerCode + */ + @JsonProperty("OwnerCode") + private String OwnerCode; + /** + * 시퀀스 + * API: Sequence + */ + @JsonProperty("Sequence") + private String Sequence; + + @JsonProperty("DataSetVersion") + private DataSetVersion dataSetVersion; + + @Data + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class DataSetVersion { + @JsonProperty("DataSetVersion") + private String version; + } + + public OwnerHistoryEntity toEntity(){ + return OwnerHistoryEntity.builder() + .CompanyStatus(this.CompanyStatus) + .EffectiveDate(this.EffectiveDate) + .LRNO(this.LRNO) + .Owner(this.Owner) + .OwnerCode(this.OwnerCode) + .Sequence(this.Sequence) + .dataSetVersion(this.dataSetVersion.version) + .build(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/PandIHistoryDto.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/PandIHistoryDto.java new file mode 100644 index 0000000..9ea963b --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/PandIHistoryDto.java @@ -0,0 +1,46 @@ +package com.snp.batch.jobs.batch.shipdetail.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.snp.batch.jobs.batch.shipdetail.entity.PandIHistoryEntity; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@NoArgsConstructor +public class PandIHistoryDto { + @Getter @Setter @ToString @NoArgsConstructor + public static class DataSetVersionDto { + @JsonProperty("DataSetVersion") + private String dataSetVersion; + } + @JsonProperty("DataSetVersion") + private DataSetVersionDto dataSetVersion; + @JsonProperty("LRNO") + private String lrno; + @JsonProperty("Sequence") + private String sequence; + @JsonProperty("PandIClubCode") + private String pandIClubCode; + @JsonProperty("PandIClubDecode") + private String pandIClubDecode; + @JsonProperty("EffectiveDate") + private String effectiveDate; + @JsonProperty("Source") + private String source; + + public PandIHistoryEntity toEntity() { + return PandIHistoryEntity.builder() + .dataSetVersion(this.dataSetVersion != null ? this.dataSetVersion.getDataSetVersion() : null) + .lrno(this.lrno) + .sequence(this.sequence) + .pandiclubcode(this.pandIClubCode) + .pandiclubdecode(this.pandIClubDecode) + .effectiveDate(this.effectiveDate) + .source(this.source) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/SafetyManagementCertificateHistoryDto.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/SafetyManagementCertificateHistoryDto.java new file mode 100644 index 0000000..16df2cf --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/SafetyManagementCertificateHistoryDto.java @@ -0,0 +1,70 @@ +package com.snp.batch.jobs.batch.shipdetail.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.snp.batch.jobs.batch.shipdetail.entity.SafetyManagementCertificateHistoryEntity; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@NoArgsConstructor +public class SafetyManagementCertificateHistoryDto { + @Getter @Setter @ToString @NoArgsConstructor + public static class DataSetVersionDto { + @JsonProperty("DataSetVersion") + private String dataSetVersion; + } + @JsonProperty("DataSetVersion") + private DataSetVersionDto dataSetVersion; + @JsonProperty("LRNO") + private String lrno; + @JsonProperty("SafetyManagementCertificateAuditor") + private String safetyManagementCertificateAuditor; + @JsonProperty("SafetyManagementCertificateConventionOrVol") + private String safetyManagementCertificateConventionOrVol; + @JsonProperty("SafetyManagementCertificateDateExpires") + private String safetyManagementCertificateDateExpires; + @JsonProperty("SafetyManagementCertificateDateIssued") + private String safetyManagementCertificateDateIssued; + @JsonProperty("SafetyManagementCertificateDOCCompany") + private String safetyManagementCertificateDOCCompany; + @JsonProperty("SafetyManagementCertificateFlag") + private String safetyManagementCertificateFlag; + @JsonProperty("SafetyManagementCertificateIssuer") + private String safetyManagementCertificateIssuer; + @JsonProperty("SafetyManagementCertificateOtherDescription") + private String safetyManagementCertificateOtherDescription; + @JsonProperty("SafetyManagementCertificateShipName") + private String safetyManagementCertificateShipName; + @JsonProperty("SafetyManagementCertificateShipType") + private String safetyManagementCertificateShipType; + @JsonProperty("SafetyManagementCertificateSource") + private String safetyManagementCertificateSource; + @JsonProperty("SafetyManagementCertificateCompanyCode") + private String safetyManagementCertificateCompanyCode; + @JsonProperty("Sequence") + private String sequence; + + public SafetyManagementCertificateHistoryEntity toEntity() { + return SafetyManagementCertificateHistoryEntity.builder() + .dataSetVersion(this.dataSetVersion != null ? this.dataSetVersion.getDataSetVersion() : null) + .lrno(this.lrno) + .safetyManagementCertificateAuditor(this.safetyManagementCertificateAuditor) + .safetyManagementCertificateConventionOrVol(this.safetyManagementCertificateConventionOrVol) + .safetyManagementCertificateDateExpires(this.safetyManagementCertificateDateExpires) + .safetyManagementCertificateDateIssued(this.safetyManagementCertificateDateIssued) + .safetyManagementCertificateDOCCompany(this.safetyManagementCertificateDOCCompany) + .safetyManagementCertificateFlag(this.safetyManagementCertificateFlag) + .safetyManagementCertificateIssuer(this.safetyManagementCertificateIssuer) + .safetyManagementCertificateOtherDescription(this.safetyManagementCertificateOtherDescription) + .safetyManagementCertificateShipName(this.safetyManagementCertificateShipName) + .safetyManagementCertificateShipType(this.safetyManagementCertificateShipType) + .safetyManagementCertificateSource(this.safetyManagementCertificateSource) + .safetyManagementCertificateCompanyCode(this.safetyManagementCertificateCompanyCode) + .sequence(this.sequence) + .build(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/ShipDetailApiResponse.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/ShipDetailApiResponse.java new file mode 100644 index 0000000..3e0a019 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/ShipDetailApiResponse.java @@ -0,0 +1,52 @@ +package com.snp.batch.jobs.batch.shipdetail.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * Maritime API GetShipsByIHSLRorIMONumbers 응답 래퍼 + * + * API 응답 구조: + * { + * "shipCount": 5, + * "Ships": [...] + * } + * + * Maritime API GetShipsByIHSLRorIMONumbersAll 응답 래퍼 + * + * API 응답 구조: + * { + * "shipCount": 5, + * "ShipResult": [...], + * "APSStatus": {...} + * } + * + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ShipDetailApiResponse { + + /** + * 선박 개수 + * API에서 "shipCount"로 반환 + */ + @JsonProperty("shipCount") + private Integer shipCount; + /** + * 선박 상세 정보 리스트 + * API에서 "Ships" (대문자 S)로 반환 + */ + @JsonProperty("Ships") + private List ships; + + @JsonProperty("ShipResult") + private List shipResult; + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/ShipDetailComparisonData.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/ShipDetailComparisonData.java new file mode 100644 index 0000000..1692036 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/ShipDetailComparisonData.java @@ -0,0 +1,17 @@ +package com.snp.batch.jobs.batch.shipdetail.dto; +import lombok.Builder; +import lombok.Getter; +import java.util.Map; + +/** + * DB 해시와 API 데이터를 결합하여 Processor로 전달하는 컨테이너 DTO + */ +@Getter +@Builder +public class ShipDetailComparisonData { + private final String imoNumber; + private final String previousMasterHash; // DB에서 조회한 해시값 (비교대상 1) + private final Map currentMasterMap; // API JSON을 Map으로 변환/정렬/필터링한 데이터 + private final String currentMasterHash; // currentMasterMap으로 생성한 해시값 (비교대상 2) + private final ShipDetailDto structuredDto; // 비즈니스 로직 처리용 DTO +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/ShipDetailDto.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/ShipDetailDto.java new file mode 100644 index 0000000..e6ed1a8 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/ShipDetailDto.java @@ -0,0 +1,491 @@ +package com.snp.batch.jobs.batch.shipdetail.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.*; + +import java.util.List; + +/** + * 선박 상세 정보 DTO + * Maritime API GetShipsByIHSLRorIMONumbers 응답 데이터 + * API 응답 필드명과 매핑: + * - IHSLRorIMOShipNo → imoNumber + * - ShipName → shipName + * - ShiptypeLevel5 → shipType + * - FlagName → flag + * - GrossTonnage → grossTonnage + * - Deadweight → deadweight + * - ShipStatus → status + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class ShipDetailDto { + @JsonProperty("DataSetVersion") + private DataSetVersion dataSetVersion; + + @Data + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class DataSetVersion { + @JsonProperty("DataSetVersion") + private String version; + } + /** + * IMO 번호 + * API: IHSLRorIMOShipNo + */ + @JsonProperty("IHSLRorIMOShipNo") + private String ihslrorimoshipno; + /** + * MMSI 번호 + * API: MaritimeMobileServiceIdentityMMSINumber + */ + @JsonProperty("MaritimeMobileServiceIdentityMMSINumber") + private String maritimemobileserviceidentitymmsinumber; + + /** + * 선박명 + * API: ShipName + */ + @JsonProperty("ShipName") + private String shipName; + + /** + * 호출신호 + * API: CallSign + */ + @JsonProperty("CallSign") + private String callsign; + + /** + * 깃발 국가 (Flag) + * API: FlagName + */ + @JsonProperty("FlagName") + private String flagname; + + /** + * 등록항 + * API: PortOfRegistry + */ + @JsonProperty("PortOfRegistry") + private String portofregistry; + + /** + * 선급 + * API: ClassificationSociety + */ + @JsonProperty("ClassificationSociety") + private String ClassificationSociety; + + /** + * 선종(Lv5) + * API: ShiptypeLevel5 + */ + @JsonProperty("ShiptypeLevel5") + private String shiptypelevel5; + /** + * 선종(Lv2) + * API: ShiptypeLevel2 + */ + @JsonProperty("ShiptypeLevel2") + private String shiptypelevel2; + /** + * 세부선종 + * API: ShiptypeLevel5SubType + */ + @JsonProperty("ShiptypeLevel5SubType") + private String shiptypelevel5subtype; + /** + * 건조연도 + * API: YearOfBuild + */ + @JsonProperty("YearOfBuild") + private String yearofbuild; + /** + * 조선소 + * API: Shipbuilder + */ + @JsonProperty("Shipbuilder") + private String shipbuilder; + /** + * 전장(LOA)[m] + * API: LengthOverallLOA + */ + @JsonProperty("LengthOverallLOA") + private String lengthoverallloa; + /** + * 형폭(몰디드)[m] + * API: BreadthMoulded + */ + @JsonProperty("BreadthMoulded") + private String breadthmoulded; + /** + * 깊이[m] + * API: Depth + */ + @JsonProperty("Depth") + private String depth; + /** + * 흘수[m] + * API: Draught + */ + @JsonProperty("Draught") + private String draught; + + /** + * 총톤수 (Gross Tonnage) + * API: GrossTonnage + */ + @JsonProperty("GrossTonnage") + private String grossTonnage; + + /** + * 재화중량톤수 (Deadweight) + * API: Deadweight + */ + @JsonProperty("Deadweight") + private String deadweight; + + /** + * 컨테이너(TEU) + * API: TEU + */ + @JsonProperty("TEU") + private String teu; + + /** + * 항속(kt) + * API: SpeedService + */ + @JsonProperty("SpeedService") + private String speedservice; + + /** + * 주기관 형식 + * API: MainEngineType + */ + @JsonProperty("MainEngineType") + private String mainenginetype; + + + @JsonProperty("ShipStatus") + private String shipStatus; + + @JsonProperty("Operator") + private String operator; + + @JsonProperty("FlagCode") + private String flagCode; + + + // 소유주 및 등록정보 + @JsonProperty("OfficialNumber") + private String officialnumber; + @JsonProperty("FishingNumber") + private String fishingnumber; + + // 안전 및 인증 + @JsonProperty("ClassNarrative") + private String classnarrative; + + // 선박 건조 + @JsonProperty("AlterationsDescriptiveNarrative") + private String alterationsdescriptivenarrative; + @JsonProperty("ShiptypeGroup") + private String shiptypegroup; + @JsonProperty("ShiptypeLevel3") + private String shiptypelevel3; + @JsonProperty("ShiptypeLevel4") + private String shiptypelevel4; + @JsonProperty("ShiptypeLevel5HullType") + private String shiptypelevel5hulltype; + @JsonProperty("ShiptypeLevel5SubGroup") + private String shiptypelevel5subgroup; + @JsonProperty("ConstructionDescriptiveNarrative") + private String constructiondescriptivenarrative; + @JsonProperty("DateOfBuild") + private String dateofbuild; + @JsonProperty("ShipbuilderFullStyle") + private String shipbuilderfullstyle; + @JsonProperty("YardNumber") + private String yardnumber; + @JsonProperty("ConsumptionSpeed1") + private String consumptionspeed1; // Double + @JsonProperty("ConsumptionValue1") + private String consumptionvalue1; // Double + @JsonProperty("ConsumptionSpeed2") + private String consumptionspeed2; // Double + @JsonProperty("ConsumptionValue2") + private String consumptionvalue2; // Double + @JsonProperty("TotalBunkerCapacity") + private String totalbunkercapacity; // Double + @JsonProperty("BoilerManufacturer") + private String boilermanufacturer; + @JsonProperty("PropellerManufacturer") + private String propellermanufacturer; + + // 치수 및 톤수 + @JsonProperty("LengthRegistered") + private String lengthregistered; // Double + @JsonProperty("BreadthExtreme") + private String breadthextreme; // Double + @JsonProperty("KeelToMastHeight") + private String keeltomastheight; // Double + @JsonProperty("Displacement") + private String displacement; // Double + @JsonProperty("LengthBetweenPerpendicularsLBP") + private String lengthbetweenperpendicularslbp; // Double + @JsonProperty("BulbousBow") + private String bulbousbow; + @JsonProperty("TonnesPerCentimetreImmersionTPCI") + private String tonnespercentimetreimmersiontpci; // Double + @JsonProperty("TonnageEffectiveDate") + private String tonnageeffectivedate; + @JsonProperty("FormulaDWT") + private String formuladwt; // Double + @JsonProperty("NetTonnage") + private String nettonnage; // Integer + @JsonProperty("CompensatedGrossTonnageCGT") + private String compensatedgrosstonnagecgt; // Integer + @JsonProperty("LightDisplacementTonnage") + private String lightdisplacementtonnage; // Integer + + // 화물 및 적재량 + @JsonProperty("GrainCapacity") + private String graincapacity; + @JsonProperty("BaleCapacity") + private String balecapacity; + @JsonProperty("LiquidCapacity") + private String liquidcapacity; + @JsonProperty("GasCapacity") + private String gascapacity; + @JsonProperty("TEUCapacity14THomogenous") + private String teucapacity14thomogenous; + @JsonProperty("InsulatedCapacity") + private String insulatedcapacity; + @JsonProperty("PassengerCapacity") + private String passengercapacity; + @JsonProperty("BollardPull") + private String bollardpull; + @JsonProperty("CargoCapacitiesNarrative") + private String cargocapacitiesnarrative; + @JsonProperty("GearDescriptiveNarrative") + private String geardescriptivenarrative; + @JsonProperty("HoldsDescriptiveNarrative") + private String holdsdescriptivenarrative; + @JsonProperty("HatchesDescriptiveNarrative") + private String hatchesdescriptivenarrative; + @JsonProperty("LanesDoorsRampsNarrative") + private String lanesdoorsrampsnarrative; + @JsonProperty("SpecialistTankerNarrative") + private String specialisttankernarrative; + @JsonProperty("TanksDescriptiveNarrative") + private String tanksdescriptivenarrative; + + // 선박 기관 + @JsonProperty("PrimeMoverDescriptiveNarrative") + private String primemoverdescriptivenarrative; + @JsonProperty("PrimeMoverDescriptiveOverviewNarrative") + private String primemoverdescriptiveoverviewnarrative; + @JsonProperty("AuxiliaryEnginesNarrative") + private String auxiliaryenginesnarrative; + @JsonProperty("AuxiliaryGeneratorsDescriptiveNarrative") + private String auxiliarygeneratorsdescriptivenarrative; + @JsonProperty("BunkersDescriptiveNarrative") + private String bunkersdescriptivenarrative; + + // 마지막 수정 일자 + @JsonProperty("LastUpdateDate") + private String lastUpdateDate; + // 회사 코드 + @JsonProperty("DocumentOfComplianceDOCCompanyCode") + private String documentOfComplianceDOCCompanyCode; + @JsonProperty("GroupBeneficialOwnerCompanyCode") + private String groupBeneficialOwnerCompanyCode; + @JsonProperty("OperatorCompanyCode") + private String operatorCompanyCode; + @JsonProperty("ShipManagerCompanyCode") + private String shipManagerCompanyCode; + @JsonProperty("TechnicalManagerCode") + private String technicalManagerCode; + @JsonProperty("RegisteredOwnerCode") + private String registeredOwnerCode; + + /** + * 소유주 이력 List + * API: OwnerHistory + */ + @JsonProperty("OwnerHistory") + private List ownerHistory; + + /** + * 승선자 정보 List + * API: CrewList + */ + @JsonProperty("CrewList") + private List crewList; + + /** + * 화물 적재 정보 List + * API: StowageCommodity + */ + @JsonProperty("StowageCommodity") + private List stowageCommodity; + + /** + * 그룹 실질 소유자 이력 List + * API: GroupBeneficialOwnerHistory + */ + @JsonProperty("GroupBeneficialOwnerHistory") + private List groupBeneficialOwnerHistory; + + /** + * 선박 관리자 이력 List + * API: ShipManagerHistory + */ + @JsonProperty("ShipManagerHistory") + private List shipManagerHistory; + + /** + * 운항사 이력 List + * API: OperatorHistory + */ + @JsonProperty("OperatorHistory") + private List operatorHistory; + + /** + * 기술 관리자 이력 List + * API: TechnicalManagerHistory + */ + @JsonProperty("TechnicalManagerHistory") + private List technicalManagerHistory; + + /** + * 나용선(Bare Boat Charter) 이력 List + * API: BareBoatCharterHistory + */ + @JsonProperty("BareBoatCharterHistory") + private List bareBoatCharterHistory; + + /** + * 선박 이름 이력 List + * API: NameHistory + */ + @JsonProperty("NameHistory") + private List nameHistory; + + /** + * 선박 국적/선기 이력 List + * API: FlagHistory + */ + @JsonProperty("FlagHistory") + private List flagHistory; + + /** + * 추가 정보 List (API 명세에 따라 단일 객체일 수 있으나 List로 선언) + * API: AdditionalInformation + */ + @JsonProperty("AdditionalInformation") + private List additionalInformation; + + /** + * P&I 보험 이력 List + * API: PandIHistory + */ + @JsonProperty("PandIHistory") + private List pandIHistory; + + /** + * 호출 부호 및 MMSI 이력 List + * API: CallSignAndMmsiHistory + */ + @JsonProperty("CallSignAndMmsiHistory") + private List callSignAndMmsiHistory; + + /** + * 내빙 등급 List + * API: IceClass + */ + @JsonProperty("IceClass") + private List iceClass; + + /** + * 안전 관리 증서 이력 List + * API: SafetyManagementCertificateHistory + */ + @JsonProperty("SafetyManagementCertificateHistory") + private List safetyManagementCertificateHistory; + + /** + * 선급 이력 List + * API: ClassHistory + */ + @JsonProperty("ClassHistory") + private List classHistory; + + /** + * 검사 일자 이력 List (집계된 정보) + * API: SurveyDatesHistory + */ + @JsonProperty("SurveyDates") + private List surveyDatesHistory; + + /** + * 검사 일자 이력 List (개별 상세 정보) + * API: SurveyDatesHistoryUnique + */ + @JsonProperty("SurveyDatesHistoryUnique") + private List surveyDatesHistoryUnique; + + /** + * 자매선 연결 정보 List + * API: SisterShipLinks + */ + @JsonProperty("SisterShipLinks") + private List sisterShipLinks; + + /** + * 선박 상태 이력 List + * API: StatusHistory + */ + @JsonProperty("StatusHistory") + private List statusHistory; + + /** + * 특수 기능/설비 List + * API: SpecialFeature + */ + @JsonProperty("SpecialFeature") + private List specialFeature; + + /** + * 추진기 정보 List + * API: Thrusters + */ + @JsonProperty("Thrusters") + private List thrusters; + + /** + * 선박과 연관된 회사 정보 List + * API: CompanyVesselRelationships + */ + @JsonProperty("CompanyVesselRelationships") + private List companyVesselRelationships; + + /** + * 다크활동이력 정보 List + * API: DarkActivityConfirmed + */ + @JsonProperty("DarkActivityConfirmed") + private List darkActivityConfirmed; + + @JsonProperty("CompanyDetailsComplexWithCodesAndParent") + private List companyDetail; + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/ShipDetailUpdate.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/ShipDetailUpdate.java new file mode 100644 index 0000000..a2d03f0 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/ShipDetailUpdate.java @@ -0,0 +1,49 @@ +package com.snp.batch.jobs.batch.shipdetail.dto; + +import com.snp.batch.jobs.batch.shipdetail.entity.*; +import lombok.Builder; +import lombok.Getter; + +import java.util.*; + +/** + * 변경이 감지되어 Writer로 전달될 최종 증분 업데이트 데이터 + */ +@Getter +@Builder +public class ShipDetailUpdate { + private final String imoNumber; + private final Map currentMasterMap; // DB JSONB 컬럼 업데이트용 데이터 + private final String newMasterHash; + + // Hash Update Entity + private final ShipHashEntity shipHashEntity; + + // 이 외에 OwnerHistory Entity, Core Entity 등 증분 데이터를 추가합니다. + private final ShipDetailEntity shipDetailEntity; + private final List ownerHistoryEntityList; + private final List crewListEntityList; + private final List stowageCommodityEntityList; + private final List groupBeneficialOwnerHistoryEntityList; + private final List shipManagerHistoryEntityList; + private final List operatorHistoryEntityList; + private final List technicalManagerHistoryEntityList; + private final List bareBoatCharterHistoryEntityList; + private final List nameHistoryEntityList; + private final List flagHistoryEntityList; + private final List additionalInformationEntityList; + private final List pandIHistoryEntityList; + private final List callSignAndMmsiHistoryEntityList; + private final List iceClassEntityList; + private final List safetyManagementCertificateHistoryEntityList; + private final List classHistoryEntityList; + private final List surveyDatesHistoryEntityList; + private final List surveyDatesHistoryUniqueEntityList; + private final List sisterShipLinksEntityList; + private final List statusHistoryEntityList; + private final List specialFeatureEntityList; + private final List thrustersEntityList; + private final List darkActivityConfirmedEntityList; + private final List companyVesselRelationshipEntityList; + private final List companyDetailEntityList; +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/ShipDto.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/ShipDto.java new file mode 100644 index 0000000..a2fde4e --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/ShipDto.java @@ -0,0 +1,34 @@ +package com.snp.batch.jobs.batch.shipdetail.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class ShipDto { + + @JsonProperty("DataSetVersion") + private DataSetVersion dataSetVersion; + + @JsonProperty("CoreShipInd") + private String coreShipInd; + + @JsonProperty("IHSLRorIMOShipNo") + private String imoNumber; + + @Data + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class DataSetVersion { + @JsonProperty("DataSetVersion") + private String version; + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/ShipManagerHistoryDto.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/ShipManagerHistoryDto.java new file mode 100644 index 0000000..7b93a7f --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/ShipManagerHistoryDto.java @@ -0,0 +1,58 @@ +package com.snp.batch.jobs.batch.shipdetail.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.snp.batch.jobs.batch.shipdetail.entity.ShipManagerHistoryEntity; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@NoArgsConstructor +public class ShipManagerHistoryDto { + + // Nested class for DataSetVersion object + @Getter + @Setter + @ToString + @NoArgsConstructor + public static class DataSetVersionDto { + @JsonProperty("DataSetVersion") + private String dataSetVersion; + } + + @JsonProperty("DataSetVersion") + private DataSetVersionDto dataSetVersion; + + @JsonProperty("CompanyStatus") + private String companyStatus; + + @JsonProperty("EffectiveDate") + private String effectiveDate; + + @JsonProperty("LRNO") + private String lrno; + + @JsonProperty("Sequence") + private String sequence; + + @JsonProperty("ShipManager") + private String shipManager; + + @JsonProperty("ShipManagerCode") + private String shipManagerCode; + + public ShipManagerHistoryEntity toEntity() { + return ShipManagerHistoryEntity.builder() + .dataSetVersion(this.dataSetVersion != null ? this.dataSetVersion.getDataSetVersion() : null) + .companyStatus(this.companyStatus) + .effectiveDate(this.effectiveDate) + .lrno(this.lrno) + .sequence(this.sequence) + .shipManager(this.shipManager) + .shipManagerCode(this.shipManagerCode) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/ShipResultDto.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/ShipResultDto.java new file mode 100644 index 0000000..556e4ab --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/ShipResultDto.java @@ -0,0 +1,25 @@ +package com.snp.batch.jobs.batch.shipdetail.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.*; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ShipResultDto { + + @JsonProperty("shipCount") + private Integer shipCount; + + @JsonProperty("APSShipDetail") + private ShipDetailDto shipDetails; + + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/ShipUpdateApiResponse.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/ShipUpdateApiResponse.java new file mode 100644 index 0000000..03416b6 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/ShipUpdateApiResponse.java @@ -0,0 +1,23 @@ +package com.snp.batch.jobs.batch.shipdetail.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class ShipUpdateApiResponse { + + @JsonProperty("shipCount") + private Integer shipCount; + + @JsonProperty("Ships") + private List ships; + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/SisterShipLinksDto.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/SisterShipLinksDto.java new file mode 100644 index 0000000..6c1931b --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/SisterShipLinksDto.java @@ -0,0 +1,34 @@ +package com.snp.batch.jobs.batch.shipdetail.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.snp.batch.jobs.batch.shipdetail.entity.SisterShipLinksEntity; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@NoArgsConstructor +public class SisterShipLinksDto { + @Getter @Setter @ToString @NoArgsConstructor + public static class DataSetVersionDto { + @JsonProperty("DataSetVersion") + private String dataSetVersion; + } + @JsonProperty("DataSetVersion") + private DataSetVersionDto dataSetVersion; + @JsonProperty("Lrno") + private String lrno; + @JsonProperty("LinkedLRNO") + private String linkedLRNO; + + public SisterShipLinksEntity toEntity() { + return SisterShipLinksEntity.builder() + .dataSetVersion(this.dataSetVersion != null ? this.dataSetVersion.getDataSetVersion() : null) + .lrno(this.lrno) + .linkedLRNO(this.linkedLRNO) + .build(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/SpecialFeatureDto.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/SpecialFeatureDto.java new file mode 100644 index 0000000..6dd33fc --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/SpecialFeatureDto.java @@ -0,0 +1,40 @@ +package com.snp.batch.jobs.batch.shipdetail.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.snp.batch.jobs.batch.shipdetail.entity.SpecialFeatureEntity; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@NoArgsConstructor +public class SpecialFeatureDto { + @Getter @Setter @ToString @NoArgsConstructor + public static class DataSetVersionDto { + @JsonProperty("DataSetVersion") + private String dataSetVersion; + } + @JsonProperty("DataSetVersion") + private DataSetVersionDto dataSetVersion; + @JsonProperty("LRNO") + private String lrno; + @JsonProperty("Sequence") + private String sequence; + @JsonProperty("SpecialFeature") + private String specialFeature; + @JsonProperty("SpecialFeatureCode") + private String specialFeatureCode; + + public SpecialFeatureEntity toEntity() { + return SpecialFeatureEntity.builder() + .dataSetVersion(this.dataSetVersion != null ? this.dataSetVersion.getDataSetVersion() : null) + .lrno(this.lrno) + .sequence(this.sequence) + .specialFeature(this.specialFeature) + .specialFeatureCode(this.specialFeatureCode) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/StatusHistoryDto.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/StatusHistoryDto.java new file mode 100644 index 0000000..d3a1c86 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/StatusHistoryDto.java @@ -0,0 +1,43 @@ +package com.snp.batch.jobs.batch.shipdetail.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.snp.batch.jobs.batch.shipdetail.entity.StatusHistoryEntity; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@NoArgsConstructor +public class StatusHistoryDto { + @Getter @Setter @ToString @NoArgsConstructor + public static class DataSetVersionDto { + @JsonProperty("DataSetVersion") + private String dataSetVersion; + } + @JsonProperty("DataSetVersion") + private DataSetVersionDto dataSetVersion; + @JsonProperty("LRNO") + private String lrno; + @JsonProperty("Sequence") + private String sequence; + @JsonProperty("Status") + private String status; + @JsonProperty("StatusCode") + private String statusCode; + @JsonProperty("StatusDate") + private String statusDate; + + public StatusHistoryEntity toEntity() { + return StatusHistoryEntity.builder() + .dataSetVersion(this.dataSetVersion != null ? this.dataSetVersion.getDataSetVersion() : null) + .lrno(this.lrno) + .sequence(this.sequence) + .status(this.status) + .statusCode(this.statusCode) + .statusDate(this.statusDate) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/StowageCommodityDto.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/StowageCommodityDto.java new file mode 100644 index 0000000..585f41b --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/StowageCommodityDto.java @@ -0,0 +1,56 @@ +package com.snp.batch.jobs.batch.shipdetail.dto; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.snp.batch.jobs.batch.shipdetail.entity.StowageCommodityEntity; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +@Getter +@Setter +@ToString +@NoArgsConstructor +public class StowageCommodityDto { + + // Nested class for DataSetVersion object + @Getter + @Setter + @ToString + @NoArgsConstructor + public static class DataSetVersionDto { + @JsonProperty("DataSetVersion") + private String dataSetVersion; + } + + @JsonProperty("DataSetVersion") + private DataSetVersionDto dataSetVersion; + + @JsonProperty("CommodityCode") + private String commodityCode; + + @JsonProperty("CommodityDecode") + private String commodityDecode; + + @JsonProperty("LRNO") + private String lrno; + + @JsonProperty("Sequence") + private String sequence; + + @JsonProperty("StowageCode") + private String stowageCode; + + @JsonProperty("StowageDecode") + private String stowageDecode; + + public StowageCommodityEntity toEntity() { + return StowageCommodityEntity.builder() + .dataSetVersion(this.dataSetVersion != null ? this.dataSetVersion.getDataSetVersion() : null) + .commodityCode(this.commodityCode) + .commodityDecode(this.commodityDecode) + .lrno(this.lrno) + .sequence(this.sequence) + .stowageCode(this.stowageCode) + .stowageDecode(this.stowageDecode) + .build(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/SurveyDatesHistoryDto.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/SurveyDatesHistoryDto.java new file mode 100644 index 0000000..aa2c5d2 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/SurveyDatesHistoryDto.java @@ -0,0 +1,52 @@ +package com.snp.batch.jobs.batch.shipdetail.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.snp.batch.jobs.batch.shipdetail.entity.SurveyDatesHistoryEntity; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@NoArgsConstructor +public class SurveyDatesHistoryDto { + @Getter @Setter @ToString @NoArgsConstructor + public static class DataSetVersionDto { + @JsonProperty("DataSetVersion") + private String dataSetVersion; + } + @JsonProperty("DataSetVersion") + private DataSetVersionDto dataSetVersion; + @JsonProperty("AnnualSurvey") + private String annualSurvey; + @JsonProperty("ClassSociety") + private String classSociety; + @JsonProperty("ClassSocietyCode") + private String classSocietyCode; + @JsonProperty("ContinuousMachinerySurvey") + private String continuousMachinerySurvey; + @JsonProperty("DockingSurvey") + private String dockingSurvey; + @JsonProperty("LRNO") + private String lrno; + @JsonProperty("SpecialSurvey") + private String specialSurvey; + @JsonProperty("TailShaftSurvey") + private String tailShaftSurvey; + + public SurveyDatesHistoryEntity toEntity() { + return SurveyDatesHistoryEntity.builder() + .dataSetVersion(this.dataSetVersion != null ? this.dataSetVersion.getDataSetVersion() : null) + .annualSurvey(this.annualSurvey) + .classSociety(this.classSociety) + .classSocietyCode(this.classSocietyCode) + .continuousMachinerySurvey(this.continuousMachinerySurvey) + .dockingSurvey(this.dockingSurvey) + .lrno(this.lrno) + .specialSurvey(this.specialSurvey) + .tailShaftSurvey(this.tailShaftSurvey) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/SurveyDatesHistoryUniqueDto.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/SurveyDatesHistoryUniqueDto.java new file mode 100644 index 0000000..afafda4 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/SurveyDatesHistoryUniqueDto.java @@ -0,0 +1,43 @@ +package com.snp.batch.jobs.batch.shipdetail.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.snp.batch.jobs.batch.shipdetail.entity.SurveyDatesHistoryUniqueEntity; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@NoArgsConstructor +public class SurveyDatesHistoryUniqueDto { + @Getter @Setter @ToString @NoArgsConstructor + public static class DataSetVersionDto { + @JsonProperty("DataSetVersion") + private String dataSetVersion; + } + @JsonProperty("DataSetVersion") + private DataSetVersionDto dataSetVersion; + @JsonProperty("LRNO") + private String lrno; + @JsonProperty("ClassSocietyCode") + private String classSocietyCode; + @JsonProperty("SurveyDate") + private String surveyDate; + @JsonProperty("SurveyType") + private String surveyType; + @JsonProperty("ClassSociety") + private String classSociety; + + public SurveyDatesHistoryUniqueEntity toEntity() { + return SurveyDatesHistoryUniqueEntity.builder() + .dataSetVersion(this.dataSetVersion != null ? this.dataSetVersion.getDataSetVersion() : null) + .lrno(this.lrno) + .classSocietyCode(this.classSocietyCode) + .surveyDate(this.surveyDate) + .surveyType(this.surveyType) + .classSociety(this.classSociety) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/TechnicalManagerHistoryDto.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/TechnicalManagerHistoryDto.java new file mode 100644 index 0000000..590ea5b --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/TechnicalManagerHistoryDto.java @@ -0,0 +1,45 @@ +package com.snp.batch.jobs.batch.shipdetail.dto; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.snp.batch.jobs.batch.shipdetail.entity.TechnicalManagerHistoryEntity; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@NoArgsConstructor +public class TechnicalManagerHistoryDto { + @Getter @Setter @ToString @NoArgsConstructor + public static class DataSetVersionDto { + @JsonProperty("DataSetVersion") + private String dataSetVersion; + } + @JsonProperty("DataSetVersion") + private DataSetVersionDto dataSetVersion; + @JsonProperty("CompanyStatus") + private String companyStatus; + @JsonProperty("EffectiveDate") + private String effectiveDate; + @JsonProperty("LRNO") + private String lrno; + @JsonProperty("Sequence") + private String sequence; + @JsonProperty("TechnicalManager") + private String technicalManager; + @JsonProperty("TechnicalManagerCode") + private String technicalManagerCode; + + public TechnicalManagerHistoryEntity toEntity() { + return TechnicalManagerHistoryEntity.builder() + .dataSetVersion(this.dataSetVersion != null ? this.dataSetVersion.getDataSetVersion() : null) + .companyStatus(this.companyStatus) + .effectiveDate(this.effectiveDate) + .lrno(this.lrno) + .sequence(this.sequence) + .technicalManager(this.technicalManager) + .technicalManagerCode(this.technicalManagerCode) + .build(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/ThrustersDto.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/ThrustersDto.java new file mode 100644 index 0000000..3ce623e --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/dto/ThrustersDto.java @@ -0,0 +1,55 @@ +package com.snp.batch.jobs.batch.shipdetail.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.snp.batch.jobs.batch.shipdetail.entity.ThrustersEntity; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@NoArgsConstructor +public class ThrustersDto { + @Getter @Setter @ToString @NoArgsConstructor + public static class DataSetVersionDto { + @JsonProperty("DataSetVersion") + private String dataSetVersion; + } + @JsonProperty("DataSetVersion") + private DataSetVersionDto dataSetVersion; + @JsonProperty("LRNO") + private String lrno; + @JsonProperty("Sequence") + private String sequence; + @JsonProperty("ThrusterType") + private String thrusterType; + @JsonProperty("ThrusterTypeCode") + private String thrusterTypeCode; + @JsonProperty("NumberOfThrusters") + private String numberOfThrusters; // numeric(20) in DB + @JsonProperty("ThrusterPosition") + private String thrusterPosition; + @JsonProperty("ThrusterBHP") + private String thrusterBHP; // numeric(20) in DB + @JsonProperty("ThrusterKW") + private String thrusterKW; // numeric(20) in DB + @JsonProperty("TypeOfInstallation") + private String typeOfInstallation; + + public ThrustersEntity toEntity() { + return ThrustersEntity.builder() + .dataSetVersion(this.dataSetVersion != null ? this.dataSetVersion.getDataSetVersion() : null) + .lrno(this.lrno) + .sequence(this.sequence) + .thrusterType(this.thrusterType) + .thrusterTypeCode(this.thrusterTypeCode) + .numberOfThrusters(this.numberOfThrusters) + .thrusterPosition(this.thrusterPosition) + .thrusterBHP(this.thrusterBHP) + .thrusterKW(this.thrusterKW) + .typeOfInstallation(this.typeOfInstallation) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/AdditionalInformationEntity.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/AdditionalInformationEntity.java new file mode 100644 index 0000000..de9a8ab --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/AdditionalInformationEntity.java @@ -0,0 +1,30 @@ +package com.snp.batch.jobs.batch.shipdetail.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class AdditionalInformationEntity extends BaseEntity { + // SQL Table: additionalshipsdata + private String lrno; + private String shipemail; + private String waterdepthmax; + private String drilldepthmax; + private String drillbargeind; + private String productionvesselind; + private String deckheatexchangerind; + private String deckheatexchangermaterial; + private String tweendeckportable; + private String tweendeckfixed; + private String satcomid; + private String satcomansback; + private String dataSetVersion; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/BareBoatCharterHistoryEntity.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/BareBoatCharterHistoryEntity.java new file mode 100644 index 0000000..26f934d --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/BareBoatCharterHistoryEntity.java @@ -0,0 +1,21 @@ +package com.snp.batch.jobs.batch.shipdetail.entity; +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class BareBoatCharterHistoryEntity extends BaseEntity { + private String dataSetVersion; + private String lrno; + private String sequence; + private String effectiveDate; + private String bbChartererCode; + private String bbCharterer; +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/CallSignAndMmsiHistoryEntity.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/CallSignAndMmsiHistoryEntity.java new file mode 100644 index 0000000..5ee60de --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/CallSignAndMmsiHistoryEntity.java @@ -0,0 +1,22 @@ +package com.snp.batch.jobs.batch.shipdetail.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class CallSignAndMmsiHistoryEntity extends BaseEntity { + private String dataSetVersion; + private String lrno; + private String sequence; // JSON: SeqNo + private String callsign; + private String mmsi; // JSON에 없음 + private String effectiveDate; +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/ClassHistoryEntity.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/ClassHistoryEntity.java new file mode 100644 index 0000000..24c5c87 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/ClassHistoryEntity.java @@ -0,0 +1,25 @@ +package com.snp.batch.jobs.batch.shipdetail.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class ClassHistoryEntity extends BaseEntity { + private String dataSetVersion; + private String _class; // 'class' in DB + private String classCode; + private String classIndicator; + private String classID; + private String currentIndicator; + private String effectiveDate; + private String lrno; + private String sequence; // "sequence" in DB +} diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/CompanyDetailEntity.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/CompanyDetailEntity.java new file mode 100644 index 0000000..08c4206 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/CompanyDetailEntity.java @@ -0,0 +1,46 @@ +package com.snp.batch.jobs.batch.shipdetail.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class CompanyDetailEntity extends BaseEntity { + private String dataSetVersion; + private String owcode; + private String shortcompanyname; + private String countryname; + private String townname; + private String telephone; + private String telex; + private String emailaddress; + private String website; + private String fullname; + private String careofcode; + private String roomfloorbuilding1; + private String roomfloorbuilding2; + private String roomfloorbuilding3; + private String pobox; + private String streetnumber; + private String street; + private String prepostcode; + private String postpostcode; + private String nationalityofregistration; + private String nationalityofcontrol; + private String locationcode; + private String nationalityofregistrationcode; + private String nationalityofcontrolcode; + private String lastchangedate; + private String parentcompany; + private String companystatus; + private String fulladdress; + private String facsimile; + private String foundeddate; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/CompanyVesselRelationshipEntity.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/CompanyVesselRelationshipEntity.java new file mode 100644 index 0000000..9ffc9d8 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/CompanyVesselRelationshipEntity.java @@ -0,0 +1,39 @@ +package com.snp.batch.jobs.batch.shipdetail.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class CompanyVesselRelationshipEntity extends BaseEntity { + private String datasetversion; + private String doccode; + private String doccompany; + private String groupbeneficialowner; + private String groupbeneficialownercode; + private String lrno; + private String operator; + private String operatorcode; + private String registeredowner; + private String registeredownercode; + private String shipmanager; + private String shipmanagercode; + private String technicalmanager; + private String technicalmanagercode; + private String docgroup; + private String docgroupcode; + private String operatorgroup; + private String operatorgroupcode; + private String shipmanagergroup; + private String shipmanagergroupcode; + private String technicalmanagergroup; + private String technicalmanagergroupcode; + private String vesselid; +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/CrewListEntity.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/CrewListEntity.java new file mode 100644 index 0000000..e77cbe0 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/CrewListEntity.java @@ -0,0 +1,31 @@ +package com.snp.batch.jobs.batch.shipdetail.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class CrewListEntity extends BaseEntity { + + private String dataSetVersion; // varchar(5) + private String id; // varchar(7) + private String lrno; // varchar(7) + private String shipname; // varchar(200) + private String crewlistdate; // varchar(20) + private String nationality; // varchar(200) + private String totalcrew; // varchar(2) + private String totalratings; // varchar(2) + private String totalofficers; // varchar(2) + private String totalcadets; // varchar(1) + private String totaltrainees; // varchar(1) + private String totalridingsquad; // varchar(1) + private String totalundeclared; // varchar(1) + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/DarkActivityConfirmedEntity.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/DarkActivityConfirmedEntity.java new file mode 100644 index 0000000..b9ba5fd --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/DarkActivityConfirmedEntity.java @@ -0,0 +1,44 @@ +package com.snp.batch.jobs.batch.shipdetail.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class DarkActivityConfirmedEntity extends BaseEntity { + private String datasetversion; + private String lrno; + private String mmsi; + private String vessel_name; + private String dark_hours; + private String dark_activity; + private String dark_status; + private String area_id; + private String area_name; + private String area_country; + private String dark_time; + private String dark_latitude; + private String dark_longitude; + private String dark_speed; + private String dark_heading; + private String dark_draught; + private String nextseen; + private String nextseen_speed; + private String nextseen_draught; + private String nextseen_heading; + private String dark_reported_destination; + private String last_port_of_call; + private String last_port_country_code; + private String last_port_country; + private String nextseen_latitude; + private String nextseen_longitude; + private String nextseen_reported_destination; + private String vesselid; +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/FlagHistoryEntity.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/FlagHistoryEntity.java new file mode 100644 index 0000000..6646582 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/FlagHistoryEntity.java @@ -0,0 +1,22 @@ +package com.snp.batch.jobs.batch.shipdetail.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class FlagHistoryEntity extends BaseEntity { + private String dataSetVersion; + private String effectiveDate; + private String flag; + private String flagCode; + private String lrno; + private String sequence; +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/GroupBeneficialOwnerHistoryEntity.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/GroupBeneficialOwnerHistoryEntity.java new file mode 100644 index 0000000..b474dac --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/GroupBeneficialOwnerHistoryEntity.java @@ -0,0 +1,27 @@ +package com.snp.batch.jobs.batch.shipdetail.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +// BaseEntity는 프로젝트에 정의되어 있어야 합니다. +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class GroupBeneficialOwnerHistoryEntity extends BaseEntity { + + // JSON & DDL 컬럼 + private String dataSetVersion; + private String companyStatus; + private String effectiveDate; + private String groupBeneficialOwner; + private String groupBeneficialOwnerCode; + private String lrno; + private String sequence; + +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/IceClassEntity.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/IceClassEntity.java new file mode 100644 index 0000000..54b9ff8 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/IceClassEntity.java @@ -0,0 +1,20 @@ +package com.snp.batch.jobs.batch.shipdetail.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class IceClassEntity extends BaseEntity { + private String dataSetVersion; + private String iceClass; + private String iceClassCode; + private String lrno; +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/NameHistoryEntity.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/NameHistoryEntity.java new file mode 100644 index 0000000..000a664 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/NameHistoryEntity.java @@ -0,0 +1,21 @@ +package com.snp.batch.jobs.batch.shipdetail.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class NameHistoryEntity extends BaseEntity { + private String dataSetVersion; + private String effectiveDate; + private String lrno; + private String sequence; + private String vesselName; +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/OperatorHistoryEntity.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/OperatorHistoryEntity.java new file mode 100644 index 0000000..826d738 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/OperatorHistoryEntity.java @@ -0,0 +1,23 @@ +package com.snp.batch.jobs.batch.shipdetail.entity; +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class OperatorHistoryEntity extends BaseEntity { + + private String dataSetVersion; + private String companyStatus; + private String effectiveDate; + private String lrno; + private String operator; + private String operatorCode; + private String sequence; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/OwnerHistoryEntity.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/OwnerHistoryEntity.java new file mode 100644 index 0000000..80a06e3 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/OwnerHistoryEntity.java @@ -0,0 +1,24 @@ +package com.snp.batch.jobs.batch.shipdetail.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class OwnerHistoryEntity extends BaseEntity { + + private String CompanyStatus; + private String EffectiveDate; + private String LRNO; + private String Owner; + private String OwnerCode; + private String Sequence; + private String dataSetVersion; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/PandIHistoryEntity.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/PandIHistoryEntity.java new file mode 100644 index 0000000..9c1af1c --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/PandIHistoryEntity.java @@ -0,0 +1,23 @@ +package com.snp.batch.jobs.batch.shipdetail.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class PandIHistoryEntity extends BaseEntity { + private String dataSetVersion; + private String lrno; + private String sequence; + private String pandiclubcode; + private String pandiclubdecode; + private String effectiveDate; + private String source; +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/SafetyManagementCertificateHistoryEntity.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/SafetyManagementCertificateHistoryEntity.java new file mode 100644 index 0000000..790127d --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/SafetyManagementCertificateHistoryEntity.java @@ -0,0 +1,31 @@ +package com.snp.batch.jobs.batch.shipdetail.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class SafetyManagementCertificateHistoryEntity extends BaseEntity { + private String dataSetVersion; + private String lrno; + private String safetyManagementCertificateAuditor; + private String safetyManagementCertificateConventionOrVol; + private String safetyManagementCertificateDateExpires; + private String safetyManagementCertificateDateIssued; + private String safetyManagementCertificateDOCCompany; + private String safetyManagementCertificateFlag; + private String safetyManagementCertificateIssuer; + private String safetyManagementCertificateOtherDescription; + private String safetyManagementCertificateShipName; + private String safetyManagementCertificateShipType; + private String safetyManagementCertificateSource; + private String safetyManagementCertificateCompanyCode; + private String sequence; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/ShipDetailEntity.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/ShipDetailEntity.java new file mode 100644 index 0000000..617da18 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/ShipDetailEntity.java @@ -0,0 +1,283 @@ +package com.snp.batch.jobs.batch.shipdetail.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.util.List; + +/** + * 선박 상세 정보 Entity + * BaseEntity를 상속하여 감사 필드 자동 포함 + * + * JPA 어노테이션 사용 금지! + * 컬럼 매핑은 주석으로 명시 + * + * API에서 제공하는 필드만 저장: + * - IMO 번호, 선박명, 선박 타입, 깃발 정보, 톤수, 상태 + */ +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class ShipDetailEntity extends BaseEntity { + private String dataSetVersion; + /** + * 기본 키 (자동 생성) + * 컬럼: id (BIGSERIAL) + */ + private Long id; + + /** + * 결과인덱스 + * 컬럼: shipresultindex (int8, NOT NULL) + */ + private long shipResultIndex; + + /** + * IMO 번호 (비즈니스 키) + * 컬럼: imo_number (VARCHAR(20), UNIQUE, NOT NULL) + */ + private String ihslrorimoshipno; + + /** + * 선박ID + * 컬럼: vesselid (VARCHAR(7), NOT NULL) + */ + private String vesselId; + + /** + * MMSI + * 컬럼: maritimemobileserviceidentitymmsinumber (VARCHAR(9)) + */ + // Core20 테이블 컬럼 추가 + private String maritimeMobileServiceIdentityMmsiNumber; + + /** + * 선명 + * 컬럼: shipname (VARCHAR(100)) + */ + private String shipName; + + /** + * 호출부호 + * 컬럼: callsign (VARCHAR(5)) + */ + private String callSign; + + /** + * 국가 + * 컬럼: flagname (VARCHAR(100)) + */ + private String flagName; + + /** + * 등록항 + * 컬럼: portofregistry (VARCHAR(50)) + */ + private String portOfRegistry; + + /** + * 선급 + * 컬럼: classificationsociety (VARCHAR(50)) + */ + private String classificationSociety; + + /** + * 선종(Lv5) + * 컬럼: shiptypelevel5 (VARCHAR(15)) + */ + private String shipTypeLevel5; + + /** + * 선종(Lv2) + * 컬럼: shiptypelevel2 (VARCHAR(100)) + */ + private String shipTypeLevel2; + + /** + * 세부선종 + * 컬럼: shiptypelevel5subtype (VARCHAR(15)) + */ + private String shipTypeLevel5SubType; + + /** + * 건조연도 + * 컬럼: yearofbuild (VARCHAR(4)) + */ + private String yearOfBuild; + + /** + * 조선소 + * 컬럼: shipbuilder (VARCHAR(100)) + */ + private String shipBuilder; + + /** + * 전장(LOA)[m] + * 컬럼: lengthoverallloa (NUMERIC(3, 3)) + */ + private Double lengthOverallLoa; + + /** + * 형폭(몰디드)[m] + * 컬럼: breadthmoulded (NUMERIC(3, 3)) + */ + private Double breadthMoulded; + + /** + * 깊이[m] + * 컬럼: depth (NUMERIC(3, 3)) + */ + private Double depth; + + /** + * 흘수[m] + * 컬럼: draught (NUMERIC(3, 3)) + */ + private Double draught; + + /** + * 총톤수(GT) + * 컬럼: grosstonnage (VARCHAR(4)) + */ + private String grossTonnage; + + /** + * 재화중량톤수(DWT) + * 컬럼: deadweight (VARCHAR(5)) + */ + private String deadWeight; + + /** + * 컨테이너(TEU) + * 컬럼: teu (VARCHAR(1)) + */ + private String teu; + + /** + * 항속(kt) + * 컬럼: speedservice (NUMERIC(2, 2)) + */ + private String speedService; + + /** + * 주기관 형식 + * 컬럼: mainenginetype (VARCHAR(2)) + */ + private String mainEngineType; + + /** + * 업데이트 이력 확인 (N:대기,P:진행,S:완료) + * 컬럼: batch_flag (VARCHAR(1)) + */ + private String batchFlag; + private String shipStatus; + private String operator; + private String flagCode; + + // 소유주 및 등록정보 + private String officialnumber; + private String fishingnumber; + + // 안전 및 인증 + private String classnarrative; + + // 선박 건조 + private String alterationsdescriptivenarrative; + private String shiptypegroup; + private String shiptypelevel3; + private String shiptypelevel4; + private String shiptypelevel5hulltype; + private String shiptypelevel5subgroup; + private String constructiondescriptivenarrative; + private String dateofbuild; + private String shipbuilderfullstyle; + private String yardnumber; + private String consumptionspeed1; + private String consumptionvalue1; + private String consumptionspeed2; + private String consumptionvalue2; + private String totalbunkercapacity; + private String boilermanufacturer; + private String propellermanufacturer; + + // 치수 및 톤수 + private String lengthregistered; + private String breadthextreme; + private String keeltomastheight; + private String displacement; + private String lengthbetweenperpendicularslbp; + private String bulbousbow; + private String tonnespercentimetreimmersiontpci; + private String tonnageeffectivedate; + private String formuladwt; + private String nettonnage; + private String compensatedgrosstonnagecgt; + private String lightdisplacementtonnage; + + // 화물 및 적재량 + private String graincapacity; + private String balecapacity; + private String liquidcapacity; + private String gascapacity; + private String teucapacity14thomogenous; + private String insulatedcapacity; + private String passengercapacity; + private String bollardpull; + private String cargocapacitiesnarrative; + private String geardescriptivenarrative; + private String holdsdescriptivenarrative; + private String hatchesdescriptivenarrative; + private String lanesdoorsrampsnarrative; + private String specialisttankernarrative; + private String tanksdescriptivenarrative; + + // 선박 기관 + private String primemoverdescriptivenarrative; + private String primemoverdescriptiveoverviewnarrative; + private String auxiliaryenginesnarrative; + private String auxiliarygeneratorsdescriptivenarrative; + private String bunkersdescriptivenarrative; + + // 마지막 수정 일자 + private String lastUpdateDate; + // 회사 코드 + private String documentOfComplianceDOCCompanyCode; + private String groupBeneficialOwnerCompanyCode; + private String operatorCompanyCode; + private String shipManagerCompanyCode; + private String technicalManagerCode; + private String registeredOwnerCode; + + private List ownerHistoryEntityList; + private List crewListEntityList; + private List stowageCommodityEntityList; + private List groupBeneficialOwnerHistoryEntityList; + private List shipManagerHistoryEntityList; + private List operatorHistoryEntityList; + private List technicalManagerHistoryEntityList; + private List bareBoatCharterHistoryEntityList; + private List nameHistoryEntityList; + private List flagHistoryEntityList; + private List additionalInformationEntityList; + private List pandIHistoryEntityList; + private List callSignAndMmsiHistoryEntityList; + private List iceClassEntityList; + private List safetyManagementCertificateHistoryEntityList; + private List classHistoryEntityList; + private List surveyDatesHistoryEntityList; + private List surveyDatesHistoryUniqueEntityList; + private List sisterShipLinksEntityList; + private List statusHistoryEntityList; + private List specialFeatureEntityList; + private List thrustersEntityList; + private List darkActivityConfirmedEntityList; + private List companyVesselRelationshipEntityList; + private List companyDetailEntityList; + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/ShipHashEntity.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/ShipHashEntity.java new file mode 100644 index 0000000..5043e35 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/ShipHashEntity.java @@ -0,0 +1,20 @@ +package com.snp.batch.jobs.batch.shipdetail.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class ShipHashEntity extends BaseEntity { + + private String imoNumber; + private String shipDetailHash; + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/ShipManagerHistoryEntity.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/ShipManagerHistoryEntity.java new file mode 100644 index 0000000..2757146 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/ShipManagerHistoryEntity.java @@ -0,0 +1,27 @@ +package com.snp.batch.jobs.batch.shipdetail.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +// BaseEntity는 프로젝트에 정의되어 있다고 가정합니다. +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class ShipManagerHistoryEntity extends BaseEntity { + + // JSON & DDL 컬럼 + private String dataSetVersion; + private String companyStatus; + private String effectiveDate; // DDL: bpchar(8) 또는 varchar(8) + private String lrno; + private String sequence; + private String shipManager; + private String shipManagerCode; + +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/SisterShipLinksEntity.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/SisterShipLinksEntity.java new file mode 100644 index 0000000..04a05e7 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/SisterShipLinksEntity.java @@ -0,0 +1,19 @@ +package com.snp.batch.jobs.batch.shipdetail.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class SisterShipLinksEntity extends BaseEntity { + private String dataSetVersion; + private String lrno; + private String linkedLRNO; +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/SpecialFeatureEntity.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/SpecialFeatureEntity.java new file mode 100644 index 0000000..d7badea --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/SpecialFeatureEntity.java @@ -0,0 +1,21 @@ +package com.snp.batch.jobs.batch.shipdetail.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class SpecialFeatureEntity extends BaseEntity { + private String dataSetVersion; + private String lrno; + private String sequence; + private String specialFeature; + private String specialFeatureCode; +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/StatusHistoryEntity.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/StatusHistoryEntity.java new file mode 100644 index 0000000..56bf51b --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/StatusHistoryEntity.java @@ -0,0 +1,22 @@ +package com.snp.batch.jobs.batch.shipdetail.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class StatusHistoryEntity extends BaseEntity { + private String dataSetVersion; + private String lrno; + private String sequence; + private String status; + private String statusCode; + private String statusDate; +} diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/StowageCommodityEntity.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/StowageCommodityEntity.java new file mode 100644 index 0000000..bb44740 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/StowageCommodityEntity.java @@ -0,0 +1,27 @@ +package com.snp.batch.jobs.batch.shipdetail.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +// BaseEntity는 프로젝트에 정의되어 있어야 합니다. +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class StowageCommodityEntity extends BaseEntity { + + // JSON & DDL 컬럼 + private String dataSetVersion; // varchar(5) + private String commodityCode; // varchar(5) + private String commodityDecode; // varchar(50) + private String lrno; // varchar(7) + private String sequence; // varchar(2) + private String stowageCode; // varchar(2) + private String stowageDecode; // varchar(50) + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/SurveyDatesHistoryEntity.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/SurveyDatesHistoryEntity.java new file mode 100644 index 0000000..3a067d4 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/SurveyDatesHistoryEntity.java @@ -0,0 +1,25 @@ +package com.snp.batch.jobs.batch.shipdetail.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class SurveyDatesHistoryEntity extends BaseEntity { + private String dataSetVersion; + private String annualSurvey; + private String classSociety; + private String classSocietyCode; + private String continuousMachinerySurvey; + private String dockingSurvey; + private String lrno; + private String specialSurvey; + private String tailShaftSurvey; +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/SurveyDatesHistoryUniqueEntity.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/SurveyDatesHistoryUniqueEntity.java new file mode 100644 index 0000000..b85bd9d --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/SurveyDatesHistoryUniqueEntity.java @@ -0,0 +1,22 @@ +package com.snp.batch.jobs.batch.shipdetail.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class SurveyDatesHistoryUniqueEntity extends BaseEntity { + private String dataSetVersion; + private String lrno; + private String classSocietyCode; + private String surveyDate; + private String surveyType; + private String classSociety; +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/TechnicalManagerHistoryEntity.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/TechnicalManagerHistoryEntity.java new file mode 100644 index 0000000..9c9df56 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/TechnicalManagerHistoryEntity.java @@ -0,0 +1,22 @@ +package com.snp.batch.jobs.batch.shipdetail.entity; +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class TechnicalManagerHistoryEntity extends BaseEntity { + private String dataSetVersion; + private String companyStatus; + private String effectiveDate; + private String lrno; + private String sequence; + private String technicalManager; + private String technicalManagerCode; +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/ThrustersEntity.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/ThrustersEntity.java new file mode 100644 index 0000000..4efc24e --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/entity/ThrustersEntity.java @@ -0,0 +1,26 @@ +package com.snp.batch.jobs.batch.shipdetail.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class ThrustersEntity extends BaseEntity { + private String dataSetVersion; + private String lrno; + private String sequence; + private String thrusterType; + private String thrusterTypeCode; + private String numberOfThrusters; + private String thrusterPosition; + private String thrusterBHP; + private String thrusterKW; + private String typeOfInstallation; +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/processor/ShipDetailDataProcessor.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/processor/ShipDetailDataProcessor.java new file mode 100644 index 0000000..600a64f --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/processor/ShipDetailDataProcessor.java @@ -0,0 +1,261 @@ +package com.snp.batch.jobs.batch.shipdetail.processor; + +import com.snp.batch.common.batch.processor.BaseProcessor; +import com.snp.batch.jobs.batch.shipdetail.dto.*; +import com.snp.batch.jobs.batch.shipdetail.entity.*; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * 선박 상세 정보 Processor + * ShipDetailDto → ShipDetailEntity 변환 + */ + +/** + * 선박 상세 정보 Processor (해시 비교 및 증분 데이터 추출) + * I: ShipDetailComparisonData (DB 해시 + API Map Data) + * O: ShipDetailUpdate (변경분) + */ +@Slf4j +@Component +public class ShipDetailDataProcessor extends BaseProcessor { + private final Long jobExecutionId; + + public ShipDetailDataProcessor(@Value("#{stepExecution.jobExecution.id}") Long jobExecutionId){ + this.jobExecutionId = jobExecutionId; + } + + @Override + protected ShipDetailEntity processItem(ShipDetailDto dto) throws Exception { + return ShipDetailEntity.builder() + .ihslrorimoshipno(safeGetString(dto.getIhslrorimoshipno())) + .shipName(safeGetString(dto.getShipName())) + .vesselId(safeGetString(dto.getIhslrorimoshipno())) + .maritimeMobileServiceIdentityMmsiNumber(safeGetString(dto.getMaritimemobileserviceidentitymmsinumber())) + .shipName(safeGetString(dto.getShipName())) + .callSign(safeGetString(dto.getCallsign())) + .flagName(safeGetString(dto.getFlagname())) + .portOfRegistry(safeGetString(dto.getPortofregistry())) + .classificationSociety(safeGetString(dto.getClassificationSociety())) + .shipTypeLevel5(safeGetString(dto.getShiptypelevel5())) + .shipTypeLevel2(safeGetString(dto.getShiptypelevel2())) + .shipTypeLevel5SubType(safeGetString(dto.getShiptypelevel5subtype())) + .yearOfBuild(safeGetString(dto.getYearofbuild())) + .shipBuilder(safeGetString(dto.getShipbuilder())) + .lengthOverallLoa(safeGetDouble(dto.getLengthoverallloa())) + .breadthMoulded(safeGetDouble(dto.getBreadthmoulded())) + .depth(safeGetDouble(dto.getDepth())) + .draught(safeGetDouble(dto.getDraught())) + .grossTonnage(safeGetString(dto.getGrossTonnage())) + .deadWeight(safeGetString(dto.getDeadweight())) + .teu(safeGetString(dto.getTeu())) + .mainEngineType(safeGetString(dto.getMainenginetype())) + .shipStatus(safeGetString(dto.getShipStatus())) + .operator(safeGetString(dto.getOperator())) + .flagCode(safeGetString(dto.getFlagCode())) + // 소유주 및 등록정보 (String) + .officialnumber(safeGetString(dto.getOfficialnumber())) + .fishingnumber(safeGetString(dto.getFishingnumber())) + // 안전 및 인증 (String) + .classnarrative(safeGetString(dto.getClassnarrative())) + // 선박 건조 + .alterationsdescriptivenarrative(safeGetString(dto.getAlterationsdescriptivenarrative())) + .shiptypegroup(safeGetString(dto.getShiptypegroup())) + .shiptypelevel3(safeGetString(dto.getShiptypelevel3())) + .shiptypelevel4(safeGetString(dto.getShiptypelevel4())) + .shiptypelevel5hulltype(safeGetString(dto.getShiptypelevel5hulltype())) + .shiptypelevel5subgroup(safeGetString(dto.getShiptypelevel5subgroup())) + .constructiondescriptivenarrative(safeGetString(dto.getConstructiondescriptivenarrative())) + .dateofbuild(safeGetString(dto.getDateofbuild())) + .shipbuilderfullstyle(safeGetString(dto.getShipbuilderfullstyle())) + .yardnumber(safeGetString(dto.getYardnumber())) + // 선박 건조 (Double 변환 필드) + .consumptionspeed1(safeGetString(dto.getConsumptionspeed1())) + .consumptionvalue1(safeGetString(dto.getConsumptionvalue1())) + .consumptionspeed2(safeGetString(dto.getConsumptionspeed2())) + .consumptionvalue2(safeGetString(dto.getConsumptionvalue2())) + .totalbunkercapacity(safeGetString(dto.getTotalbunkercapacity())) + // 선박 건조 (String) + .boilermanufacturer(safeGetString(dto.getBoilermanufacturer())) + .propellermanufacturer(safeGetString(dto.getPropellermanufacturer())) + // 치수 및 톤수 (Double 변환 필드) + .lengthregistered(safeGetString(dto.getLengthregistered())) + .breadthextreme(safeGetString(dto.getBreadthextreme())) + .keeltomastheight(safeGetString(dto.getKeeltomastheight())) + .displacement(safeGetString(dto.getDisplacement())) + .lengthbetweenperpendicularslbp(safeGetString(dto.getLengthbetweenperpendicularslbp())) + // 치수 및 톤수 (String) + .bulbousbow(safeGetString(dto.getBulbousbow())) + // 치수 및 톤수 (Double 변환 필드) + .tonnespercentimetreimmersiontpci(safeGetString(dto.getTonnespercentimetreimmersiontpci())) + .tonnageeffectivedate(safeGetString(dto.getTonnageeffectivedate())) + .formuladwt(safeGetString(dto.getFormuladwt())) + // 치수 및 톤수 (Integer 변환 필드) + .nettonnage(safeGetString(dto.getNettonnage())) + .compensatedgrosstonnagecgt(safeGetString(dto.getCompensatedgrosstonnagecgt())) + .lightdisplacementtonnage(safeGetString(dto.getLightdisplacementtonnage())) + // 화물 및 적재량 (Integer 변환 필드) + .graincapacity(safeGetString(dto.getGraincapacity())) + .balecapacity(safeGetString(dto.getBalecapacity())) + .liquidcapacity(safeGetString(dto.getLiquidcapacity())) + .gascapacity(safeGetString(dto.getGascapacity())) + .teucapacity14thomogenous(safeGetString(dto.getTeucapacity14thomogenous())) + .insulatedcapacity(safeGetString(dto.getInsulatedcapacity())) + .passengercapacity(safeGetString(dto.getPassengercapacity())) + .bollardpull(safeGetString(dto.getBollardpull())) + // 화물 및 적재량 (String) + .cargocapacitiesnarrative(safeGetString(dto.getCargocapacitiesnarrative())) + .geardescriptivenarrative(safeGetString(dto.getGeardescriptivenarrative())) + .holdsdescriptivenarrative(safeGetString(dto.getHoldsdescriptivenarrative())) + .hatchesdescriptivenarrative(safeGetString(dto.getHatchesdescriptivenarrative())) + .lanesdoorsrampsnarrative(safeGetString(dto.getLanesdoorsrampsnarrative())) + .specialisttankernarrative(safeGetString(dto.getSpecialisttankernarrative())) + .tanksdescriptivenarrative(safeGetString(dto.getTanksdescriptivenarrative())) + // 선박 기관 (String) + .primemoverdescriptivenarrative(safeGetString(dto.getPrimemoverdescriptivenarrative())) + .primemoverdescriptiveoverviewnarrative(safeGetString(dto.getPrimemoverdescriptiveoverviewnarrative())) + .auxiliaryenginesnarrative(safeGetString(dto.getAuxiliaryenginesnarrative())) + .auxiliarygeneratorsdescriptivenarrative(safeGetString(dto.getAuxiliarygeneratorsdescriptivenarrative())) + .bunkersdescriptivenarrative(safeGetString(dto.getBunkersdescriptivenarrative())) + // 마지막 수정 일자 + .lastUpdateDate(safeGetString(dto.getLastUpdateDate())) + // 회사 코드 + .documentOfComplianceDOCCompanyCode(safeGetString(dto.getDocumentOfComplianceDOCCompanyCode())) + .groupBeneficialOwnerCompanyCode(safeGetString(dto.getGroupBeneficialOwnerCompanyCode())) + .operatorCompanyCode(safeGetString(dto.getOperatorCompanyCode())) + .shipManagerCompanyCode(safeGetString(dto.getShipManagerCompanyCode())) + .technicalManagerCode(safeGetString(dto.getTechnicalManagerCode())) + .registeredOwnerCode(safeGetString(dto.getRegisteredOwnerCode())) + .dataSetVersion(safeGetString(dto.getDataSetVersion().getVersion())) + .speedService(safeGetString(dto.getSpeedservice())) + .jobExecutionId(jobExecutionId) + .createdBy("SYSTEM") + // 자식 데이터 구성 (Array/List) + .ownerHistoryEntityList(dto.getOwnerHistory() != null ? + dto.getOwnerHistory().stream() + .map(d -> (OwnerHistoryEntity) d.toEntity().setBatchInfo(jobExecutionId, "SYSTEM")) + .collect(Collectors.toList()) : null) + .crewListEntityList(dto.getCrewList() != null ? + dto.getCrewList().stream() + .map(d -> (CrewListEntity) d.toEntity().setBatchInfo(jobExecutionId, "SYSTEM")) + .collect(Collectors.toList()) : null) + .stowageCommodityEntityList(dto.getStowageCommodity() != null ? + dto.getStowageCommodity().stream() + .map(d -> (StowageCommodityEntity) d.toEntity().setBatchInfo(jobExecutionId, "SYSTEM")) + .collect(Collectors.toList()) : null) + .groupBeneficialOwnerHistoryEntityList(dto.getGroupBeneficialOwnerHistory() != null ? + dto.getGroupBeneficialOwnerHistory().stream() + .map(d -> (GroupBeneficialOwnerHistoryEntity) d.toEntity().setBatchInfo(jobExecutionId, "SYSTEM")) + .collect(Collectors.toList()) : null) + .shipManagerHistoryEntityList(dto.getShipManagerHistory() != null ? + dto.getShipManagerHistory().stream() + .map(d -> (ShipManagerHistoryEntity) d.toEntity().setBatchInfo(jobExecutionId, "SYSTEM")) + .collect(Collectors.toList()) : null) + .operatorHistoryEntityList(dto.getOperatorHistory() != null ? + dto.getOperatorHistory().stream() + .map(d -> (OperatorHistoryEntity) d.toEntity().setBatchInfo(jobExecutionId, "SYSTEM")) + .collect(Collectors.toList()) : null) + .technicalManagerHistoryEntityList(dto.getTechnicalManagerHistory() != null ? + dto.getTechnicalManagerHistory().stream() + .map(d -> (TechnicalManagerHistoryEntity) d.toEntity().setBatchInfo(jobExecutionId, "SYSTEM")) + .collect(Collectors.toList()) : null) + .bareBoatCharterHistoryEntityList(dto.getBareBoatCharterHistory() != null ? + dto.getBareBoatCharterHistory().stream() + .map(d -> (BareBoatCharterHistoryEntity) d.toEntity().setBatchInfo(jobExecutionId, "SYSTEM")) + .collect(Collectors.toList()) : null) + .nameHistoryEntityList(dto.getNameHistory() != null ? + dto.getNameHistory().stream() + .map(d -> (NameHistoryEntity) d.toEntity().setBatchInfo(jobExecutionId, "SYSTEM")) + .collect(Collectors.toList()) : null) + .flagHistoryEntityList(dto.getFlagHistory() != null ? + dto.getFlagHistory().stream() + .map(d -> (FlagHistoryEntity) d.toEntity().setBatchInfo(jobExecutionId, "SYSTEM")) + .collect(Collectors.toList()) : null) + .additionalInformationEntityList(dto.getAdditionalInformation() != null ? + dto.getAdditionalInformation().stream() + .map(d -> (AdditionalInformationEntity) d.toEntity().setBatchInfo(jobExecutionId, "SYSTEM")) + .collect(Collectors.toList()) : null) + .pandIHistoryEntityList(dto.getPandIHistory() != null ? + dto.getPandIHistory().stream() + .map(d -> (PandIHistoryEntity) d.toEntity().setBatchInfo(jobExecutionId, "SYSTEM")) + .collect(Collectors.toList()) : null) + .callSignAndMmsiHistoryEntityList(dto.getCallSignAndMmsiHistory() != null ? + dto.getCallSignAndMmsiHistory().stream() + .map(d -> (CallSignAndMmsiHistoryEntity) d.toEntity().setBatchInfo(jobExecutionId, "SYSTEM")) + .collect(Collectors.toList()) : null) + .iceClassEntityList(dto.getIceClass() != null ? + dto.getIceClass().stream() + .map(d -> (IceClassEntity) d.toEntity().setBatchInfo(jobExecutionId, "SYSTEM")) + .collect(Collectors.toList()) : null) + .safetyManagementCertificateHistoryEntityList(dto.getSafetyManagementCertificateHistory() != null ? + dto.getSafetyManagementCertificateHistory().stream() + .map(d -> (SafetyManagementCertificateHistoryEntity) d.toEntity().setBatchInfo(jobExecutionId, "SYSTEM")) + .collect(Collectors.toList()) : null) + .classHistoryEntityList(dto.getClassHistory() != null ? + dto.getClassHistory().stream() + .map(d -> (ClassHistoryEntity) d.toEntity().setBatchInfo(jobExecutionId, "SYSTEM")) + .collect(Collectors.toList()) : null) + .surveyDatesHistoryEntityList(dto.getSurveyDatesHistory() != null ? + dto.getSurveyDatesHistory().stream() + .map(d -> (SurveyDatesHistoryEntity) d.toEntity().setBatchInfo(jobExecutionId, "SYSTEM")) + .collect(Collectors.toList()) : null) + .surveyDatesHistoryUniqueEntityList(dto.getSurveyDatesHistoryUnique() != null ? + dto.getSurveyDatesHistoryUnique().stream() + .map(d -> (SurveyDatesHistoryUniqueEntity) d.toEntity().setBatchInfo(jobExecutionId, "SYSTEM")) + .collect(Collectors.toList()) : null) + .sisterShipLinksEntityList(dto.getSisterShipLinks() != null ? + dto.getSisterShipLinks().stream() + .map(d -> (SisterShipLinksEntity) d.toEntity().setBatchInfo(jobExecutionId, "SYSTEM")) + .collect(Collectors.toList()) : null) + .statusHistoryEntityList(dto.getStatusHistory() != null ? + dto.getStatusHistory().stream() + .map(d -> (StatusHistoryEntity) d.toEntity().setBatchInfo(jobExecutionId, "SYSTEM")) + .collect(Collectors.toList()) : null) + .specialFeatureEntityList(dto.getSpecialFeature() != null ? + dto.getSpecialFeature().stream() + .map(d -> (SpecialFeatureEntity) d.toEntity().setBatchInfo(jobExecutionId, "SYSTEM")) + .collect(Collectors.toList()) : null) + .thrustersEntityList(dto.getThrusters() != null ? + dto.getThrusters().stream() + .map(d -> (ThrustersEntity) d.toEntity().setBatchInfo(jobExecutionId, "SYSTEM")) + .collect(Collectors.toList()) : null) + .companyVesselRelationshipEntityList(dto.getCompanyVesselRelationships() != null ? + dto.getCompanyVesselRelationships().stream() + .map(d -> (CompanyVesselRelationshipEntity) d.toEntity().setBatchInfo(jobExecutionId, "SYSTEM")) + .collect(Collectors.toList()) : null) + .darkActivityConfirmedEntityList(dto.getDarkActivityConfirmed() != null ? + dto.getDarkActivityConfirmed().stream() + .map(d -> (DarkActivityConfirmedEntity) d.toEntity().setBatchInfo(jobExecutionId, "SYSTEM")) + .collect(Collectors.toList()) : null) + .companyDetailEntityList(dto.getCompanyDetail() != null ? + dto.getCompanyDetail().stream() + .map(d -> (CompanyDetailEntity) d.toEntity().setBatchInfo(jobExecutionId, "SYSTEM")) + .collect(Collectors.toList()) : null) + .build(); +// } + } + + private String safeGetString(String value) { + if (value == null || value.trim().isEmpty()) { + return null; + } + // 값이 존재하면 트림된 문자열을 반환합니다. + return value.trim(); + } + + private Double safeGetDouble(String value) { + if (value == null || value.trim().isEmpty()) { + return null; + } + try { + return Double.parseDouble(value); + } catch (NumberFormatException e) { + return null; + } + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/reader/ShipDetailUpdateDataReader.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/reader/ShipDetailUpdateDataReader.java new file mode 100644 index 0000000..a4716e4 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/reader/ShipDetailUpdateDataReader.java @@ -0,0 +1,337 @@ +package com.snp.batch.jobs.batch.shipdetail.reader; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.batch.shipdetail.dto.ShipDetailApiResponse; +import com.snp.batch.jobs.batch.shipdetail.dto.ShipDetailDto; +import com.snp.batch.jobs.batch.shipdetail.dto.ShipResultDto; +import com.snp.batch.service.BatchApiLogService; +import com.snp.batch.service.BatchDateService; +import com.snp.batch.service.BatchFailedRecordService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +public class ShipDetailUpdateDataReader extends BaseApiReader { + + private final BatchDateService batchDateService; + private final BatchApiLogService batchApiLogService; + private final BatchFailedRecordService batchFailedRecordService; + private final String maritimeApiUrl; + private final JdbcTemplate jdbcTemplate; + private final ObjectMapper objectMapper; + + // 외부 설정값 + private final int batchSize; + private final long delayOnSuccessMs; + private final long delayOnFailureMs; + private final int maxRetryCount; + + // 배치 처리 상태 + private List allImoNumbers; + private int currentBatchIndex = 0; + + // 실패 IMO 추적 + private final List failedImoNumbers = new ArrayList<>(); + private String lastErrorMessage; + private boolean afterFetchCompleted = false; + + // 파티션 모드 + private String partitionImoNumbers; + private boolean recollectMode = false; + + public ShipDetailUpdateDataReader( + WebClient webClient, + JdbcTemplate jdbcTemplate, + ObjectMapper objectMapper, + BatchDateService batchDateService, + BatchApiLogService batchApiLogService, + BatchFailedRecordService batchFailedRecordService, + String maritimeApiUrl, + int batchSize, + long delayOnSuccessMs, + long delayOnFailureMs, + int maxRetryCount + ) { + super(webClient); + this.jdbcTemplate = jdbcTemplate; + this.objectMapper = objectMapper; + this.batchDateService = batchDateService; + this.batchApiLogService = batchApiLogService; + this.batchFailedRecordService = batchFailedRecordService; + this.maritimeApiUrl = maritimeApiUrl; + this.batchSize = batchSize; + this.delayOnSuccessMs = delayOnSuccessMs; + this.delayOnFailureMs = delayOnFailureMs; + this.maxRetryCount = maxRetryCount; + enableChunkMode(); + } + + public void setPartitionImoNumbers(String partitionImoNumbers) { + this.partitionImoNumbers = partitionImoNumbers; + } + + public void setRecollectMode(boolean recollectMode) { + this.recollectMode = recollectMode; + } + + @Override + protected String getReaderName() { + return "ShipDetailUpdateDataReader"; + } + + @Override + protected String getApiPath() { + return "/MaritimeWCF/APSShipService.svc/RESTFul/GetShipsByIHSLRorIMONumbersAll"; + } + + protected String getApiKey() { + return "SHIP_DETAIL_UPDATE_API"; + } + + @Override + protected void resetCustomState() { + this.currentBatchIndex = 0; + this.allImoNumbers = null; + this.failedImoNumbers.clear(); + this.lastErrorMessage = null; + this.afterFetchCompleted = false; + } + + @Override + protected void beforeFetch() { + // 파티션에서 할당받은 IMO 목록을 파싱 + if (partitionImoNumbers != null && !partitionImoNumbers.isBlank()) { + allImoNumbers = new ArrayList<>(Arrays.asList(partitionImoNumbers.split(","))); + } else { + allImoNumbers = Collections.emptyList(); + } + + int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize); + + log.info("[{}] 파티션 IMO {} 건 할당 (recollectMode: {})", + getReaderName(), allImoNumbers.size(), recollectMode); + log.info("[{}] 설정: batch-size={}, delay-success={}ms, delay-failure={}ms, max-retry={}", + getReaderName(), batchSize, delayOnSuccessMs, delayOnFailureMs, maxRetryCount); + log.info("[{}] 예상 배치 수: {} 개", getReaderName(), totalBatches); + + updateApiCallStats(totalBatches, 0); + } + + @Override + protected List fetchNextBatch() throws Exception { + + if (allImoNumbers == null || currentBatchIndex >= allImoNumbers.size()) { + return null; + } + + int startIndex = currentBatchIndex; + int endIndex = Math.min(currentBatchIndex + batchSize, allImoNumbers.size()); + List currentBatch = allImoNumbers.subList(startIndex, endIndex); + + int currentBatchNumber = (currentBatchIndex / batchSize) + 1; + int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize); + + log.info("[{}] 배치 {}/{} 처리 중 (IMO {} 개)...", + getReaderName(), currentBatchNumber, totalBatches, currentBatch.size()); + + // 다음 배치로 인덱스 이동 (성공/실패 무관하게 진행) + currentBatchIndex = endIndex; + + String imoParam = String.join(",", currentBatch); + + // Retry with exponential backoff + ShipDetailApiResponse response = callApiWithRetry(imoParam, currentBatch, currentBatchNumber, totalBatches); + + // API 호출 통계 업데이트 + updateApiCallStats(totalBatches, currentBatchNumber); + + if (response != null && response.getShipResult() != null) { + List shipDetailDtoList = response.getShipResult().stream() + .map(ShipResultDto::getShipDetails) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + log.info("[{}] 배치 {}/{} 완료: {} 건 조회", + getReaderName(), currentBatchNumber, totalBatches, shipDetailDtoList.size()); + + // 성공 시 딜레이 + sleepIfNeeded(delayOnSuccessMs); + + return shipDetailDtoList; + } else { + log.warn("[{}] 배치 {}/{} 응답 없음", getReaderName(), currentBatchNumber, totalBatches); + return Collections.emptyList(); + } + } + + /** + * Retry with exponential backoff + * 최대 maxRetryCount 회 재시도, 대기: 2초 → 4초 → 8초 + */ + private ShipDetailApiResponse callApiWithRetry( + String imoParam, + List currentBatch, + int currentBatchNumber, + int totalBatches + ) { + Exception lastException = null; + + for (int attempt = 1; attempt <= maxRetryCount; attempt++) { + try { + ShipDetailApiResponse response = callApiWithBatch(imoParam); + + if (attempt > 1) { + log.info("[{}] 배치 {}/{} 재시도 {}/{} 성공", + getReaderName(), currentBatchNumber, totalBatches, attempt, maxRetryCount); + } + + return response; + } catch (Exception e) { + lastException = e; + log.warn("[{}] 배치 {}/{} 재시도 {}/{} 실패: {}", + getReaderName(), currentBatchNumber, totalBatches, + attempt, maxRetryCount, e.getMessage()); + + if (attempt < maxRetryCount) { + long backoffMs = delayOnFailureMs * (1L << (attempt - 1)); // 2s, 4s, 8s + log.info("[{}] {}ms 후 재시도...", getReaderName(), backoffMs); + sleepIfNeeded(backoffMs); + } + } + } + + // 모든 재시도 실패 - 실패 IMO 기록 + failedImoNumbers.addAll(currentBatch); + lastErrorMessage = lastException != null ? lastException.getMessage() : "unknown"; + log.error("[{}] 배치 {}/{} 최종 실패 ({}회 재시도 소진). 실패 IMO {} 건 기록: {}", + getReaderName(), currentBatchNumber, totalBatches, maxRetryCount, + currentBatch.size(), lastErrorMessage); + + // 실패 후 딜레이 + sleepIfNeeded(delayOnFailureMs); + + return null; + } + + private void sleepIfNeeded(long ms) { + if (currentBatchIndex < allImoNumbers.size()) { + try { + Thread.sleep(ms); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + + private ShipDetailApiResponse callApiWithBatch(String imoNumbers) { + Map params = new HashMap<>(); + params.put("IMONumbers", imoNumbers); + + return executeSingleApiCall( + maritimeApiUrl, + getApiPath(), + params, + new ParameterizedTypeReference() {}, + batchApiLogService, + res -> res.getShipResult() != null ? (long) res.getShipResult().size() : 0L + ); + } + + @Override + protected void afterFetch(List data) { + int totalBatches = allImoNumbers != null + ? (int) Math.ceil((double) allImoNumbers.size() / batchSize) : 0; + try { + if (data == null && !afterFetchCompleted) { + afterFetchCompleted = true; + log.info("[{}] 전체 {} 개 배치 처리 완료", getReaderName(), totalBatches); + log.info("[{}] 총 {} 개의 IMO 번호에 대한 API 호출 종료", + getReaderName(), allImoNumbers != null ? allImoNumbers.size() : 0); + + if (recollectMode) { + // RECOLLECT 모드: 성공 건 RESOLVED 처리 + 재실패 건 새 FAILED 레코드 저장 + Long sourceJobExecutionId = getSourceJobExecutionId(); + log.info("[{}] [RECOLLECT] 재수집 결과 처리 시작 (sourceJobExecutionId: {})", + getReaderName(), sourceJobExecutionId); + + if (sourceJobExecutionId != null) { + batchFailedRecordService.resolveSuccessfulRetries( + "ShipDetailUpdateJob", sourceJobExecutionId, + allImoNumbers, failedImoNumbers); + } + + if (!failedImoNumbers.isEmpty()) { + log.warn("[{}] [RECOLLECT] 재실패 IMO 건수: {} 건", getReaderName(), failedImoNumbers.size()); + batchFailedRecordService.saveFailedRecords( + "ShipDetailUpdateJob", + getJobExecutionId(), + getStepExecutionId(), + failedImoNumbers, + true, + lastErrorMessage + ); + } else { + log.info("[{}] [RECOLLECT] 모든 재수집 건 정상 처리 완료", getReaderName()); + } + + int successCount = (allImoNumbers != null ? allImoNumbers.size() : 0) - failedImoNumbers.size(); + log.info("[{}] [RECOLLECT] 결과: 성공 {} 건, 재실패 {} 건", + getReaderName(), successCount, failedImoNumbers.size()); + } else { + // 일반 모드: 동기 저장 (자동 재수집 트리거 전에 커밋 보장) + if (!failedImoNumbers.isEmpty()) { + log.warn("[{}] 최종 실패 IMO 건수: {} 건", getReaderName(), failedImoNumbers.size()); + log.warn("[{}] 실패 IMO 목록: {}", getReaderName(), failedImoNumbers); + + batchFailedRecordService.saveFailedRecordsSync( + "ShipDetailUpdateJob", + getJobExecutionId(), + getStepExecutionId(), + failedImoNumbers, + false, + lastErrorMessage + ); + } else { + log.info("[{}] 모든 배치 정상 처리 완료 (실패 건 없음)", getReaderName()); + } + } + } + } catch (Exception e) { + log.error("[{}] afterFetch 처리 중 예외 발생: {}", getReaderName(), e.getMessage(), e); + } finally { + // 일반 모드에서만: 실패 건이 있으면 ExecutionContext에 저장 (자동 재수집 트리거용) + if (!recollectMode && !failedImoNumbers.isEmpty() && stepExecution != null) { + try { + String failedKeys = String.join(",", failedImoNumbers); + stepExecution.getExecutionContext().putString("failedRecordKeys", failedKeys); + stepExecution.getExecutionContext().putLong("failedJobExecutionId", getJobExecutionId()); + stepExecution.getExecutionContext().putString("failedApiKey", getApiKey()); + log.info("[{}] 자동 재수집 대상 실패 키 {} 건 ExecutionContext에 저장", + getReaderName(), failedImoNumbers.size()); + } catch (Exception ex) { + log.error("[{}] ExecutionContext 저장 실패: {}", getReaderName(), ex.getMessage(), ex); + } + } + } + } + + /** + * JobParameter에서 sourceJobExecutionId를 조회 (RECOLLECT 모드용) + */ + private Long getSourceJobExecutionId() { + if (stepExecution != null) { + String param = stepExecution.getJobExecution() + .getJobParameters().getString("sourceJobExecutionId"); + if (param != null && !param.isBlank()) { + return Long.parseLong(param); + } + } + return null; + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/repository/ShipDetailRepository.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/repository/ShipDetailRepository.java new file mode 100644 index 0000000..ec03a52 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/repository/ShipDetailRepository.java @@ -0,0 +1,68 @@ +package com.snp.batch.jobs.batch.shipdetail.repository; + +import com.snp.batch.jobs.batch.shipdetail.entity.*; + +import java.util.List; + +/** + * 선박 상세 정보 Repository 인터페이스 + */ +public interface ShipDetailRepository { + + void saveAll(List entities); + + void saveAllCoreData(List entities); + + void saveAllOwnerHistoryData(List entities); + + void saveAllCrewListData(List entities); + + void saveAllStowageCommodityData(List entities); + + void saveAllGroupBeneficialOwnerHistoryData(List entities); + + void saveAllShipManagerHistoryData(List entities); + + void saveAllOperatorHistoryData(List entities); + + void saveAllTechnicalManagerHistoryData(List entities); + + void saveAllBareBoatCharterHistoryData(List entities); + + void saveAllNameHistoryData(List entities); + + void saveAllFlagHistoryData(List entities); + + void saveAllAdditionalInformationData(List entities); + + void saveAllPandIHistoryData(List entities); + + void saveAllCallSignAndMmsiHistoryData(List entities); + + void saveAllIceClassData(List entities); + + void saveAllSafetyManagementCertificateHistoryData(List entities); + + void saveAllClassHistoryData(List entities); + + void saveAllSurveyDatesHistoryData(List entities); + + void saveAllSurveyDatesHistoryUniqueData(List entities); + + void saveAllSisterShipLinksData(List entities); + + void saveAllStatusHistoryData(List entities); + + void saveAllSpecialFeatureData(List entities); + + void saveAllThrustersData(List entities); + + void saveAllDarkActivityConfirmedData(List entities); + + void saveAllCompanyVesselRelationshipData(List entities); + + void saveAllCompanyDetailData(List entities); + + void delete(String id); + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/repository/ShipDetailRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/repository/ShipDetailRepositoryImpl.java new file mode 100644 index 0000000..359ad6c --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/repository/ShipDetailRepositoryImpl.java @@ -0,0 +1,1316 @@ +package com.snp.batch.jobs.batch.shipdetail.repository; + +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.batch.shipdetail.entity.*; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.*; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; + +/** + * 선박 상세 정보 Repository 구현체 + * BaseJdbcRepository를 상속하여 JDBC 기반 CRUD 구현 + */ +@Slf4j +@Repository("shipDetailRepository") +public class ShipDetailRepositoryImpl extends BaseJdbcRepository + implements ShipDetailRepository { + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + + @Value("${app.batch.target-schema.tables.ship-002}") + private String tableName; + + public ShipDetailRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + protected String getTargetSchema() { + return targetSchema; + } + + @Override + protected String getSimpleTableName() { + return tableName; + } + + @Override + protected String getEntityName() { + return "ShipDetailEntity"; + } + + @Override + protected String extractId(ShipDetailEntity entity) { + return entity.getIhslrorimoshipno(); + } + + @Override + protected String getInsertSql() { + return """ + INSERT INTO %s( + imo_no, mmsi_no, ship_nm, + clsgn_no, ship_ntnlty, load_port, clfic, ship_type_lv_five, + ship_type_lv_five_dtld_type, build_yy, shpyrd, whlnth_loa, formn_breadth, + depth, draft, gt, dwt, teu_cnt, + main_engine_type, ship_status, operator, ntnlty_cd, ship_type_lv_two, + frmla_reg_no, fshr_prmt_no, clfic_desc, + modf_hstry_desc, ship_type_group, ship_type_lv_thr, ship_type_lv_four, ship_type_lv_five_hull_type, + ship_type_lv_five_lwrnk_group, build_desc, build_ymd, + shpyrd_offcl_nm, shpyrd_build_no, fuel_cnsmp_spd_one, fuel_cnsmpamt_val_one, fuel_cnsmp_spd_two, + fuel_cnsmpamt_val_two, total_fuel_capacity_m3, blr_mftr, proplr_mftr, + reg_length, max_breadth, keel_mast_hg, displacement, lbp, + bulb_bow, fldng_one_cm_per_ton_tpci, ton_efect_day, calcfrm_dwt, nt_ton, + cgt, light_displacement_ton, grain_capacity_m3, bale_capacity, liquid_capacity, + gas_m3, teu_capacity, insulated_m3, passenger_capacity, bollard_pull, + cargo_capacity_m3_desc, eqpmnt_desc, hdn, hatche_desc, + lane_door_ramp_desc, spc_tank_desc, tank_desc, + prmovr_desc, prmovr_ovrvw_desc, + aux_desc, asst_gnrtr_desc, fuel_desc, + last_mdfcn_dt, + doc_company_cd, group_actl_ownr_company_cd, operator_company_cd, ship_mngr_company_cd, tech_mngr_cd, reg_shponr_cd, + dataset_ver, svc_spd, + job_execution_id, creatr_id + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, + ?, ?, ?, ?, ?, ?, + ?, ?, + ?, ? + ); + """.formatted(getTableName()); + } + + @Override + protected String getUpdateSql() { + return null; + } + + @Override + protected void setInsertParameters(PreparedStatement ps, ShipDetailEntity entity) throws Exception { + int idx = 1; + ps.setString(idx++, entity.getIhslrorimoshipno()); + ps.setString(idx++, entity.getMaritimeMobileServiceIdentityMmsiNumber()); + ps.setString(idx++, entity.getShipName()); + ps.setString(idx++, entity.getCallSign()); + ps.setString(idx++, entity.getFlagName()); + ps.setString(idx++, entity.getPortOfRegistry()); + ps.setString(idx++, entity.getClassificationSociety()); + ps.setString(idx++, entity.getShipTypeLevel5()); + ps.setString(idx++, entity.getShipTypeLevel5SubType()); + ps.setString(idx++, entity.getYearOfBuild()); + ps.setString(idx++, entity.getShipBuilder()); + setDoubleOrNull(ps, idx++, entity.getLengthOverallLoa()); + setDoubleOrNull(ps, idx++, entity.getBreadthMoulded()); + setDoubleOrNull(ps, idx++, entity.getDepth()); + setDoubleOrNull(ps, idx++, entity.getDraught()); + ps.setString(idx++, entity.getGrossTonnage()); + ps.setString(idx++, entity.getDeadWeight()); + ps.setString(idx++, entity.getTeu()); + ps.setString(idx++, entity.getMainEngineType()); + ps.setString(idx++, entity.getShipStatus()); + ps.setString(idx++, entity.getOperator()); + ps.setString(idx++, entity.getFlagCode()); + ps.setString(idx++, entity.getShipTypeLevel2()); + // 1. 소유주 및 등록정보 (VARCHAR) + ps.setString(idx++, entity.getOfficialnumber()); + ps.setString(idx++, entity.getFishingnumber()); + + // 2. 안전 및 인증 (VARCHAR) + ps.setString(idx++, entity.getClassnarrative()); + + // 3. 선박 건조 (VARCHAR 및 숫자형) + ps.setString(idx++, entity.getAlterationsdescriptivenarrative()); + ps.setString(idx++, entity.getShiptypegroup()); + ps.setString(idx++, entity.getShiptypelevel3()); + ps.setString(idx++, entity.getShiptypelevel4()); + ps.setString(idx++, entity.getShiptypelevel5hulltype()); + ps.setString(idx++, entity.getShiptypelevel5subgroup()); + ps.setString(idx++, entity.getConstructiondescriptivenarrative()); + ps.setString(idx++, entity.getDateofbuild()); + ps.setString(idx++, entity.getShipbuilderfullstyle()); + ps.setString(idx++, entity.getYardnumber()); + + // 숫자형 필드 (SqlUtils의 setDoubleOrNull을 사용하며, String을 Double로 파싱 시도) + setDoubleOrNull(ps, idx++, entity.getConsumptionspeed1()); + setDoubleOrNull(ps, idx++, entity.getConsumptionvalue1()); + setDoubleOrNull(ps, idx++, entity.getConsumptionspeed2()); + setDoubleOrNull(ps, idx++, entity.getConsumptionvalue2()); + setDoubleOrNull(ps, idx++, entity.getTotalbunkercapacity()); + + ps.setString(idx++, entity.getBoilermanufacturer()); + ps.setString(idx++, entity.getPropellermanufacturer()); + + // 4. 치수 및 톤수 (VARCHAR 및 숫자형) + // 숫자형 필드 + setDoubleOrNull(ps, idx++, entity.getLengthregistered()); + setDoubleOrNull(ps, idx++, entity.getBreadthextreme()); + setDoubleOrNull(ps, idx++, entity.getKeeltomastheight()); + setDoubleOrNull(ps, idx++, entity.getDisplacement()); + setDoubleOrNull(ps, idx++, entity.getLengthbetweenperpendicularslbp()); + + // VARCHAR + ps.setString(idx++, entity.getBulbousbow()); + + // 숫자형 필드 + setDoubleOrNull(ps, idx++, entity.getTonnespercentimetreimmersiontpci()); + + // VARCHAR + ps.setString(idx++, entity.getTonnageeffectivedate()); + + // 숫자형 필드 + setDoubleOrNull(ps, idx++, entity.getFormuladwt()); + + // 정수형 필드 (SqlUtils의 setIntegerOrNull을 사용하며, String을 Integer로 파싱 시도) + setIntegerOrNull(ps, idx++, entity.getNettonnage()); + setIntegerOrNull(ps, idx++, entity.getCompensatedgrosstonnagecgt()); + setIntegerOrNull(ps, idx++, entity.getLightdisplacementtonnage()); + + // 5. 화물 및 적재량 (정수형 및 VARCHAR) + // 정수형 필드 + setIntegerOrNull(ps, idx++, entity.getGraincapacity()); + setIntegerOrNull(ps, idx++, entity.getBalecapacity()); + setIntegerOrNull(ps, idx++, entity.getLiquidcapacity()); + setIntegerOrNull(ps, idx++, entity.getGascapacity()); + setIntegerOrNull(ps, idx++, entity.getTeucapacity14thomogenous()); + setIntegerOrNull(ps, idx++, entity.getInsulatedcapacity()); + setIntegerOrNull(ps, idx++, entity.getPassengercapacity()); + setIntegerOrNull(ps, idx++, entity.getBollardpull()); + + // VARCHAR + ps.setString(idx++, entity.getCargocapacitiesnarrative()); + ps.setString(idx++, entity.getGeardescriptivenarrative()); + ps.setString(idx++, entity.getHoldsdescriptivenarrative()); + ps.setString(idx++, entity.getHatchesdescriptivenarrative()); + ps.setString(idx++, entity.getLanesdoorsrampsnarrative()); + ps.setString(idx++, entity.getSpecialisttankernarrative()); + ps.setString(idx++, entity.getTanksdescriptivenarrative()); + + // 6. 선박 기관 (VARCHAR) + ps.setString(idx++, entity.getPrimemoverdescriptivenarrative()); + ps.setString(idx++, entity.getPrimemoverdescriptiveoverviewnarrative()); + ps.setString(idx++, entity.getAuxiliaryenginesnarrative()); + ps.setString(idx++, entity.getAuxiliarygeneratorsdescriptivenarrative()); + ps.setString(idx++, entity.getBunkersdescriptivenarrative()); + + // 마지막 수정 일자 + ps.setString(idx++, entity.getLastUpdateDate()); + // 회사 코드 + ps.setString(idx++, entity.getDocumentOfComplianceDOCCompanyCode()); + ps.setString(idx++, entity.getGroupBeneficialOwnerCompanyCode()); + ps.setString(idx++, entity.getOperatorCompanyCode()); + ps.setString(idx++, entity.getShipManagerCompanyCode()); + ps.setString(idx++, entity.getTechnicalManagerCode()); + ps.setString(idx++, entity.getRegisteredOwnerCode()); + ps.setString(idx++, entity.getDataSetVersion()); + ps.setString(idx++, entity.getSpeedService()); + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + + } + private void setDoubleOrNull(PreparedStatement ps, int index, Double value) throws Exception { + if (value != null) { + ps.setDouble(index, value); + } else { + // java.sql.Types.DOUBLE을 사용하여 명시적으로 SQL NULL을 설정 + ps.setNull(index, java.sql.Types.DOUBLE); + } + } + public static void setDoubleOrNull(PreparedStatement ps, int index, String value) throws Exception { + // 1. null 또는 빈 문자열 체크 + if (value == null || value.trim().isEmpty()) { + ps.setNull(index, Types.DOUBLE); + return; + } + + try { + // 2. String을 Double로 변환 + double parsedValue = Double.parseDouble(value.trim()); + ps.setDouble(index, parsedValue); + } catch (NumberFormatException e) { + // 3. 파싱 오류 발생 시 SQL NULL 처리 + // 경고 로그를 남기고 NULL을 설정하는 것이 안전합니다. + // System.err.println("Warning: Invalid double format for index " + index + ". Value: '" + value + "'. Setting NULL."); + ps.setNull(index, Types.DOUBLE); + } + } + + private void setIntegerOrNull(PreparedStatement ps, int index, String value) throws Exception { + if (value == null || value.trim().isEmpty()) { + ps.setNull(index, java.sql.Types.INTEGER); + } else { + try { + int parsedValue = Integer.parseInt(value.trim()); + ps.setInt(index, parsedValue); + } catch (NumberFormatException e) { + // 유효하지 않은 문자열이 들어왔을 경우, 데이터 로드 실패 대신 NULL 처리 + System.err.println("Warning: Invalid integer format for index " + index + ". Value: '" + value + "'. Setting NULL."); + ps.setNull(index, java.sql.Types.INTEGER); + } + } + } + + public static void setTimestampOrNull(PreparedStatement ps, int idx, String value) throws SQLException { + DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss"); + if (value == null || value.trim().isEmpty() || "null".equalsIgnoreCase(value)) { + ps.setNull(idx, Types.TIMESTAMP); + } else { + try { + // 날짜 문자열을 LocalDateTime으로 변환 후 Timestamp로 변환 + LocalDateTime dateTime = LocalDateTime.parse(value.trim(), DATE_TIME_FORMATTER); + ps.setTimestamp(idx, Timestamp.valueOf(dateTime)); + } catch (Exception e) { + // 로그 기록 (필요 시) 및 NULL 처리 + ps.setNull(idx, Types.TIMESTAMP); + } + } + } + + @Override + protected void setUpdateParameters(PreparedStatement ps, ShipDetailEntity entity) throws Exception { + + } + + @Override + protected RowMapper getRowMapper() { + return null; + } + + @Override + public void saveAllCoreData(List entities) { + if (entities == null || entities.isEmpty()) { + return; + } + batchInsert(entities); + } + + @Override + public void saveAllOwnerHistoryData(List entities) { + String ownerHistorySql = ShipDetailSql.getOwnerHistorySql(); + + if (entities == null || entities.isEmpty()) { + return; + } + + log.debug("{} 배치 삽입 시작: {} 건", "OwnerHistoryEntity", entities.size()); + + jdbcTemplate.batchUpdate(ownerHistorySql, entities, entities.size(), + (ps, entity) -> { + try { + setOwnerHistoryInsertParameters(ps, (OwnerHistoryEntity) entity); + } catch (Exception e) { + log.error("배치 삽입 파라미터 설정 실패", e); + throw new RuntimeException(e); + } + }); + + log.debug("{} 배치 삽입 완료: {} 건", getEntityName(), entities.size()); + } + + @Override + public void saveAllCrewListData(List entities) { + String entityName = "CrewListEntity"; + String sql = ShipDetailSql.getCrewListSql(); + + if (entities == null || entities.isEmpty()) { + return; + } + + log.debug("{} 배치 삽입 시작: {} 건", entityName, entities.size()); + + jdbcTemplate.batchUpdate(sql, entities, entities.size(), + (ps, entity) -> { + try { + setCrewListInsertParameters(ps, (CrewListEntity) entity); + } catch (Exception e) { + log.error("배치 삽입 파라미터 설정 실패 - " + entityName, e); + throw new RuntimeException(e); + } + }); + + log.debug("{} 배치 삽입 완료: {} 건", entityName, entities.size()); + } + + @Override + public void saveAllStowageCommodityData(List entities) { + String entityName = "StowageCommodityEntity"; + String sql = ShipDetailSql.getStowageCommoditySql(); + + if (entities == null || entities.isEmpty()) { + return; + } + + log.debug("{} 배치 삽입 시작: {} 건", entityName, entities.size()); + + jdbcTemplate.batchUpdate(sql, entities, entities.size(), + (ps, entity) -> { + try { + setStowageCommodityInsertParameters(ps, (StowageCommodityEntity) entity); + } catch (Exception e) { + log.error("배치 삽입 파라미터 설정 실패 - " + entityName, e); + throw new RuntimeException(e); + } + }); + + log.debug("{} 배치 삽입 완료: {} 건", entityName, entities.size()); + } + + @Override + public void saveAllGroupBeneficialOwnerHistoryData(List entities) { + String entityName = "GroupBeneficialOwnerHistoryEntity"; + String sql = ShipDetailSql.getGroupBeneficialOwnerHistorySql(); + + if (entities == null || entities.isEmpty()) { + return; + } + + log.debug("{} 배치 삽입 시작: {} 건", entityName, entities.size()); + + jdbcTemplate.batchUpdate(sql, entities, entities.size(), + (ps, entity) -> { + try { + setGroupBeneficialOwnerHistoryInsertParameters(ps, (GroupBeneficialOwnerHistoryEntity) entity); + } catch (Exception e) { + log.error("배치 삽입 파라미터 설정 실패 - " + entityName, e); + throw new RuntimeException(e); + } + }); + + log.debug("{} 배치 삽입 완료: {} 건", entityName, entities.size()); + } + + @Override + public void saveAllShipManagerHistoryData(List entities) { + String entityName = "ShipManagerHistoryEntity"; + String sql = ShipDetailSql.getShipManagerHistorySql(); + + if (entities == null || entities.isEmpty()) { + return; + } + + log.debug("{} 배치 삽입 시작: {} 건", entityName, entities.size()); + + jdbcTemplate.batchUpdate(sql, entities, entities.size(), + (ps, entity) -> { + try { + setShipManagerHistoryInsertParameters(ps, (ShipManagerHistoryEntity) entity); + } catch (Exception e) { + log.error("배치 삽입 파라미터 설정 실패 - " + entityName, e); + throw new RuntimeException(e); + } + }); + + log.debug("{} 배치 삽입 완료: {} 건", entityName, entities.size()); + } + + @Override + public void saveAllOperatorHistoryData(List entities) { + String entityName = "OperatorHistoryEntity"; + String sql = ShipDetailSql.getOperatorHistorySql(); + + if (entities == null || entities.isEmpty()) { + return; + } + + log.debug("{} 배치 삽입 시작: {} 건", entityName, entities.size()); + + jdbcTemplate.batchUpdate(sql, entities, entities.size(), + (ps, entity) -> { + try { + setOperatorHistoryInsertParameters(ps, (OperatorHistoryEntity) entity); + } catch (Exception e) { + log.error("배치 삽입 파라미터 설정 실패 - " + entityName, e); + throw new RuntimeException(e); + } + }); + + log.debug("{} 배치 삽입 완료: {} 건", entityName, entities.size()); + } + + @Override + public void saveAllTechnicalManagerHistoryData(List entities) { + String entityName = "TechnicalManagerHistoryEntity"; + String sql = ShipDetailSql.getTechnicalManagerHistorySql(); + + if (entities == null || entities.isEmpty()) { + return; + } + + log.debug("{} 배치 삽입 시작: {} 건", entityName, entities.size()); + + jdbcTemplate.batchUpdate(sql, entities, entities.size(), + (ps, entity) -> { + try { + setTechnicalManagerHistoryInsertParameters(ps, (TechnicalManagerHistoryEntity) entity); + } catch (Exception e) { + log.error("배치 삽입 파라미터 설정 실패 - " + entityName, e); + throw new RuntimeException(e); + } + }); + + log.debug("{} 배치 삽입 완료: {} 건", entityName, entities.size()); + } + + @Override + public void saveAllBareBoatCharterHistoryData(List entities) { + String entityName = "BareBoatCharterHistoryEntity"; + String sql = ShipDetailSql.getBareBoatCharterHistorySql(); + + if (entities == null || entities.isEmpty()) { + return; + } + + log.debug("{} 배치 삽입 시작: {} 건", entityName, entities.size()); + + jdbcTemplate.batchUpdate(sql, entities, entities.size(), + (ps, entity) -> { + try { + setBareBoatCharterHistoryInsertParameters(ps, (BareBoatCharterHistoryEntity) entity); + } catch (Exception e) { + log.error("배치 삽입 파라미터 설정 실패 - " + entityName, e); + throw new RuntimeException(e); + } + }); + + log.debug("{} 배치 삽입 완료: {} 건", entityName, entities.size()); + } + + @Override + public void saveAllNameHistoryData(List entities) { + String entityName = "NameHistoryEntity"; + String sql = ShipDetailSql.getNameHistorySql(); + + if (entities == null || entities.isEmpty()) { + return; + } + + log.debug("{} 배치 삽입 시작: {} 건", entityName, entities.size()); + + jdbcTemplate.batchUpdate(sql, entities, entities.size(), + (ps, entity) -> { + try { + setNameHistoryInsertParameters(ps, (NameHistoryEntity) entity); + } catch (Exception e) { + log.error("배치 삽입 파라미터 설정 실패 - " + entityName, e); + throw new RuntimeException(e); + } + }); + + log.debug("{} 배치 삽입 완료: {} 건", entityName, entities.size()); + } + + @Override + public void saveAllFlagHistoryData(List entities) { + String entityName = "FlagHistoryEntity"; + String sql = ShipDetailSql.getFlagHistorySql(); + + if (entities == null || entities.isEmpty()) { + return; + } + + log.debug("{} 배치 삽입 시작: {} 건", entityName, entities.size()); + + jdbcTemplate.batchUpdate(sql, entities, entities.size(), + (ps, entity) -> { + try { + setFlagHistoryInsertParameters(ps, (FlagHistoryEntity) entity); + } catch (Exception e) { + log.error("배치 삽입 파라미터 설정 실패 - " + entityName, e); + throw new RuntimeException(e); + } + }); + + log.debug("{} 배치 삽입 완료: {} 건", entityName, entities.size()); + } + + @Override + public void saveAllAdditionalInformationData(List entities) { + String entityName = "AdditionalInformationEntity"; + String sql = ShipDetailSql.getAdditionalInformationSql(); + + if (entities == null || entities.isEmpty()) { + return; + } + + log.debug("{} 배치 삽입 시작: {} 건", entityName, entities.size()); + + jdbcTemplate.batchUpdate(sql, entities, entities.size(), + (ps, entity) -> { + try { + setAdditionalInformationInsertParameters(ps, (AdditionalInformationEntity) entity); + } catch (Exception e) { + log.error("배치 삽입 파라미터 설정 실패 - " + entityName, e); + throw new RuntimeException(e); + } + }); + + log.debug("{} 배치 삽입 완료: {} 건", entityName, entities.size()); + } + + @Override + public void saveAllPandIHistoryData(List entities) { + String entityName = "PandIHistoryEntity"; + String sql = ShipDetailSql.getPandIHistorySql(); + + if (entities == null || entities.isEmpty()) { + return; + } + + log.debug("{} 배치 삽입 시작: {} 건", entityName, entities.size()); + + jdbcTemplate.batchUpdate(sql, entities, entities.size(), + (ps, entity) -> { + try { + setPandIHistoryInsertParameters(ps, (PandIHistoryEntity) entity); + } catch (Exception e) { + log.error("배치 삽입 파라미터 설정 실패 - " + entityName, e); + throw new RuntimeException(e); + } + }); + + log.debug("{} 배치 삽입 완료: {} 건", entityName, entities.size()); + } + + @Override + public void saveAllCallSignAndMmsiHistoryData(List entities) { + String entityName = "CallSignAndMmsiHistoryEntity"; + String sql = ShipDetailSql.getCallSignAndMmsiHistorySql(); + + if (entities == null || entities.isEmpty()) { + return; + } + + log.debug("{} 배치 삽입 시작: {} 건", entityName, entities.size()); + + jdbcTemplate.batchUpdate(sql, entities, entities.size(), + (ps, entity) -> { + try { + setCallSignAndMmsiHistoryInsertParameters(ps, (CallSignAndMmsiHistoryEntity) entity); + } catch (Exception e) { + log.error("배치 삽입 파라미터 설정 실패 - " + entityName, e); + throw new RuntimeException(e); + } + }); + + log.debug("{} 배치 삽입 완료: {} 건", entityName, entities.size()); + } + + @Override + public void saveAllIceClassData(List entities) { + String entityName = "IceClassEntity"; + String sql = ShipDetailSql.getIceClassSql(); + + if (entities == null || entities.isEmpty()) { + return; + } + + log.debug("{} 배치 삽입 시작: {} 건", entityName, entities.size()); + + jdbcTemplate.batchUpdate(sql, entities, entities.size(), + (ps, entity) -> { + try { + setIceClassInsertParameters(ps, (IceClassEntity) entity); + } catch (Exception e) { + log.error("배치 삽입 파라미터 설정 실패 - " + entityName, e); + throw new RuntimeException(e); + } + }); + + log.debug("{} 배치 삽입 완료: {} 건", entityName, entities.size()); + } + + @Override + public void saveAllSafetyManagementCertificateHistoryData(List entities) { + String entityName = "SafetyManagementCertificateHistoryEntity"; + String sql = ShipDetailSql.getSafetyManagementCertificateHistorySql(); + + if (entities == null || entities.isEmpty()) { + return; + } + + log.debug("{} 배치 삽입 시작: {} 건", entityName, entities.size()); + + jdbcTemplate.batchUpdate(sql, entities, entities.size(), + (ps, entity) -> { + try { + setSafetyManagementCertificateHistoryInsertParameters(ps, (SafetyManagementCertificateHistoryEntity) entity); + } catch (Exception e) { + log.error("배치 삽입 파라미터 설정 실패 - " + entityName, e); + throw new RuntimeException(e); + } + }); + + log.debug("{} 배치 삽입 완료: {} 건", entityName, entities.size()); + } + + @Override + public void saveAllClassHistoryData(List entities) { + String entityName = "ClassHistoryEntity"; + String sql = ShipDetailSql.getClassHistorySql(); + + if (entities == null || entities.isEmpty()) { + return; + } + + log.debug("{} 배치 삽입 시작: {} 건", entityName, entities.size()); + + jdbcTemplate.batchUpdate(sql, entities, entities.size(), + (ps, entity) -> { + try { + setClassHistoryInsertParameters(ps, (ClassHistoryEntity) entity); + } catch (Exception e) { + log.error("배치 삽입 파라미터 설정 실패 - " + entityName, e); + throw new RuntimeException(e); + } + }); + + log.debug("{} 배치 삽입 완료: {} 건", entityName, entities.size()); + } + + @Override + public void saveAllSurveyDatesHistoryData(List entities) { + String entityName = "SurveyDatesHistoryEntity"; + String sql = ShipDetailSql.getSurveyDatesHistorySql(); + + if (entities == null || entities.isEmpty()) { + return; + } + + log.debug("{} 배치 삽입 시작: {} 건", entityName, entities.size()); + + jdbcTemplate.batchUpdate(sql, entities, entities.size(), + (ps, entity) -> { + try { + setSurveyDatesHistoryInsertParameters(ps, (SurveyDatesHistoryEntity) entity); + } catch (Exception e) { + log.error("배치 삽입 파라미터 설정 실패 - " + entityName, e); + throw new RuntimeException(e); + } + }); + + log.debug("{} 배치 삽입 완료: {} 건", entityName, entities.size()); + } + + @Override + public void saveAllSurveyDatesHistoryUniqueData(List entities) { + String entityName = "SurveyDatesHistoryUniqueEntity"; + String sql = ShipDetailSql.getSurveyDatesHistoryUniqueSql(); + + if (entities == null || entities.isEmpty()) { + return; + } + + log.debug("{} 배치 삽입 시작: {} 건", entityName, entities.size()); + + jdbcTemplate.batchUpdate(sql, entities, entities.size(), + (ps, entity) -> { + try { + setSurveyDatesHistoryUniqueInsertParameters(ps, (SurveyDatesHistoryUniqueEntity) entity); + } catch (Exception e) { + log.error("배치 삽입 파라미터 설정 실패 - " + entityName, e); + throw new RuntimeException(e); + } + }); + + log.debug("{} 배치 삽입 완료: {} 건", entityName, entities.size()); + } + + @Override + public void saveAllSisterShipLinksData(List entities) { + String entityName = "SisterShipLinksEntity"; + String sql = ShipDetailSql.getSisterShipLinksSql(); + + if (entities == null || entities.isEmpty()) { + return; + } + + log.debug("{} 배치 삽입 시작: {} 건", entityName, entities.size()); + + jdbcTemplate.batchUpdate(sql, entities, entities.size(), + (ps, entity) -> { + try { + setSisterShipLinksInsertParameters(ps, (SisterShipLinksEntity) entity); + } catch (Exception e) { + log.error("배치 삽입 파라미터 설정 실패 - " + entityName, e); + throw new RuntimeException(e); + } + }); + + log.debug("{} 배치 삽입 완료: {} 건", entityName, entities.size()); + } + + @Override + public void saveAllStatusHistoryData(List entities) { + String entityName = "StatusHistoryEntity"; + String sql = ShipDetailSql.getStatusHistorySql(); + + if (entities == null || entities.isEmpty()) { + return; + } + + log.debug("{} 배치 삽입 시작: {} 건", entityName, entities.size()); + + jdbcTemplate.batchUpdate(sql, entities, entities.size(), + (ps, entity) -> { + try { + setStatusHistoryInsertParameters(ps, (StatusHistoryEntity) entity); + } catch (Exception e) { + log.error("배치 삽입 파라미터 설정 실패 - " + entityName, e); + throw new RuntimeException(e); + } + }); + + log.debug("{} 배치 삽입 완료: {} 건", entityName, entities.size()); + } + + @Override + public void saveAllSpecialFeatureData(List entities) { + String entityName = "SpecialFeatureEntity"; + String sql = ShipDetailSql.getSpecialFeatureSql(); + + if (entities == null || entities.isEmpty()) { + return; + } + + log.debug("{} 배치 삽입 시작: {} 건", entityName, entities.size()); + + jdbcTemplate.batchUpdate(sql, entities, entities.size(), + (ps, entity) -> { + try { + setSpecialFeatureInsertParameters(ps, (SpecialFeatureEntity) entity); + } catch (Exception e) { + log.error("배치 삽입 파라미터 설정 실패 - " + entityName, e); + throw new RuntimeException(e); + } + }); + + log.debug("{} 배치 삽입 완료: {} 건", entityName, entities.size()); + } + + @Override + public void saveAllThrustersData(List entities) { + String entityName = "ThrustersEntity"; + String sql = ShipDetailSql.getThrustersSql(); + + if (entities == null || entities.isEmpty()) { + return; + } + + log.debug("{} 배치 삽입 시작: {} 건", entityName, entities.size()); + + jdbcTemplate.batchUpdate(sql, entities, entities.size(), + (ps, entity) -> { + try { + setThrustersInsertParameters(ps, (ThrustersEntity) entity); + } catch (Exception e) { + log.error("배치 삽입 파라미터 설정 실패 - " + entityName, e); + throw new RuntimeException(e); + } + }); + + log.debug("{} 배치 삽입 완료: {} 건", entityName, entities.size()); + } + + @Override + public void saveAllDarkActivityConfirmedData(List entities) { + String entityName = "DarkActivityConfirmedEntity"; + String sql = ShipDetailSql.getDarkActivityConfirmedSql(); + + if (entities == null || entities.isEmpty()) { + return; + } + + log.debug("{} 배치 삽입 시작: {} 건", entityName, entities.size()); + + jdbcTemplate.batchUpdate(sql, entities, entities.size(), + (ps, entity) -> { + try { + setDarkActivityConfirmedInsertParameters(ps, (DarkActivityConfirmedEntity) entity); + } catch (Exception e) { + log.error("배치 삽입 파라미터 설정 실패 - " + entityName, e); + throw new RuntimeException(e); + } + }); + + log.debug("{} 배치 삽입 완료: {} 건", entityName, entities.size()); + + } + + @Override + public void saveAllCompanyVesselRelationshipData(List entities) { + String entityName = "CompanyVesselRelationshipEntity"; + String sql = ShipDetailSql.getCompanyVesselRelationshipSql(); + + if (entities == null || entities.isEmpty()) { + return; + } + + log.debug("{} 배치 삽입 시작: {} 건", entityName, entities.size()); + + jdbcTemplate.batchUpdate(sql, entities, entities.size(), + (ps, entity) -> { + try { + setCompanyVesselRelationshipInsertParameters(ps, (CompanyVesselRelationshipEntity) entity); + } catch (Exception e) { + log.error("배치 삽입 파라미터 설정 실패 - " + entityName, e); + throw new RuntimeException(e); + } + }); + + log.debug("{} 배치 삽입 완료: {} 건", entityName, entities.size()); + } + + @Override + public void saveAllCompanyDetailData(List entities) { + String entityName = "CompanyDetailEntity"; + String sql = ShipDetailSql.getCompanyDetailSql(); + + if (entities == null || entities.isEmpty()) { + return; + } + + log.debug("{} 배치 삽입 시작: {} 건", entityName, entities.size()); + + jdbcTemplate.batchUpdate(sql, entities, entities.size(), + (ps, entity) -> { + try { + setCompanyDetailInsertParameters(ps, entity); + } catch (Exception e) { + log.error("배치 삽입 파라미터 설정 실패 - " + entityName, e); + throw new RuntimeException(e); + } + }); + + log.debug("{} 배치 삽입 완료: {} 건", entityName, entities.size()); + } + + private void setCompanyDetailInsertParameters(PreparedStatement ps, CompanyDetailEntity entity) throws Exception { + int idx = 1; + ps.setString(idx++, entity.getDataSetVersion()); + ps.setString(idx++, entity.getOwcode()); + ps.setString(idx++, entity.getShortcompanyname()); + ps.setString(idx++, entity.getCountryname()); + ps.setString(idx++, entity.getTownname()); + ps.setString(idx++, entity.getTelephone()); + ps.setString(idx++, entity.getTelex()); + ps.setString(idx++, entity.getEmailaddress()); + ps.setString(idx++, entity.getWebsite()); + ps.setString(idx++, entity.getFullname()); + ps.setString(idx++, entity.getCareofcode()); + ps.setString(idx++, entity.getRoomfloorbuilding1()); + ps.setString(idx++, entity.getRoomfloorbuilding2()); + ps.setString(idx++, entity.getRoomfloorbuilding3()); + ps.setString(idx++, entity.getPobox()); + ps.setString(idx++, entity.getStreetnumber()); + ps.setString(idx++, entity.getStreet()); + ps.setString(idx++, entity.getPrepostcode()); + ps.setString(idx++, entity.getPostpostcode()); + ps.setString(idx++, entity.getNationalityofregistration()); + ps.setString(idx++, entity.getNationalityofcontrol()); + ps.setString(idx++, entity.getLocationcode()); + ps.setString(idx++, entity.getNationalityofregistrationcode()); + ps.setString(idx++, entity.getNationalityofcontrolcode()); + ps.setString(idx++, entity.getLastchangedate()); + ps.setString(idx++, entity.getParentcompany()); + ps.setString(idx++, entity.getCompanystatus()); + ps.setString(idx++, entity.getFulladdress()); + ps.setString(idx++, entity.getFacsimile()); + ps.setString(idx++, entity.getFoundeddate()); + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + + public boolean existsByImo(String imo) { + String sql = String.format("SELECT COUNT(*) FROM %s WHERE %s = ?", getTableName(), getIdColumnName("imo_no")); + Long count = jdbcTemplate.queryForObject(sql, Long.class, imo); + return count != null && count > 0; + } + + + + @Override + public void delete(String id) { + String sql = "DELETE FROM " + getTableName() + " WHERE imo_no = ?"; + jdbcTemplate.update(sql, id); + log.debug("[{}] 삭제 완료: id={}", getEntityName(), id); + } + + private void setOwnerHistoryInsertParameters(PreparedStatement ps, OwnerHistoryEntity entity)throws Exception{ + int idx = 1; + ps.setString(idx++, entity.getDataSetVersion()); + ps.setString(idx++, entity.getCompanyStatus()); + ps.setString(idx++, entity.getEffectiveDate()); + ps.setString(idx++, entity.getLRNO()); + ps.setString(idx++, entity.getOwner()); + ps.setString(idx++, entity.getOwnerCode()); + ps.setString(idx++, entity.getSequence()); + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + + private void setCrewListInsertParameters(PreparedStatement ps, CrewListEntity entity)throws Exception{ + int idx = 1; + ps.setString(idx++, entity.getDataSetVersion()); // 1. datasetversion + ps.setString(idx++, entity.getId()); // 2. id + ps.setString(idx++, entity.getLrno()); // 3. lrno + ps.setString(idx++, entity.getShipname()); // 4. shipname + ps.setString(idx++, entity.getCrewlistdate()); // 5. crewlistdate + ps.setString(idx++, entity.getNationality()); // 6. nationality + setIntegerOrNull(ps, idx++, entity.getTotalcrew()); // 7. totalcrew + setIntegerOrNull(ps, idx++, entity.getTotalratings()); // 8. totalratings + setIntegerOrNull(ps, idx++, entity.getTotalofficers()); // 9. totalofficers + setIntegerOrNull(ps, idx++, entity.getTotalcadets()); // 10. totalcadets + setIntegerOrNull(ps, idx++, entity.getTotaltrainees()); // 11. totaltrainees + setIntegerOrNull(ps, idx++, entity.getTotalridingsquad()); // 12. totalridingsquad + setIntegerOrNull(ps, idx++, entity.getTotalundeclared()); // 13. totalundeclared + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + + private void setStowageCommodityInsertParameters(PreparedStatement ps, StowageCommodityEntity entity)throws Exception{ + int idx = 1; + ps.setString(idx++, entity.getDataSetVersion()); + ps.setString(idx++, entity.getCommodityCode()); + ps.setString(idx++, entity.getCommodityDecode()); + ps.setString(idx++, entity.getLrno()); + ps.setString(idx++, entity.getSequence()); + ps.setString(idx++, entity.getStowageCode()); + ps.setString(idx++, entity.getStowageDecode()); + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + + private void setGroupBeneficialOwnerHistoryInsertParameters(PreparedStatement ps, GroupBeneficialOwnerHistoryEntity entity)throws Exception{ + int idx = 1; + ps.setString(idx++, entity.getDataSetVersion()); + ps.setString(idx++, entity.getCompanyStatus()); + ps.setString(idx++, entity.getEffectiveDate()); + ps.setString(idx++, entity.getGroupBeneficialOwner()); + ps.setString(idx++, entity.getGroupBeneficialOwnerCode()); + ps.setString(idx++, entity.getLrno()); + ps.setString(idx++, entity.getSequence()); + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + + + private void setShipManagerHistoryInsertParameters(PreparedStatement ps, ShipManagerHistoryEntity entity)throws Exception{ + int idx = 1; + ps.setString(idx++, entity.getDataSetVersion()); + ps.setString(idx++, entity.getCompanyStatus()); + ps.setString(idx++, entity.getEffectiveDate()); + ps.setString(idx++, entity.getLrno()); + ps.setString(idx++, entity.getSequence()); + ps.setString(idx++, entity.getShipManager()); + ps.setString(idx++, entity.getShipManagerCode()); + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + + private void setOperatorHistoryInsertParameters(PreparedStatement ps, OperatorHistoryEntity entity)throws Exception{ + int idx = 1; + ps.setString(idx++, entity.getDataSetVersion()); + ps.setString(idx++, entity.getCompanyStatus()); + ps.setString(idx++, entity.getEffectiveDate()); + ps.setString(idx++, entity.getLrno()); + ps.setString(idx++, entity.getOperator()); + ps.setString(idx++, entity.getOperatorCode()); + ps.setString(idx++, entity.getSequence()); + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + + private void setTechnicalManagerHistoryInsertParameters(PreparedStatement ps, TechnicalManagerHistoryEntity entity)throws Exception{ + int idx = 1; + ps.setString(idx++, entity.getDataSetVersion()); // 1. datasetversion + ps.setString(idx++, entity.getCompanyStatus()); // 2. companystatus + ps.setString(idx++, entity.getEffectiveDate()); // 3. effectivedate + ps.setString(idx++, entity.getLrno()); // 4. lrno + ps.setString(idx++, entity.getSequence()); // 5. sequence + ps.setString(idx++, entity.getTechnicalManager()); // 6. technicalmanager + ps.setString(idx++, entity.getTechnicalManagerCode()); // 7. technicalmanagercode + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + + private void setBareBoatCharterHistoryInsertParameters(PreparedStatement ps, BareBoatCharterHistoryEntity entity)throws Exception{ + int idx = 1; + ps.setString(idx++, entity.getDataSetVersion()); // 1. datasetversion + ps.setString(idx++, entity.getLrno()); // 2. lrno + ps.setString(idx++, entity.getSequence()); // 3. sequence + ps.setString(idx++, entity.getEffectiveDate()); // 4. effectivedate + ps.setString(idx++, entity.getBbChartererCode()); // 5. bbcharterercode + ps.setString(idx++, entity.getBbCharterer()); // 6. bbcharterer + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + + private void setNameHistoryInsertParameters(PreparedStatement ps, NameHistoryEntity entity)throws Exception{ + int idx = 1; + ps.setString(idx++, entity.getDataSetVersion()); // 1. datasetversion + ps.setString(idx++, entity.getEffectiveDate()); // 2. effectivedate + ps.setString(idx++, entity.getLrno()); // 3. lrno + ps.setString(idx++, entity.getSequence()); // 4. sequence + ps.setString(idx++, entity.getVesselName()); // 5. vesselname + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + + private void setFlagHistoryInsertParameters(PreparedStatement ps, FlagHistoryEntity entity)throws Exception{ + int idx = 1; + ps.setString(idx++, entity.getDataSetVersion()); // 1. datasetversion + ps.setString(idx++, entity.getEffectiveDate()); // 2. effectivedate + ps.setString(idx++, entity.getFlag()); // 3. flag + ps.setString(idx++, entity.getFlagCode()); // 4. flagcode + ps.setString(idx++, entity.getLrno()); // 5. lrno + ps.setString(idx++, entity.getSequence()); // 6. sequence + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + + private void setAdditionalInformationInsertParameters(PreparedStatement ps, AdditionalInformationEntity entity)throws Exception{ + int idx = 1; + ps.setString(idx++, entity.getLrno()); // 1. lrno + ps.setString(idx++, entity.getShipemail()); // 2. shipemail + ps.setString(idx++, entity.getWaterdepthmax()); // 3. waterdepthmax + ps.setString(idx++, entity.getDrilldepthmax()); // 4. drilldepthmax + ps.setString(idx++, entity.getDrillbargeind()); // 5. drillbargeind + ps.setString(idx++, entity.getProductionvesselind()); // 6. productionvesselind + ps.setString(idx++, entity.getDeckheatexchangerind()); // 7. deckheatexchangerind + ps.setString(idx++, entity.getDeckheatexchangermaterial()); // 8. deckheatexchangermaterial + ps.setString(idx++, entity.getTweendeckportable()); // 9. tweendeckportable + ps.setString(idx++, entity.getTweendeckfixed()); // 10. tweendeckfixed + ps.setString(idx++, entity.getSatcomid()); // 11. satcomid + ps.setString(idx++, entity.getSatcomansback()); // 12. satcomansback + ps.setString(idx++, entity.getDataSetVersion()); + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + + private void setPandIHistoryInsertParameters(PreparedStatement ps, PandIHistoryEntity entity)throws Exception{ + int idx = 1; + ps.setString(idx++, entity.getDataSetVersion()); // 1. datasetversion + ps.setString(idx++, entity.getLrno()); // 2. lrno + ps.setString(idx++, entity.getSequence()); // 3. sequence + ps.setString(idx++, entity.getPandiclubcode()); // 4. pandiclubcode + ps.setString(idx++, entity.getPandiclubdecode()); // 5. pandiclubdecode + ps.setString(idx++, entity.getEffectiveDate()); // 6. effectivedate + ps.setString(idx++, entity.getSource()); // 7. source + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + + private void setCallSignAndMmsiHistoryInsertParameters(PreparedStatement ps, CallSignAndMmsiHistoryEntity entity)throws Exception{ + int idx = 1; + ps.setString(idx++, entity.getDataSetVersion()); // 1. datasetversion + ps.setString(idx++, entity.getLrno()); // 2. lrno + ps.setString(idx++, entity.getSequence()); // 3. sequence + ps.setString(idx++, entity.getCallsign()); // 4. callsign + ps.setString(idx++, entity.getMmsi()); // 5. mmsi (JSON에 없으므로 Entity에서 null 처리) + ps.setString(idx++, entity.getEffectiveDate()); // 6. effectivedate + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + + private void setIceClassInsertParameters(PreparedStatement ps, IceClassEntity entity)throws Exception{ + int idx = 1; + ps.setString(idx++, entity.getDataSetVersion()); // 1. datasetversion + ps.setString(idx++, entity.getIceClass()); // 2. iceclass + ps.setString(idx++, entity.getIceClassCode()); // 3. iceclasscode + ps.setString(idx++, entity.getLrno()); // 4. lrno + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + + private void setSafetyManagementCertificateHistoryInsertParameters(PreparedStatement ps, SafetyManagementCertificateHistoryEntity entity)throws Exception{ + int idx = 1; + ps.setString(idx++, entity.getDataSetVersion()); // 1. datasetversion + ps.setString(idx++, entity.getLrno()); // 2. lrno + ps.setString(idx++, entity.getSafetyManagementCertificateAuditor()); // 3. safetymanagementcertificateauditor + ps.setString(idx++, entity.getSafetyManagementCertificateConventionOrVol()); // 4. safetymanagementcertificateconventionorvol + ps.setString(idx++, entity.getSafetyManagementCertificateDateExpires()); // 5. safetymanagementcertificatedateexpires + ps.setString(idx++, entity.getSafetyManagementCertificateDateIssued()); // 6. safetymanagementcertificatedateissued + ps.setString(idx++, entity.getSafetyManagementCertificateDOCCompany()); // 7. safetymanagementcertificatedoccompany + ps.setString(idx++, entity.getSafetyManagementCertificateFlag()); // 8. safetymanagementcertificateflag + ps.setString(idx++, entity.getSafetyManagementCertificateIssuer()); // 9. safetymanagementcertificateissuer + ps.setString(idx++, entity.getSafetyManagementCertificateOtherDescription()); // 10. safetymanagementcertificateotherdescription + ps.setString(idx++, entity.getSafetyManagementCertificateShipName()); // 11. safetymanagementcertificateshipname + ps.setString(idx++, entity.getSafetyManagementCertificateShipType()); // 12. safetymanagementcertificateshiptype + ps.setString(idx++, entity.getSafetyManagementCertificateSource()); // 13. safetymanagementcertificatesource + ps.setString(idx++, entity.getSafetyManagementCertificateCompanyCode()); // 14. safetymanagementcertificatecompanycode + ps.setString(idx++, entity.getSequence()); // 15. sequence + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + + private void setClassHistoryInsertParameters(PreparedStatement ps, ClassHistoryEntity entity)throws Exception{ + int idx = 1; + ps.setString(idx++, entity.getDataSetVersion()); // 1. datasetversion + ps.setString(idx++, entity.get_class()); // 2. "class" + ps.setString(idx++, entity.getClassCode()); // 3. classcode + ps.setString(idx++, entity.getClassIndicator()); // 4. classindicator + ps.setString(idx++, entity.getCurrentIndicator()); // 5. currentindicator + ps.setString(idx++, entity.getEffectiveDate()); // 6. effectivedate + ps.setString(idx++, entity.getLrno()); // 7. lrno + ps.setString(idx++, entity.getSequence()); // 8. "sequence" + ps.setString(idx++, entity.getClassID()); // 9. classid + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + + private void setSurveyDatesHistoryInsertParameters(PreparedStatement ps, SurveyDatesHistoryEntity entity)throws Exception{ + int idx = 1; + ps.setString(idx++, entity.getDataSetVersion()); // 1. datasetversion + ps.setString(idx++, entity.getClassSociety()); // 2. classsociety + ps.setString(idx++, entity.getClassSocietyCode()); // 3. classsocietycode + ps.setString(idx++, entity.getDockingSurvey()); // 4. dockingsurvey + ps.setString(idx++, entity.getLrno()); // 5. lrno + ps.setString(idx++, entity.getSpecialSurvey()); // 6. specialsurvey + ps.setString(idx++, entity.getAnnualSurvey()); // 7. annualsurvey + ps.setString(idx++, entity.getContinuousMachinerySurvey()); // 8. continuousmachinerysurvey + ps.setString(idx++, entity.getTailShaftSurvey()); // 9. tailshaftsurvey + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + + private void setSurveyDatesHistoryUniqueInsertParameters(PreparedStatement ps, SurveyDatesHistoryUniqueEntity entity)throws Exception{ + int idx = 1; + ps.setString(idx++, entity.getDataSetVersion()); // 1. datasetversion + ps.setString(idx++, entity.getLrno()); // 2. lrno + ps.setString(idx++, entity.getClassSocietyCode()); // 3. classsocietycode + ps.setString(idx++, entity.getSurveyDate()); // 4. surveydate + ps.setString(idx++, entity.getSurveyType()); // 5. surveytype + ps.setString(idx++, entity.getClassSociety()); // 6. classsociety + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + + private void setSisterShipLinksInsertParameters(PreparedStatement ps, SisterShipLinksEntity entity)throws Exception{ + int idx = 1; + ps.setString(idx++, entity.getDataSetVersion()); // 1. datasetversion + ps.setString(idx++, entity.getLrno()); // 2. lrno + ps.setString(idx++, entity.getLinkedLRNO()); // 3. linkedlrno + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + + private void setStatusHistoryInsertParameters(PreparedStatement ps, StatusHistoryEntity entity)throws Exception{ + int idx = 1; + ps.setString(idx++, entity.getDataSetVersion()); // 1. datasetversion + ps.setString(idx++, entity.getLrno()); // 2. lrno + ps.setString(idx++, entity.getSequence()); // 3. sequence + ps.setString(idx++, entity.getStatus()); // 4. status + ps.setString(idx++, entity.getStatusCode()); // 5. statuscode + ps.setString(idx++, entity.getStatusDate()); // 6. statusdate + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + + private void setSpecialFeatureInsertParameters(PreparedStatement ps, SpecialFeatureEntity entity)throws Exception{ + int idx = 1; + ps.setString(idx++, entity.getDataSetVersion()); // 1. datasetversion + ps.setString(idx++, entity.getLrno()); // 2. lrno + ps.setString(idx++, entity.getSequence()); // 3. sequence + ps.setString(idx++, entity.getSpecialFeature()); // 4. specialfeature + ps.setString(idx++, entity.getSpecialFeatureCode()); // 5. specialfeaturecode + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + + private void setThrustersInsertParameters(PreparedStatement ps, ThrustersEntity entity)throws Exception{ + int idx = 1; + ps.setString(idx++, entity.getDataSetVersion()); // 1. datasetversion + ps.setString(idx++, entity.getLrno()); // 2. lrno + ps.setString(idx++, entity.getSequence()); // 3. sequence + ps.setString(idx++, entity.getThrusterType()); // 4. thrustertype + ps.setString(idx++, entity.getThrusterTypeCode()); // 5. thrustertypecode + setIntegerOrNull(ps, idx++, entity.getNumberOfThrusters()); // 6. numberofthrusters + ps.setString(idx++, entity.getThrusterPosition()); // 7. thrusterposition + setIntegerOrNull(ps, idx++, entity.getThrusterBHP()); // 8. thrusterbhp + setIntegerOrNull(ps, idx++, entity.getThrusterKW()); // 9. thrusterkw + ps.setString(idx++, entity.getTypeOfInstallation()); // 10. typeofinstallation + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + + private void setCompanyVesselRelationshipInsertParameters(PreparedStatement ps, CompanyVesselRelationshipEntity entity) throws Exception { + int idx = 1; + ps.setString(idx++, entity.getDatasetversion()); + ps.setString(idx++, entity.getDoccode()); + ps.setString(idx++, entity.getDoccompany()); + ps.setString(idx++, entity.getGroupbeneficialowner()); + ps.setString(idx++, entity.getGroupbeneficialownercode()); + ps.setString(idx++, entity.getLrno()); + ps.setString(idx++, entity.getOperator()); + ps.setString(idx++, entity.getOperatorcode()); + ps.setString(idx++, entity.getRegisteredowner()); + ps.setString(idx++, entity.getRegisteredownercode()); + ps.setString(idx++, entity.getShipmanager()); + ps.setString(idx++, entity.getShipmanagercode()); + ps.setString(idx++, entity.getTechnicalmanager()); + ps.setString(idx++, entity.getTechnicalmanagercode()); + ps.setString(idx++, entity.getDocgroup()); + ps.setString(idx++, entity.getDocgroupcode()); + ps.setString(idx++, entity.getOperatorgroup()); + ps.setString(idx++, entity.getOperatorgroupcode()); + ps.setString(idx++, entity.getShipmanagergroup()); + ps.setString(idx++, entity.getShipmanagergroupcode()); + ps.setString(idx++, entity.getTechnicalmanagergroup()); + ps.setString(idx++, entity.getTechnicalmanagergroupcode()); + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + + private void setDarkActivityConfirmedInsertParameters(PreparedStatement ps, DarkActivityConfirmedEntity entity) throws Exception { + int idx = 1; + ps.setString(idx++, entity.getDatasetversion()); + ps.setString(idx++, entity.getLrno()); + ps.setString(idx++, entity.getMmsi()); + ps.setString(idx++, entity.getVessel_name()); + + setIntegerOrNull(ps, idx++, entity.getDark_hours()); + ps.setString(idx++, entity.getDark_activity()); + setIntegerOrNull(ps, idx++, entity.getDark_status()); + setIntegerOrNull(ps, idx++, entity.getArea_id()); + ps.setString(idx++, entity.getArea_name()); + ps.setString(idx++, entity.getArea_country()); + + // 타임스탬프 및 실수형 처리 + setTimestampOrNull(ps, idx++, entity.getDark_time()); // 별도의 파싱 로직 포함된 SqlUtils 함수 필요 + setDoubleOrNull(ps, idx++, entity.getDark_latitude()); + setDoubleOrNull(ps, idx++, entity.getDark_longitude()); + setDoubleOrNull(ps, idx++, entity.getDark_speed()); + setDoubleOrNull(ps, idx++, entity.getDark_heading()); + setDoubleOrNull(ps, idx++, entity.getDark_draught()); + + setTimestampOrNull(ps, idx++, entity.getNextseen()); + setDoubleOrNull(ps, idx++, entity.getNextseen_speed()); + setDoubleOrNull(ps, idx++, entity.getNextseen_draught()); + setDoubleOrNull(ps, idx++, entity.getNextseen_heading()); + + ps.setString(idx++, entity.getDark_reported_destination()); + ps.setString(idx++, entity.getLast_port_of_call()); + ps.setString(idx++, entity.getLast_port_country_code()); + ps.setString(idx++, entity.getLast_port_country()); + setDoubleOrNull(ps, idx++, entity.getNextseen_latitude()); + setDoubleOrNull(ps, idx++, entity.getNextseen_longitude()); + ps.setString(idx++, entity.getNextseen_reported_destination()); + ps.setObject(idx++, entity.getJobExecutionId(), Types.INTEGER); + ps.setString(idx++, entity.getCreatedBy()); + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/repository/ShipDetailSql.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/repository/ShipDetailSql.java new file mode 100644 index 0000000..db92f51 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/repository/ShipDetailSql.java @@ -0,0 +1,541 @@ +package com.snp.batch.jobs.batch.shipdetail.repository; + +import lombok.Setter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * ShipDetail 관련 SQL 생성 클래스 + * application.yml의 app.batch.target-schema.name 값을 사용 + */ +@Component +public class ShipDetailSql { + + private static String targetSchema; + private static String ownerhistoryTable; + private static String crewlistTable; + private static String stowagecommodityTable; + private static String groupbeneficialownerhistoryTable; + private static String shipmanagerhistoryTable; + private static String operatorhistoryTable; + private static String technicalmanagerhistoryTable; + private static String bareboatcharterhistoryTable; + private static String namehistoryTable; + private static String flaghistoryTable; + private static String additionalshipsdataTable; + private static String pandihistoryTable; + private static String callsignandmmsihistoryTable; + private static String iceclassTable; + private static String safetymanagementcertificatehistTable; + private static String classhistoryTable; + private static String surveydatesTable; + private static String surveydateshistoryuniqueTable; + private static String sistershiplinksTable; + private static String statushistoryTable; + private static String specialfeatureTable; + private static String thrustersTable; + private static String companyvesselrelationshipsTable; + private static String darkactivityconfirmedTable; + private static String companyDetailTable; + + @Value("${app.batch.target-schema.name}") + public void setTargetSchema(String schema) { + ShipDetailSql.targetSchema = schema; + } + + @Value("${app.batch.target-schema.tables.ship-015}") + public void setOwnerhistoryTable(String table) { + ShipDetailSql.ownerhistoryTable = table; + } + + @Value("${app.batch.target-schema.tables.ship-008}") + public void setCrewlistTable(String table) { + ShipDetailSql.crewlistTable = table; + } + + @Value("${app.batch.target-schema.tables.ship-022}") + public void setStowagecommodityTable(String table) { + ShipDetailSql.stowagecommodityTable = table; + } + + @Value("${app.batch.target-schema.tables.ship-011}") + public void setGroupbeneficialownerhistoryTable(String table) { + ShipDetailSql.groupbeneficialownerhistoryTable = table; + } + + @Value("${app.batch.target-schema.tables.ship-018}") + public void setShipmanagerhistoryTable(String table) { + ShipDetailSql.shipmanagerhistoryTable = table; + } + + @Value("${app.batch.target-schema.tables.ship-014}") + public void setOperatorhistoryTable(String table) { + ShipDetailSql.operatorhistoryTable = table; + } + + @Value("${app.batch.target-schema.tables.ship-025}") + public void setTechnicalmanagerhistoryTable(String table) { + ShipDetailSql.technicalmanagerhistoryTable = table; + } + + @Value("${app.batch.target-schema.tables.ship-004}") + public void setBareboatcharterhistoryTable(String table) { + ShipDetailSql.bareboatcharterhistoryTable = table; + } + + @Value("${app.batch.target-schema.tables.ship-013}") + public void setNamehistoryTable(String table) { + ShipDetailSql.namehistoryTable = table; + } + + @Value("${app.batch.target-schema.tables.ship-010}") + public void setFlaghistoryTable(String table) { + ShipDetailSql.flaghistoryTable = table; + } + + @Value("${app.batch.target-schema.tables.ship-003}") + public void setAdditionalshipsdataTable(String table) { + ShipDetailSql.additionalshipsdataTable = table; + } + + @Value("${app.batch.target-schema.tables.ship-016}") + public void setPandihistoryTable(String table) { + ShipDetailSql.pandihistoryTable = table; + } + + @Value("${app.batch.target-schema.tables.ship-005}") + public void setCallsignandmmsihistoryTable(String table) { + ShipDetailSql.callsignandmmsihistoryTable = table; + } + + @Value("${app.batch.target-schema.tables.ship-012}") + public void setIceclassTable(String table) { + ShipDetailSql.iceclassTable = table; + } + + @Value("${app.batch.target-schema.tables.ship-017}") + public void setSafetymanagementcertificatehistTable(String table) { + ShipDetailSql.safetymanagementcertificatehistTable = table; + } + + @Value("${app.batch.target-schema.tables.ship-006}") + public void setClasshistoryTable(String table) { + ShipDetailSql.classhistoryTable = table; + } + + @Value("${app.batch.target-schema.tables.ship-023}") + public void setSurveydatesTable(String table) { + ShipDetailSql.surveydatesTable = table; + } + + @Value("${app.batch.target-schema.tables.ship-024}") + public void setSurveydateshistoryuniqueTable(String table) { + ShipDetailSql.surveydateshistoryuniqueTable = table; + } + + @Value("${app.batch.target-schema.tables.ship-019}") + public void setSistershiplinksTable(String table) { + ShipDetailSql.sistershiplinksTable = table; + } + + @Value("${app.batch.target-schema.tables.ship-021}") + public void setStatushistoryTable(String table) { + ShipDetailSql.statushistoryTable = table; + } + + @Value("${app.batch.target-schema.tables.ship-020}") + public void setSpecialfeatureTable(String table) { + ShipDetailSql.specialfeatureTable = table; + } + + @Value("${app.batch.target-schema.tables.ship-026}") + public void setThrustersTable(String table) { + ShipDetailSql.thrustersTable = table; + } + + @Value("${app.batch.target-schema.tables.ship-007}") + public void setCompanyvesselrelationshipsTable(String table) { + ShipDetailSql.companyvesselrelationshipsTable = table; + } + + @Value("${app.batch.target-schema.tables.ship-009}") + public void setDarkactivityconfirmedTable(String table) { + ShipDetailSql.darkactivityconfirmedTable = table; + } + + @Value("${app.batch.target-schema.tables.company-001}") + public void setCompanyDetailTable(String table) { + ShipDetailSql.companyDetailTable = table; + } + + public static String getTargetSchema() { + return targetSchema; + } + + public static String getOwnerHistorySql(){ + return """ + INSERT INTO %s.%s( + dataset_ver, company_status, efect_sta_day, imo_no, ownr, + ownr_cd, ship_ownr_hstry_seq, + job_execution_id, creatr_id + )VALUES( + ?, ?, ?, ?, ?, + ?, ?, + ?, ? + ); + """.formatted(targetSchema, ownerhistoryTable); + } + + public static String getCrewListSql(){ + return """ + INSERT INTO %s.%s( + dataset_ver, crew_id, imo_no, ship_nm, crew_rstr_ymd, + ntnlty, oa_crew_cnt, gen_crew_cnt, offcr_cnt, appr_offcr_cnt, + trne_cnt, embrk_mntnc_crew_cnt, unrprt_cnt, + job_execution_id, creatr_id + )VALUES( + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, + ?, ? + ); + """.formatted(targetSchema, crewlistTable); + } + + public static String getStowageCommoditySql(){ + return """ + INSERT INTO %s.%s( + dataset_ver, cargo_cd, cargo_nm, imo_no, ship_cargo_capacity_seq, + capacity_cd, capacity_cd_desc, + job_execution_id, creatr_id + )VALUES( + ?, ?, ?, ?, ?, + ?, ?, + ?, ? + ); + """.formatted(targetSchema, stowagecommodityTable); + } + + public static String getGroupBeneficialOwnerHistorySql(){ + return """ + INSERT INTO %s.%s( + dataset_ver, company_status, efect_sta_day, group_actl_ownr, group_actl_ownr_cd, + imo_no, ship_group_revn_ownr_hstry_seq, + job_execution_id, creatr_id + )VALUES( + ?, ?, ?, ?, ?, + ?, ?, + ?, ? + ); + """.formatted(targetSchema, groupbeneficialownerhistoryTable); + } + + public static String getShipManagerHistorySql(){ + return """ + INSERT INTO %s.%s( + dataset_ver, company_status, efect_sta_day, imo_no, ship_mng_company_seq, + ship_mngr, ship_mngr_cd, + job_execution_id, creatr_id + )VALUES( + ?, ?, ?, ?, ?, + ?, ?, + ?, ? + ); + """.formatted(targetSchema, shipmanagerhistoryTable); + } + + public static String getOperatorHistorySql(){ + return """ + INSERT INTO %s.%s( + dataset_ver, company_status, efect_sta_day, imo_no, ship_operator, + ship_operator_cd, ship_operator_hstry_seq, + job_execution_id, creatr_id + )VALUES( + ?, ?, ?, ?, ?, + ?, ?, + ?, ? + ); + """.formatted(targetSchema, operatorhistoryTable); + } + + public static String getTechnicalManagerHistorySql(){ + return """ + INSERT INTO %s.%s( + dataset_ver, company_status, efect_sta_day, imo_no, ship_tech_mng_company_seq, + tech_mngr, tech_mngr_cd, + job_execution_id, creatr_id + )VALUES( + ?, ?, ?, ?, ?, + ?, ?, + ?, ? + ); + """.formatted(targetSchema, technicalmanagerhistoryTable); + } + + public static String getBareBoatCharterHistorySql(){ + return """ + INSERT INTO %s.%s( + dataset_ver, imo_no, bbctr_seq, efect_sta_day, bbctr_company_cd, + bbctr_company, + job_execution_id, creatr_id + )VALUES( + ?, ?, ?, ?, ?, + ?, + ?, ? + ); + """.formatted(targetSchema, bareboatcharterhistoryTable); + } + + public static String getNameHistorySql(){ + return """ + INSERT INTO %s.%s( + dataset_ver, efect_sta_day, imo_no, ship_nm_chg_hstry_seq, ship_nm, + job_execution_id, creatr_id + )VALUES( + ?, ?, ?, ?, ?, + ?, ? + ); + """.formatted(targetSchema, namehistoryTable); + } + + public static String getFlagHistorySql(){ + return """ + INSERT INTO %s.%s( + dataset_ver, efect_sta_day, country, country_cd, imo_no, + ship_country_hstry_seq, + job_execution_id, creatr_id + )VALUES( + ?, ?, ?, ?, ?, + ?, + ?, ? + ); + """.formatted(targetSchema, flaghistoryTable); + } + + public static String getAdditionalInformationSql(){ + return """ + INSERT INTO %s.%s( + imo_no, ship_eml, max_dpwt, max_drill_depth, drill_brg, + ocean_prod_facility, deck_heat_exch, dehtex_matral, portbl_twin_deck, fixed_twin_deck, + ship_satlit_comm_id, ship_satlit_cmrsp_cd, dataset_ver, + job_execution_id, creatr_id + )VALUES( + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, + ?, ? + ); + """.formatted(targetSchema, additionalshipsdataTable); + } + + public static String getPandIHistorySql(){ + return """ + INSERT INTO %s.%s( + dataset_ver, imo_no, ship_prtc_rpn_hstry_seq, pni_club_cd, pni_club_nm, + efect_sta_day, src, + job_execution_id, creatr_id + )VALUES( + ?, ?, ?, ?, ?, + ?, ?, + ?, ? + ); + """.formatted(targetSchema, pandihistoryTable); + } + + public static String getCallSignAndMmsiHistorySql(){ + return """ + INSERT INTO %s.%s( + dataset_ver, imo_no, ship_idntf_seq, clsgn_no, mmsi_no, + efect_sta_day, + job_execution_id, creatr_id + )VALUES( + ?, ?, ?, ?, ?, + ?, + ?, ? + ); + """.formatted(targetSchema, callsignandmmsihistoryTable); + } + + public static String getIceClassSql(){ + return """ + INSERT INTO %s.%s( + dataset_ver, ice_grd, ice_grd_cd, imo_no, + job_execution_id, creatr_id + )VALUES( + ?, ?, ?, ?, + ?, ? + ); + """.formatted(targetSchema, iceclassTable); + } + + public static String getSafetyManagementCertificateHistorySql(){ + return """ + INSERT INTO %s.%s( + dataset_ver, imo_no, smgrc_srng_engines, smgrc_sys_cat_conv_arbt, smgrc_expry_day, + smgrc_issue_day, smgrc_docc_company, smgrc_ntnlty, smgrc_issue_engines, smgrc_etc_desc, + smgrc_ship_nm, smgrc_ship_type, smgrc_src, smgrc_company_cd, ship_sfty_mng_evdc_seq, + job_execution_id, creatr_id + )VALUES( + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ? + ); + """.formatted(targetSchema, safetymanagementcertificatehistTable); + } + + public static String getClassHistorySql(){ + return """ + INSERT INTO %s.%s( + dataset_ver, clfic_asctn_nm, clfic_cd, clfic_has_yn, now_yn, + efect_sta_day, imo_no, clfic_hstry_seq, clfic_id, + job_execution_id, creatr_id + )VALUES( + ?, ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ? + ); + """.formatted(targetSchema, classhistoryTable); + } + + public static String getSurveyDatesHistorySql(){ + return """ + INSERT INTO %s.%s( + dataset_ver, clfic, clfic_cd, dckng_inspection, imo_no, + fxtm_inspection, annual_inspection, mchn_fxtm_inspection_ymd, tlsft_inspection_ymd, + job_execution_id, creatr_id + )VALUES( + ?, ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ? + ); + """.formatted(targetSchema, surveydatesTable); + } + + public static String getSurveyDatesHistoryUniqueSql(){ + return """ + INSERT INTO %s.%s( + dataset_ver, imo_no, clfic_cd, inspection_ymd, inspection_type, + clfic, + job_execution_id, creatr_id + )VALUES( + ?, ?, ?, ?, ?, + ?, + ?, ? + ); + """.formatted(targetSchema, surveydateshistoryuniqueTable); + } + + public static String getSisterShipLinksSql(){ + return """ + INSERT INTO %s.%s( + dataset_ver, imo_no, link_imo_no, job_execution_id, creatr_id + )VALUES( + ?, ?, ?, ?, ? + ); + """.formatted(targetSchema, sistershiplinksTable); + } + + public static String getStatusHistorySql(){ + return """ + INSERT INTO %s.%s( + dataset_ver, imo_no, ship_status_hstry_seq, status, status_cd, status_chg_ymd, + job_execution_id, creatr_id + )VALUES( + ?, ?, ?, ?, ?, ?, + ?, ? + ); + """.formatted(targetSchema, statushistoryTable); + } + + public static String getSpecialFeatureSql(){ + return """ + INSERT INTO %s.%s( + dataset_ver, imo_no, ship_spc_fetr_seq, spc_mttr, spc_mttr_cd, + job_execution_id, creatr_id + )VALUES( + ?, ?, ?, ?, ?, + ?, ? + ); + """.formatted(targetSchema, specialfeatureTable); + } + + public static String getThrustersSql(){ + return """ + INSERT INTO %s.%s( + dataset_ver, imo_no, thrstr_seq, thrstr_type, thrstr_type_cd, + thrstr_cnt, thrstr_position, thrstr_power_bhp, thrstr_power_kw, instl_mth, + job_execution_id, creatr_id + )VALUES( + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ? + ); + """.formatted(targetSchema, thrustersTable); + } + + public static String getCompanyVesselRelationshipSql(){ + return """ + INSERT INTO %s.%s ( + dataset_ver, docc_has_company_cd, docc_has_company, group_actl_ownr, group_actl_ownr_cd, + imo_no, ship_operator, ship_operator_cd, rg_ownr, rg_ownr_cd, + ship_mng_company, ship_mng_company_cd, tech_mng_company, tech_mng_company_cd, docc_group, + docc_group_cd, ship_operator_group, ship_operator_group_cd, ship_mng_company_group, ship_mng_company_group_cd, + tech_mng_company_group, tech_mng_company_group_cd, + job_execution_id, creatr_id + ) VALUES ( + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, + ?, ? + ); + """.formatted(targetSchema, companyvesselrelationshipsTable); + } + + public static String getDarkActivityConfirmedSql(){ + return """ + INSERT INTO %s.%s ( + dataset_ver, imo_no, mmsi_no, ship_nm, dark_hr, + dark_actv, dark_actv_status, zone_id, zone_nm, zone_country, + dark_tm_utc, dark_lat, dark_lon, dark_spd, dark_heading, + dark_draft, nxt_cptr_tm_utc, nxt_cptr_spd, nxt_cptr_draft, nxt_cptr_heading, + dark_rpt_dest_ais, last_prtcll_port, last_poccntry_cd, last_poccntry, nxt_cptr_lat, + nxt_cptr_lon, nxt_cptr_rpt_dest_ais, + job_execution_id, creatr_id + ) VALUES ( + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, + ?, ? + ); + """.formatted(targetSchema, darkactivityconfirmedTable); + } + + public static String getCompanyDetailSql() { + return """ + INSERT INTO %s.%s ( + dataset_ver, company_cd, company_name_abbr, country_nm, cty_nm, + tel, tlx, eml_addr, wbst_url, full_nm, + care_cd, dtl_addr_one, dtl_addr_two, dtl_addr_thr, po_box, + dist_no, dist_nm, mail_addr_frnt, mail_addr_rear, country_reg, + country_ctrl, region_cd, country_reg_cd, country_ctrl_cd, last_upd_ymd, + prnt_company_cd, company_status, oa_addr, fax_no, company_fndn_ymd, + job_execution_id, creatr_id + ) VALUES ( + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ? + ); + """.formatted(targetSchema, companyDetailTable); + } + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/repository/ShipHashRepository.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/repository/ShipHashRepository.java new file mode 100644 index 0000000..cd96098 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/repository/ShipHashRepository.java @@ -0,0 +1,10 @@ +package com.snp.batch.jobs.batch.shipdetail.repository; + +import com.snp.batch.jobs.batch.shipdetail.entity.ShipHashEntity; + +import java.util.List; + +public interface ShipHashRepository { + void saveAllData(List entities); + +} diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/repository/ShipHashRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/repository/ShipHashRepositoryImpl.java new file mode 100644 index 0000000..10b682f --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/repository/ShipHashRepositoryImpl.java @@ -0,0 +1,141 @@ +package com.snp.batch.jobs.batch.shipdetail.repository; + +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.batch.shipdetail.entity.ShipHashEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.Timestamp; +import java.util.List; + +@Slf4j +@Repository("ShipHashRepository") +public class ShipHashRepositoryImpl extends BaseJdbcRepository implements ShipHashRepository{ + + @Value("${app.batch.target-schema.name}") + private String targetSchema; + + @Value("${app.batch.target-schema.tables.ship-028}") + private String tableName; + + public ShipHashRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + protected String getTargetSchema() { + return targetSchema; + } + + @Override + protected String getSimpleTableName() { + return tableName; + } + + @Override + protected RowMapper getRowMapper() { + return null; + } + + @Override + protected String extractId(ShipHashEntity entity) { + return entity.getImoNumber(); + } + + @Override + protected String getInsertSql() { + return """ + INSERT INTO %s( + imo_number, ship_detail_hash, created_at, created_by, updated_at, updated_by + )VALUES( + ?, ?, ?, ?, ?, ? + ) + ON CONFLICT (imo_number) + DO UPDATE SET + ship_detail_hash = EXCLUDED.ship_detail_hash, + updated_at = ?, + updated_by = ? + """.formatted(getTableName()); + } + + @Override + protected String getUpdateSql() { + return """ + UPDATE %s + SET ship_detail_hash = ?, + updated_at = ?, + updated_by = ? + WHERE imo_number = ? + """.formatted(getTableName()); + } + + @Override + protected void setInsertParameters(PreparedStatement ps, ShipHashEntity entity) throws Exception { + int idx = 1; + ps.setString(idx++, entity.getImoNumber()); + ps.setString(idx++, entity.getShipDetailHash()); + // 감사 필드 + ps.setTimestamp(idx++, entity.getCreatedAt() != null ? + Timestamp.valueOf(entity.getCreatedAt()) : Timestamp.valueOf(now())); + ps.setString(idx++, entity.getCreatedBy() != null ? entity.getCreatedBy() : "SYSTEM"); + ps.setTimestamp(idx++, entity.getUpdatedAt() != null ? + Timestamp.valueOf(entity.getUpdatedAt()) : Timestamp.valueOf(now())); + ps.setString(idx++, entity.getUpdatedBy() != null ? entity.getUpdatedBy() : "SYSTEM");ps.setTimestamp(idx++, entity.getUpdatedAt() != null ? + Timestamp.valueOf(entity.getUpdatedAt()) : Timestamp.valueOf(now())); + ps.setString(idx++, entity.getUpdatedBy() != null ? entity.getUpdatedBy() : "SYSTEM"); + } + + @Override + protected void setUpdateParameters(PreparedStatement ps, ShipHashEntity entity) throws Exception { + int idx = 1; + ps.setString(idx++, entity.getShipDetailHash()); + // 감사 필드 + ps.setTimestamp(idx++, entity.getUpdatedAt() != null ? + Timestamp.valueOf(entity.getUpdatedAt()) : Timestamp.valueOf(now())); + ps.setString(idx++, entity.getUpdatedBy() != null ? entity.getUpdatedBy() : "SYSTEM"); + ps.setString(idx++, entity.getImoNumber()); + } + + @Override + protected String getEntityName() { + return "ShipHashEntity"; + } + + @Override + public void saveAllData(List entities) { + if (entities == null || entities.isEmpty()) { + return; + } + + log.info("{} 전체 저장 시작: {} 건", getEntityName(), entities.size()); + + // INSERT와 UPDATE 분리 + List toInsert = entities.stream() + .filter(e -> extractId(e) == null || !existsByImo(extractId(e))) + .toList(); + + List toUpdate = entities.stream() + .filter(e -> extractId(e) != null && existsByImo(extractId(e))) + .toList(); + + if (!toInsert.isEmpty()) { + batchInsert(toInsert); + } + + if (!toUpdate.isEmpty()) { + batchUpdate(toUpdate); + } + + log.info("{} 전체 저장 완료: 삽입={} 건, 수정={} 건", getEntityName(), toInsert.size(), toUpdate.size()); + } + + public boolean existsByImo(String imo) { + String sql = String.format("SELECT COUNT(*) FROM %s WHERE %s = ?", getTableName(), getIdColumnName("imo_number")); + Long count = jdbcTemplate.queryForObject(sql, Long.class, imo); + return count != null && count > 0; + } +} diff --git a/src/main/java/com/snp/batch/jobs/batch/shipdetail/writer/ShipDetailDataWriter.java b/src/main/java/com/snp/batch/jobs/batch/shipdetail/writer/ShipDetailDataWriter.java new file mode 100644 index 0000000..8a7fa22 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/batch/shipdetail/writer/ShipDetailDataWriter.java @@ -0,0 +1,171 @@ +package com.snp.batch.jobs.batch.shipdetail.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.batch.shipdetail.dto.ShipDetailUpdate; +import com.snp.batch.jobs.batch.shipdetail.entity.*; +import com.snp.batch.jobs.batch.shipdetail.repository.ShipDetailRepository; +import com.snp.batch.jobs.batch.shipdetail.repository.ShipHashRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.util.CollectionUtils; + +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 선박 상세 정보 Writer + */ +@Slf4j +@Component +public class ShipDetailDataWriter extends BaseWriter { + + private final ShipDetailRepository shipDetailRepository; + private final ShipHashRepository shipHashRepository; + + public ShipDetailDataWriter(ShipDetailRepository shipDetailRepository, ShipHashRepository shipHashRepository) { + super("ShipDetail"); + this.shipDetailRepository = shipDetailRepository; + this.shipHashRepository = shipHashRepository; + } + + @Override + protected void writeItems(List items) throws Exception { + + if (CollectionUtils.isEmpty(items)) { + return; + } + + // 0. ShipDetailRepository (선박 제원정보 데이터) + shipDetailRepository.saveAllCoreData(items); + + for(ShipDetailEntity data : items) { + // 1. OwnerHistory + if (!CollectionUtils.isEmpty(data.getOwnerHistoryEntityList())) { + shipDetailRepository.saveAllOwnerHistoryData(data.getOwnerHistoryEntityList()); + } + // 2. CrewList + if (!CollectionUtils.isEmpty(data.getCrewListEntityList())) { + shipDetailRepository.saveAllCrewListData(data.getCrewListEntityList()); + } + + // 3. StowageCommodity + if (!CollectionUtils.isEmpty(data.getStowageCommodityEntityList())) { + shipDetailRepository.saveAllStowageCommodityData(data.getStowageCommodityEntityList()); + } + + // 4. GroupBeneficialOwnerHistory + if (!CollectionUtils.isEmpty(data.getGroupBeneficialOwnerHistoryEntityList())) { + shipDetailRepository.saveAllGroupBeneficialOwnerHistoryData(data.getGroupBeneficialOwnerHistoryEntityList()); + } + + // 5. ShipManagerHistory + if (!CollectionUtils.isEmpty(data.getShipManagerHistoryEntityList())) { + shipDetailRepository.saveAllShipManagerHistoryData(data.getShipManagerHistoryEntityList()); + } + + // 6. OperatorHistory + if (!CollectionUtils.isEmpty(data.getOperatorHistoryEntityList())) { + shipDetailRepository.saveAllOperatorHistoryData(data.getOperatorHistoryEntityList()); + } + + // 7. TechnicalManagerHistory + if (!CollectionUtils.isEmpty(data.getTechnicalManagerHistoryEntityList())) { + shipDetailRepository.saveAllTechnicalManagerHistoryData(data.getTechnicalManagerHistoryEntityList()); + } + + // 8. BareBoatCharterHistory + if (!CollectionUtils.isEmpty(data.getBareBoatCharterHistoryEntityList())) { + shipDetailRepository.saveAllBareBoatCharterHistoryData(data.getBareBoatCharterHistoryEntityList()); + } + + // 9. NameHistory + if (!CollectionUtils.isEmpty(data.getNameHistoryEntityList())) { + shipDetailRepository.saveAllNameHistoryData(data.getNameHistoryEntityList()); + } + + // 10. FlagHistory + if (!CollectionUtils.isEmpty(data.getFlagHistoryEntityList())) { + shipDetailRepository.saveAllFlagHistoryData(data.getFlagHistoryEntityList()); + } + + // 11. AdditionalInformation + if (!CollectionUtils.isEmpty(data.getAdditionalInformationEntityList())) { + shipDetailRepository.saveAllAdditionalInformationData(data.getAdditionalInformationEntityList()); + } + + // 12. PandIHistory + if (!CollectionUtils.isEmpty(data.getPandIHistoryEntityList())) { + shipDetailRepository.saveAllPandIHistoryData(data.getPandIHistoryEntityList()); + } + + // 13. CallSignAndMmsiHistory + if (!CollectionUtils.isEmpty(data.getCallSignAndMmsiHistoryEntityList())) { + shipDetailRepository.saveAllCallSignAndMmsiHistoryData(data.getCallSignAndMmsiHistoryEntityList()); + } + + // 14. IceClass + if (!CollectionUtils.isEmpty(data.getIceClassEntityList())) { + shipDetailRepository.saveAllIceClassData(data.getIceClassEntityList()); + } + + // 15. SafetyManagementCertificateHistory + if (!CollectionUtils.isEmpty(data.getSafetyManagementCertificateHistoryEntityList())) { + shipDetailRepository.saveAllSafetyManagementCertificateHistoryData(data.getSafetyManagementCertificateHistoryEntityList()); + } + + // 16. ClassHistory + if (!CollectionUtils.isEmpty(data.getClassHistoryEntityList())) { + shipDetailRepository.saveAllClassHistoryData(data.getClassHistoryEntityList()); + } + + // 17. SurveyDatesHistory + if (!CollectionUtils.isEmpty(data.getSurveyDatesHistoryEntityList())) { + shipDetailRepository.saveAllSurveyDatesHistoryData(data.getSurveyDatesHistoryEntityList()); + } + + // 18. SurveyDatesHistoryUnique + if (!CollectionUtils.isEmpty(data.getSurveyDatesHistoryUniqueEntityList())) { + shipDetailRepository.saveAllSurveyDatesHistoryUniqueData(data.getSurveyDatesHistoryUniqueEntityList()); + } + + // 19. SisterShipLinks + if (!CollectionUtils.isEmpty(data.getSisterShipLinksEntityList())) { + shipDetailRepository.saveAllSisterShipLinksData(data.getSisterShipLinksEntityList()); + } + + // 20. StatusHistory + if (!CollectionUtils.isEmpty(data.getStatusHistoryEntityList())) { + shipDetailRepository.saveAllStatusHistoryData(data.getStatusHistoryEntityList()); + } + + // 21. SpecialFeature + if (!CollectionUtils.isEmpty(data.getSpecialFeatureEntityList())) { + shipDetailRepository.saveAllSpecialFeatureData(data.getSpecialFeatureEntityList()); + } + + // 22. Thrusters + if (!CollectionUtils.isEmpty(data.getThrustersEntityList())) { + shipDetailRepository.saveAllThrustersData(data.getThrustersEntityList()); + } + + // 23. DarkActivityConfirmed + if (!CollectionUtils.isEmpty(data.getDarkActivityConfirmedEntityList())) { + shipDetailRepository.saveAllDarkActivityConfirmedData(data.getDarkActivityConfirmedEntityList()); + } + + // 24. CompanyVesselRelationship + if (!CollectionUtils.isEmpty(data.getCompanyVesselRelationshipEntityList())) { + shipDetailRepository.saveAllCompanyVesselRelationshipData(data.getCompanyVesselRelationshipEntityList()); + } + + // 25. CompanyDetail (만약 ShipDetail과 연관관계가 있다면) + if (!CollectionUtils.isEmpty(data.getCompanyDetailEntityList())) { + shipDetailRepository.saveAllCompanyDetailData(data.getCompanyDetailEntityList()); + } + + } + + } + +} diff --git a/src/main/java/com/snp/batch/scheduler/QuartzBatchJob.java b/src/main/java/com/snp/batch/scheduler/QuartzBatchJob.java new file mode 100644 index 0000000..59c6418 --- /dev/null +++ b/src/main/java/com/snp/batch/scheduler/QuartzBatchJob.java @@ -0,0 +1,50 @@ +package com.snp.batch.scheduler; + +import com.snp.batch.service.QuartzJobService; +import lombok.extern.slf4j.Slf4j; +import org.quartz.Job; +import org.quartz.JobExecutionContext; +import org.quartz.JobExecutionException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * Quartz Job 구현체 + * Quartz 스케줄러에 의해 실행되어 실제 Spring Batch Job을 호출 + */ +@Slf4j +@Component +public class QuartzBatchJob implements Job { + + @Autowired + private QuartzJobService quartzJobService; + + /** + * Quartz 스케줄러에 의해 호출되는 메서드 + * + * @param context JobExecutionContext + * @throws JobExecutionException 실행 중 발생한 예외 + */ + @Override + public void execute(JobExecutionContext context) throws JobExecutionException { + // JobDataMap에서 배치 작업 이름 가져오기 + String jobName = context.getJobDetail().getJobDataMap().getString("jobName"); + + log.info("========================================"); + log.info("Quartz 스케줄러 트리거 발생"); + log.info("실행할 배치 작업: {}", jobName); + log.info("트리거 시간: {}", context.getFireTime()); + log.info("다음 실행 시간: {}", context.getNextFireTime()); + log.info("========================================"); + + try { + // QuartzJobService를 통해 실제 Spring Batch Job 실행 + quartzJobService.executeBatchJob(jobName); + + } catch (Exception e) { + log.error("Quartz Job 실행 중 에러 발생", e); + // JobExecutionException으로 래핑하여 Quartz에 에러 전파 + throw new JobExecutionException("Failed to execute batch job: " + jobName, e); + } + } +} diff --git a/src/main/java/com/snp/batch/scheduler/SchedulerInitializer.java b/src/main/java/com/snp/batch/scheduler/SchedulerInitializer.java new file mode 100644 index 0000000..6b21cf7 --- /dev/null +++ b/src/main/java/com/snp/batch/scheduler/SchedulerInitializer.java @@ -0,0 +1,173 @@ +package com.snp.batch.scheduler; + +import com.snp.batch.global.model.JobScheduleEntity; +import com.snp.batch.global.repository.JobScheduleRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.quartz.*; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Set; + +/** + * 애플리케이션 시작 시 DB에 저장된 스케줄을 Quartz에 자동 로드 + * ApplicationReadyEvent를 수신하여 모든 빈 초기화 후 실행 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SchedulerInitializer { + + private final JobScheduleRepository scheduleRepository; + private final Scheduler scheduler; + + /** + * 애플리케이션 준비 완료 시 호출 + * DB의 활성화된 스케줄을 Quartz에 로드 + */ + @EventListener(ApplicationReadyEvent.class) + public void initializeSchedules() { + log.info("========================================"); + log.info("스케줄러 초기화 시작"); + log.info("========================================"); + + try { + // 기존 orphan trigger 전체 정리 (이전 실행에서 남은 잔여 trigger 제거) + cleanupOrphanTriggers(); + + // DB에서 활성화된 스케줄 조회 + List activeSchedules = scheduleRepository.findAllActive(); + + if (activeSchedules.isEmpty()) { + log.info("활성화된 스케줄이 없습니다."); + startSchedulerIfNeeded(); + return; + } + + log.info("총 {}개의 활성 스케줄을 로드합니다.", activeSchedules.size()); + + int successCount = 0; + int failCount = 0; + + // 각 스케줄을 Quartz에 등록 + for (JobScheduleEntity schedule : activeSchedules) { + try { + registerSchedule(schedule); + successCount++; + log.info("✓ 스케줄 로드 성공: {} (Cron: {})", + schedule.getJobName(), schedule.getCronExpression()); + + } catch (Exception e) { + failCount++; + log.error("✗ 스케줄 로드 실패: {}", schedule.getJobName(), e); + } + } + + log.info("========================================"); + log.info("스케줄러 초기화 완료"); + log.info("성공: {}개, 실패: {}개", successCount, failCount); + log.info("========================================"); + + // Quartz 스케줄러 시작 + startSchedulerIfNeeded(); + + } catch (Exception e) { + log.error("스케줄러 초기화 중 에러 발생", e); + } + } + + /** + * 개별 스케줄을 Quartz에 등록 + * Trigger를 먼저 명시적으로 제거한 후 Job을 삭제하여 orphan trigger 방지 + * + * @param schedule JobScheduleEntity + * @throws SchedulerException Quartz 스케줄러 예외 + */ + private void registerSchedule(JobScheduleEntity schedule) throws SchedulerException { + String jobName = schedule.getJobName(); + JobKey jobKey = new JobKey(jobName, "batch-jobs"); + TriggerKey triggerKey = new TriggerKey(jobName + "-trigger", "batch-triggers"); + + // 1. 기존 Trigger 명시적 제거 (orphan trigger 방지) + if (scheduler.checkExists(triggerKey)) { + scheduler.unscheduleJob(triggerKey); + log.debug("기존 Quartz Trigger 제거: {}", triggerKey); + } + + // 2. 기존 Job 삭제 (연결된 trigger도 함께 삭제됨) + if (scheduler.checkExists(jobKey)) { + scheduler.deleteJob(jobKey); + log.debug("기존 Quartz Job 삭제: {}", jobName); + } + + // JobDetail 생성 + JobDetail jobDetail = JobBuilder.newJob(QuartzBatchJob.class) + .withIdentity(jobKey) + .usingJobData("jobName", jobName) + .withDescription(schedule.getDescription()) + .storeDurably(true) + .build(); + + // CronTrigger 생성 + CronTrigger trigger = TriggerBuilder.newTrigger() + .withIdentity(triggerKey) + .withSchedule(CronScheduleBuilder.cronSchedule(schedule.getCronExpression()) + .withMisfireHandlingInstructionDoNothing()) + .forJob(jobKey) + .build(); + + // Quartz에 스케줄 등록 + scheduler.scheduleJob(jobDetail, trigger); + + // 다음 실행 시간 로깅 + if (trigger.getNextFireTime() != null) { + log.debug(" → 다음 실행 예정: {}", trigger.getNextFireTime()); + } + } + + /** + * Quartz 스케줄러가 아직 시작되지 않았으면 시작 + */ + private void startSchedulerIfNeeded() throws SchedulerException { + if (!scheduler.isStarted()) { + scheduler.start(); + log.info("Quartz 스케줄러 시작됨"); + } + } + + /** + * 앱 시작 시 batch-triggers 그룹의 모든 기존 trigger와 batch-jobs 그룹의 모든 기존 job을 제거 + * JDBC Store에 잔존하는 orphan trigger로 인한 중복 실행 방지 + */ + private void cleanupOrphanTriggers() throws SchedulerException { + // batch-triggers 그룹의 모든 trigger 제거 + Set triggerKeys = scheduler.getTriggerKeys( + org.quartz.impl.matchers.GroupMatcher.triggerGroupEquals("batch-triggers")); + if (!triggerKeys.isEmpty()) { + log.info("기존 trigger {} 개 정리 시작 (batch-triggers 그룹)", triggerKeys.size()); + for (TriggerKey tk : triggerKeys) { + scheduler.unscheduleJob(tk); + log.debug(" Trigger 제거: {}", tk); + } + } + + // batch-jobs 그룹의 모든 job 제거 + Set jobKeys = scheduler.getJobKeys( + org.quartz.impl.matchers.GroupMatcher.jobGroupEquals("batch-jobs")); + if (!jobKeys.isEmpty()) { + log.info("기존 job {} 개 정리 시작 (batch-jobs 그룹)", jobKeys.size()); + for (JobKey jk : jobKeys) { + scheduler.deleteJob(jk); + log.debug(" Job 제거: {}", jk); + } + } + + if (!triggerKeys.isEmpty() || !jobKeys.isEmpty()) { + log.info("기존 스케줄 정리 완료: trigger {} 개, job {} 개 제거", + triggerKeys.size(), jobKeys.size()); + } + } +} diff --git a/src/main/java/com/snp/batch/service/BatchApiLogService.java b/src/main/java/com/snp/batch/service/BatchApiLogService.java new file mode 100644 index 0000000..a5b1665 --- /dev/null +++ b/src/main/java/com/snp/batch/service/BatchApiLogService.java @@ -0,0 +1,33 @@ +package com.snp.batch.service; + +import com.snp.batch.global.model.BatchApiLog; +import com.snp.batch.global.repository.BatchApiLogRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class BatchApiLogService { + private final BatchApiLogRepository batchApiLogRepository; + + /** + * 비동기로 API 로그를 저장합니다. + * propagation = Propagation.REQUIRES_NEW 를 사용하여 + * 메인 배치가 실패(Rollback)하더라도 로그는 저장되도록 설정합니다. + */ + @Async("apiLogExecutor") // 설정한 스레드 풀 사용 + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void saveLog(BatchApiLog logEntry) { + try { + batchApiLogRepository.save(logEntry); + } catch (Exception e) { + // 로그 저장 실패가 배치를 중단시키지 않도록 여기서 예외 처리 + log.error("API 로그 저장 실패: {}", e.getMessage()); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/service/BatchDateService.java b/src/main/java/com/snp/batch/service/BatchDateService.java new file mode 100644 index 0000000..32c45b5 --- /dev/null +++ b/src/main/java/com/snp/batch/service/BatchDateService.java @@ -0,0 +1,218 @@ +package com.snp.batch.service; + +import com.snp.batch.global.model.BatchCollectionPeriod; +import com.snp.batch.global.repository.BatchCollectionPeriodRepository; +import com.snp.batch.global.repository.BatchLastExecutionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.scope.context.StepContext; +import org.springframework.batch.core.scope.context.StepSynchronizationManager; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class BatchDateService { + + private final BatchLastExecutionRepository repository; + private final BatchCollectionPeriodRepository collectionPeriodRepository; + + /** + * 현재 Step의 Job 파라미터에서 executionMode를 확인 + */ + private String getExecutionMode() { + try { + StepContext context = StepSynchronizationManager.getContext(); + if (context != null && context.getStepExecution() != null) { + return context.getStepExecution().getJobExecution() + .getJobParameters().getString("executionMode", "NORMAL"); + } + } catch (Exception e) { + log.debug("StepSynchronizationManager 컨텍스트 접근 실패, NORMAL 모드로 처리", e); + } + return "NORMAL"; + } + + /** + * 현재 Step의 Job 파라미터에서 apiKey 파라미터를 확인 (재수집용) + */ + private String getRecollectApiKey() { + try { + StepContext context = StepSynchronizationManager.getContext(); + if (context != null && context.getStepExecution() != null) { + return context.getStepExecution().getJobExecution() + .getJobParameters().getString("apiKey"); + } + } catch (Exception e) { + // ignore + } + return null; + } + + /** + * 현재 Step의 Job 파라미터에서 executor를 확인. + * AUTO_RETRY/MANUAL_RETRY이면 실패건 재수집이므로 기간 테이블을 사용하지 않는다. + */ + private boolean isFailedRecordRetry() { + try { + StepContext context = StepSynchronizationManager.getContext(); + if (context != null && context.getStepExecution() != null) { + String executor = context.getStepExecution().getJobExecution() + .getJobParameters().getString("executor"); + return "AUTO_RETRY".equals(executor) || "MANUAL_RETRY".equals(executor); + } + } catch (Exception e) { + log.debug("executor 파라미터 확인 실패", e); + } + return false; + } + + public Map getDateRangeWithoutTimeParams(String apiKey) { + // 기간 재수집 모드: batch_collection_period에서 날짜 조회 + // 실패건 재수집(AUTO_RETRY/MANUAL_RETRY)은 정상 모드와 동일하게 last_success_date 기반 사용 + if ("RECOLLECT".equals(getExecutionMode()) && !isFailedRecordRetry()) { + return getCollectionPeriodDateParams(apiKey); + } + + // 정상 모드: last_success_date ~ now() + return repository.findDateRangeByApiKey(apiKey) + .map(projection -> { + LocalDateTime toDate = LocalDateTime.now(); + saveToDateToJobContext(toDate); + + Map params = new HashMap<>(); + putDateParams(params, "from", projection.getLastSuccessDate()); + putDateParams(params, "to", toDate); + params.put("shipsCategory", "0"); + return params; + }) + .orElseGet(() -> { + log.warn("해당 apiKey에 대한 데이터를 찾을 수 없습니다: {}", apiKey); + return new HashMap<>(); + }); + } + + public Map getDateRangeWithTimezoneParams(String apiKey) { + return getDateRangeWithTimezoneParams(apiKey, "fromDate", "toDate"); + } + + public Map getDateRangeWithTimezoneParams(String apiKey, String dateParam1, String dateParam2) { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSX"); + + // 기간 재수집 모드: batch_collection_period에서 날짜 조회 + // 실패건 재수집(AUTO_RETRY/MANUAL_RETRY)은 정상 모드와 동일하게 last_success_date 기반 사용 + if ("RECOLLECT".equals(getExecutionMode()) && !isFailedRecordRetry()) { + return getCollectionPeriodTimezoneParams(apiKey, dateParam1, dateParam2, formatter); + } + + // 정상 모드: last_success_date ~ now() + return repository.findDateRangeByApiKey(apiKey) + .map(projection -> { + LocalDateTime toDate = LocalDateTime.now(); + saveToDateToJobContext(toDate); + + Map params = new HashMap<>(); + params.put(dateParam1, formatToUtc(projection.getLastSuccessDate(), formatter)); + params.put(dateParam2, formatToUtc(toDate, formatter)); + return params; + }) + .orElseGet(() -> { + log.warn("해당 apiKey에 대한 데이터를 찾을 수 없습니다: {}", apiKey); + return new HashMap<>(); + }); + } + + /** + * 재수집 모드: batch_collection_period에서 년/월/일 파라미터 생성 + */ + private Map getCollectionPeriodDateParams(String apiKey) { + String recollectApiKey = getRecollectApiKey(); + String lookupKey = recollectApiKey != null ? recollectApiKey : apiKey; + + Optional opt = collectionPeriodRepository.findById(lookupKey); + if (opt.isEmpty()) { + log.warn("[RECOLLECT] 수집기간 설정을 찾을 수 없습니다: {}", lookupKey); + return new HashMap<>(); + } + + BatchCollectionPeriod cp = opt.get(); + Map params = new HashMap<>(); + putDateParams(params, "from", cp.getRangeFromDate()); + putDateParams(params, "to", cp.getRangeToDate()); + params.put("shipsCategory", "0"); + + log.info("[RECOLLECT] batch_collection_period 날짜 사용: apiKey={}, range={}~{}", + lookupKey, cp.getRangeFromDate(), cp.getRangeToDate()); + return params; + } + + /** + * 재수집 모드: batch_collection_period에서 UTC 타임존 파라미터 생성 + */ + private Map getCollectionPeriodTimezoneParams( + String apiKey, String dateParam1, String dateParam2, DateTimeFormatter formatter) { + String recollectApiKey = getRecollectApiKey(); + String lookupKey = recollectApiKey != null ? recollectApiKey : apiKey; + + Optional opt = collectionPeriodRepository.findById(lookupKey); + if (opt.isEmpty()) { + log.warn("[RECOLLECT] 수집기간 설정을 찾을 수 없습니다: {}", lookupKey); + return new HashMap<>(); + } + + BatchCollectionPeriod cp = opt.get(); + Map params = new HashMap<>(); + params.put(dateParam1, formatToUtc(cp.getRangeFromDate(), formatter)); + params.put(dateParam2, formatToUtc(cp.getRangeToDate(), formatter)); + + log.info("[RECOLLECT] batch_collection_period 날짜 사용 (UTC): apiKey={}, range={}~{}", + lookupKey, cp.getRangeFromDate(), cp.getRangeToDate()); + return params; + } + + /** + * 배치 시작 시 캡처한 toDate를 JobExecutionContext에 저장 + * LastExecutionUpdateTasklet에서 이 값을 꺼내 LAST_SUCCESS_DATE로 사용 + */ + private void saveToDateToJobContext(LocalDateTime toDate) { + try { + StepContext context = StepSynchronizationManager.getContext(); + if (context != null && context.getStepExecution() != null) { + context.getStepExecution().getJobExecution() + .getExecutionContext().put("batchToDate", toDate.toString()); + log.debug("batchToDate JobContext 저장 완료: {}", toDate); + } + } catch (Exception e) { + log.warn("batchToDate JobContext 저장 실패", e); + } + } + + /** + * LocalDateTime에서 연, 월, 일을 추출하여 Map에 담는 헬퍼 메소드 + */ + private void putDateParams(Map params, String prefix, LocalDateTime dateTime) { + if (dateTime != null) { + params.put(prefix + "Year", String.valueOf(dateTime.getYear())); + params.put(prefix + "Month", String.valueOf(dateTime.getMonthValue())); + params.put(prefix + "Day", String.valueOf(dateTime.getDayOfMonth())); + } + } + + /** + * 한국 시간(LocalDateTime)을 UTC 문자열로 변환 + */ + private String formatToUtc(LocalDateTime localDateTime, DateTimeFormatter formatter) { + if (localDateTime == null) return null; + return localDateTime.atZone(ZoneId.of("Asia/Seoul")) + .withZoneSameInstant(ZoneOffset.UTC) + .format(formatter); + } +} diff --git a/src/main/java/com/snp/batch/service/BatchFailedRecordService.java b/src/main/java/com/snp/batch/service/BatchFailedRecordService.java new file mode 100644 index 0000000..27e1ed6 --- /dev/null +++ b/src/main/java/com/snp/batch/service/BatchFailedRecordService.java @@ -0,0 +1,159 @@ +package com.snp.batch.service; + +import com.snp.batch.global.model.BatchFailedRecord; +import com.snp.batch.global.repository.BatchFailedRecordRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Service +@RequiredArgsConstructor +@Slf4j +public class BatchFailedRecordService { + + private final BatchFailedRecordRepository batchFailedRecordRepository; + + /** + * 실패 레코드를 비동기로 저장/업데이트합니다. + * 동일 (jobName, recordKey)에 FAILED 레코드가 이미 존재하면 실행 정보만 갱신합니다. + */ + @Async("apiLogExecutor") + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void saveFailedRecords( + String jobName, + Long jobExecutionId, + Long stepExecutionId, + List failedRecordKeys, + boolean isRetryMode, + String errorMessage + ) { + doSaveOrUpdateFailedRecords(jobName, jobExecutionId, stepExecutionId, + failedRecordKeys, isRetryMode, errorMessage); + } + + /** + * 실패 레코드를 동기적으로 저장/업데이트합니다. + * 자동 재수집 트리거 전에 실패 레코드가 반드시 커밋되어야 하는 경우 사용합니다. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void saveFailedRecordsSync( + String jobName, + Long jobExecutionId, + Long stepExecutionId, + List failedRecordKeys, + boolean isRetryMode, + String errorMessage + ) { + doSaveOrUpdateFailedRecords(jobName, jobExecutionId, stepExecutionId, + failedRecordKeys, isRetryMode, errorMessage); + } + + private void doSaveOrUpdateFailedRecords( + String jobName, + Long jobExecutionId, + Long stepExecutionId, + List failedRecordKeys, + boolean isRetryMode, + String errorMessage + ) { + try { + // 이미 FAILED 상태인 기존 레코드 키 조회 + Set existingKeys = new HashSet<>( + batchFailedRecordRepository.findExistingFailedKeys(jobName, failedRecordKeys)); + + // 기존 레코드 업데이트 (실행 정보 갱신) + List keysToUpdate = failedRecordKeys.stream() + .filter(existingKeys::contains) + .toList(); + if (!keysToUpdate.isEmpty()) { + int updated; + if (isRetryMode) { + updated = batchFailedRecordRepository.incrementRetryAndUpdateFailedRecords( + jobName, keysToUpdate, jobExecutionId, stepExecutionId, errorMessage); + } else { + updated = batchFailedRecordRepository.updateFailedRecordExecutions( + jobName, keysToUpdate, jobExecutionId, stepExecutionId, errorMessage); + } + log.info("실패 레코드 {} 건 업데이트 완료 (job: {}, retryMode: {})", + updated, jobName, isRetryMode); + } + + // 신규 레코드 INSERT + List newKeys = failedRecordKeys.stream() + .filter(key -> !existingKeys.contains(key)) + .toList(); + if (!newKeys.isEmpty()) { + int initialRetryCount = isRetryMode ? 1 : 0; + List newRecords = newKeys.stream() + .map(recordKey -> BatchFailedRecord.builder() + .jobName(jobName) + .jobExecutionId(jobExecutionId) + .stepExecutionId(stepExecutionId) + .recordKey(recordKey) + .errorMessage(errorMessage) + .retryCount(initialRetryCount) + .status("FAILED") + .build()) + .toList(); + batchFailedRecordRepository.saveAll(newRecords); + log.info("실패 레코드 {} 건 신규 저장 완료 (job: {}, retryCount: {})", + newRecords.size(), jobName, initialRetryCount); + } + } catch (Exception e) { + log.error("실패 레코드 저장/업데이트 실패: {}", e.getMessage(), e); + } + } + + /** + * ID 목록으로 FAILED 상태 실패 레코드를 일괄 RESOLVED 처리합니다. + */ + @Transactional + public int resolveByIds(List ids) { + if (ids == null || ids.isEmpty()) { + return 0; + } + int resolved = batchFailedRecordRepository.resolveByIds(ids, LocalDateTime.now()); + log.info("실패 레코드 일괄 RESOLVED: {} 건", resolved); + return resolved; + } + + /** + * FAILED 상태 레코드의 retryCount를 0으로 초기화합니다. + * 재시도 횟수를 초과한 레코드를 다시 자동 재수집 대상으로 만듭니다. + */ + @Transactional + public int resetRetryCount(List ids) { + if (ids == null || ids.isEmpty()) { + return 0; + } + int reset = batchFailedRecordRepository.resetRetryCount(ids); + log.info("실패 레코드 retryCount 초기화: {} 건", reset); + return reset; + } + + /** + * 재수집 성공 건을 RESOLVED로 처리합니다. + * 원본 jobExecutionId로 범위를 제한하여 해당 Job의 실패 건만 RESOLVED 처리합니다. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void resolveSuccessfulRetries(String jobName, Long sourceJobExecutionId, + List allRetryKeys, List failedAgainKeys) { + List successfulKeys = allRetryKeys.stream() + .filter(key -> !failedAgainKeys.contains(key)) + .toList(); + if (!successfulKeys.isEmpty()) { + int resolved = batchFailedRecordRepository.resolveByJobExecutionIdAndRecordKeys( + jobName, sourceJobExecutionId, successfulKeys, LocalDateTime.now()); + log.info("실패 레코드 RESOLVED 처리: {} 건 (job: {}, sourceJobExecutionId: {})", + resolved, jobName, sourceJobExecutionId); + } + } +} diff --git a/src/main/java/com/snp/batch/service/BatchService.java b/src/main/java/com/snp/batch/service/BatchService.java new file mode 100644 index 0000000..1aa27ff --- /dev/null +++ b/src/main/java/com/snp/batch/service/BatchService.java @@ -0,0 +1,1041 @@ +package com.snp.batch.service; + +import com.snp.batch.common.batch.listener.RecollectionJobExecutionListener; +import com.snp.batch.global.dto.*; +import com.snp.batch.global.model.BatchApiLog; +import com.snp.batch.global.model.BatchFailedRecord; +import com.snp.batch.global.model.BatchLastExecution; +import com.snp.batch.global.model.JobDisplayNameEntity; +import com.snp.batch.global.repository.BatchApiLogRepository; +import com.snp.batch.global.repository.JobDisplayNameRepository; +import com.snp.batch.global.repository.BatchFailedRecordRepository; +import com.snp.batch.global.repository.BatchLastExecutionRepository; +import com.snp.batch.global.repository.TimelineRepository; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobInstance; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.explore.JobExplorer; +import org.springframework.batch.core.job.AbstractJob; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.batch.core.launch.JobOperator; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +public class BatchService { + + /** DB에서 로드한 Job 한글 표시명 캐시 */ + private Map jobDisplayNameCache = Map.of(); + + /** DB에서 로드한 apiKey → 한글 표시명 캐시 */ + private Map apiKeyDisplayNameCache = Map.of(); + + private final JobLauncher jobLauncher; + private final JobExplorer jobExplorer; + private final JobOperator jobOperator; + private final Map jobMap; + private final ScheduleService scheduleService; + private final TimelineRepository timelineRepository; + private final RecollectionJobExecutionListener recollectionJobExecutionListener; + private final BatchApiLogRepository apiLogRepository; + private final BatchFailedRecordRepository failedRecordRepository; + private final BatchLastExecutionRepository batchLastExecutionRepository; + private final JobDisplayNameRepository jobDisplayNameRepository; + + @Autowired + public BatchService(JobLauncher jobLauncher, + JobExplorer jobExplorer, + JobOperator jobOperator, + Map jobMap, + @Lazy ScheduleService scheduleService, + TimelineRepository timelineRepository, + RecollectionJobExecutionListener recollectionJobExecutionListener, + BatchApiLogRepository apiLogRepository, + BatchFailedRecordRepository failedRecordRepository, + BatchLastExecutionRepository batchLastExecutionRepository, + JobDisplayNameRepository jobDisplayNameRepository) { + this.jobLauncher = jobLauncher; + this.jobExplorer = jobExplorer; + this.jobOperator = jobOperator; + this.jobMap = jobMap; + this.scheduleService = scheduleService; + this.timelineRepository = timelineRepository; + this.recollectionJobExecutionListener = recollectionJobExecutionListener; + this.apiLogRepository = apiLogRepository; + this.failedRecordRepository = failedRecordRepository; + this.batchLastExecutionRepository = batchLastExecutionRepository; + this.jobDisplayNameRepository = jobDisplayNameRepository; + } + + /** + * 모든 Job에 RecollectionJobExecutionListener를 등록 + * 리스너 내부에서 executionMode 체크하므로 정상 실행에는 영향 없음 + */ + @PostConstruct + public void init() { + // 리스너 등록 + jobMap.values().forEach(job -> { + if (job instanceof AbstractJob abstractJob) { + abstractJob.registerJobExecutionListener(recollectionJobExecutionListener); + } + }); + log.info("[BatchService] RecollectionJobExecutionListener를 {}개 Job에 등록", jobMap.size()); + + // Job 한글 표시명 초기 데이터 시드 (테이블이 비어있을 때만) + seedDisplayNamesIfEmpty(); + + // Job 한글 표시명 캐시 로드 + refreshDisplayNameCache(); + } + + /** + * job_display_name 테이블이 비어있으면 기본 표시명을 시드합니다. + */ + private void seedDisplayNamesIfEmpty() { + if (jobDisplayNameRepository.count() > 0) { + return; + } + List entities = List.of( + buildDisplayName("aisTargetImportJob", "AIS 선박위치 수집(1분 주기)", null), + buildDisplayName("aisTargetDbSyncJob", "AIS 선박위치 DB 적재(15분 주기)", null), + buildDisplayName("FlagCodeImportJob", "선박 국가코드 수집", null), + buildDisplayName("Stat5CodeImportJob", "선박 유형코드 수집", null), + buildDisplayName("ComplianceImportRangeJob", "선박 제재 정보 수집", "COMPLIANCE_IMPORT_API"), + buildDisplayName("CompanyComplianceImportRangeJob", "회사 제재 정보 수집", "COMPANY_COMPLIANCE_IMPORT_API"), + buildDisplayName("EventImportJob", "해양 사건/사고 수집", "EVENT_IMPORT_API"), + buildDisplayName("PortImportJob", "항구 시설 수집", null), + buildDisplayName("AnchorageCallsRangeImportJob", "정박지 기항 이력 수집", "ANCHORAGE_CALLS_IMPORT_API"), + buildDisplayName("BerthCallsRangeImportJob", "선석 기항 이력 수집", "BERTH_CALLS_IMPORT_API"), + buildDisplayName("CurrentlyAtRangeImportJob", "현재 위치 이력 수집", "CURRENTLY_AT_IMPORT_API"), + buildDisplayName("DestinationsRangeImportJob", "목적지 이력 수집", "DESTINATIONS_IMPORT_API"), + buildDisplayName("PortCallsRangeImportJob", "항구 기항 이력 수집", "PORT_CALLS_IMPORT_API"), + buildDisplayName("STSOperationRangeImportJob", "STS 작업 이력 수집", "STS_OPERATION_IMPORT_API"), + buildDisplayName("TerminalCallsRangeImportJob", "터미널 기항 이력 수집", "TERMINAL_CALLS_IMPORT_API"), + buildDisplayName("TransitsRangeImportJob", "항해 이력 수집", "TRANSITS_IMPORT_API"), + buildDisplayName("PSCDetailImportJob", "PSC 선박 검사 수집", "PSC_IMPORT_API"), + buildDisplayName("RiskRangeImportJob", "선박 위험지표 수집", "RISK_IMPORT_API"), + buildDisplayName("ShipDetailUpdateJob", "선박 제원정보 수집", "SHIP_DETAIL_UPDATE_API"), + buildDisplayName("partitionManagerJob", "AIS 파티션 테이블 생성/관리", null) + ); + jobDisplayNameRepository.saveAll(entities); + log.info("[BatchService] Job 표시명 초기 데이터 시드: {} 건", entities.size()); + } + + private JobDisplayNameEntity buildDisplayName(String jobName, String displayName, String apiKey) { + return JobDisplayNameEntity.builder() + .jobName(jobName) + .displayName(displayName) + .apiKey(apiKey) + .build(); + } + + /** + * DB에서 Job 한글 표시명을 로드하여 캐시를 갱신합니다. + */ + public void refreshDisplayNameCache() { + List all = jobDisplayNameRepository.findAll(); + jobDisplayNameCache = all.stream() + .collect(Collectors.toMap( + JobDisplayNameEntity::getJobName, + JobDisplayNameEntity::getDisplayName, + (a, b) -> a + )); + apiKeyDisplayNameCache = all.stream() + .filter(e -> e.getApiKey() != null) + .collect(Collectors.toMap( + JobDisplayNameEntity::getApiKey, + JobDisplayNameEntity::getDisplayName, + (a, b) -> a + )); + log.info("[BatchService] Job 표시명 캐시 로드: {} 건 (apiKey: {} 건)", jobDisplayNameCache.size(), apiKeyDisplayNameCache.size()); + } + + /** + * Job의 한글 표시명을 반환합니다. DB에 없으면 null. + */ + public String getDisplayName(String jobName) { + return jobDisplayNameCache.get(jobName); + } + + /** + * apiKey로 한글 표시명을 반환합니다. DB에 없으면 null. + */ + public String getDisplayNameByApiKey(String apiKey) { + return apiKeyDisplayNameCache.get(apiKey); + } + + public Long executeJob(String jobName) throws Exception { + return executeJob(jobName, null); + } + + public Long executeJob(String jobName, Map params) throws Exception { + Job job = jobMap.get(jobName); + if (job == null) { + throw new IllegalArgumentException("Job not found: " + jobName); + } + + JobParametersBuilder builder = new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis()); + + // 동적 파라미터 추가 + if (params != null && !params.isEmpty()) { + params.forEach((key, value) -> { + // timestamp는 자동 생성되므로 무시 + if (!"timestamp".equals(key)) { + builder.addString(key, value); + } + }); + } + + JobParameters jobParameters = builder.toJobParameters(); + JobExecution jobExecution = jobLauncher.run(job, jobParameters); + return jobExecution.getId(); + } + + public List listAllJobs() { + return new ArrayList<>(jobMap.keySet()); + } + + public List getJobExecutions(String jobName) { + List jobInstances = jobExplorer.findJobInstancesByJobName(jobName, 0, 100); + + List executions = jobInstances.stream() + .flatMap(instance -> jobExplorer.getJobExecutions(instance).stream()) + .map(this::convertToDto) + .sorted(Comparator.comparing(JobExecutionDto::getExecutionId).reversed()) + .collect(Collectors.toList()); + + populateFailedRecordCounts(executions); + return executions; + } + + public List getRecentExecutions(int limit) { + List> recentData = timelineRepository.findRecentExecutions(limit); + List executions = recentData.stream() + .map(this::convertMapToDto) + .collect(Collectors.toList()); + + populateFailedRecordCounts(executions); + return executions; + } + + public JobExecutionDto getExecutionDetails(Long executionId) { + JobExecution jobExecution = jobExplorer.getJobExecution(executionId); + if (jobExecution == null) { + throw new IllegalArgumentException("Job execution not found: " + executionId); + } + return convertToDto(jobExecution); + } + + public com.snp.batch.global.dto.JobExecutionDetailDto getExecutionDetailWithSteps(Long executionId) { + JobExecution jobExecution = jobExplorer.getJobExecution(executionId); + if (jobExecution == null) { + throw new IllegalArgumentException("Job execution not found: " + executionId); + } + return convertToDetailDto(jobExecution); + } + + public void stopExecution(Long executionId) throws Exception { + jobOperator.stop(executionId); + } + + + private JobExecutionDto convertToDto(JobExecution jobExecution) { + return JobExecutionDto.builder() + .executionId(jobExecution.getId()) + .jobName(jobExecution.getJobInstance().getJobName()) + .status(jobExecution.getStatus().name()) + .startTime(jobExecution.getStartTime()) + .endTime(jobExecution.getEndTime()) + .exitCode(jobExecution.getExitStatus().getExitCode()) + .exitMessage(jobExecution.getExitStatus().getExitDescription()) + .build(); + } + + private com.snp.batch.global.dto.JobExecutionDetailDto convertToDetailDto(JobExecution jobExecution) { + // 실행 시간 계산 + Long duration = null; + if (jobExecution.getStartTime() != null && jobExecution.getEndTime() != null) { + duration = java.time.Duration.between( + jobExecution.getStartTime(), + jobExecution.getEndTime() + ).toMillis(); + } + + // Job Parameters 변환 (timestamp는 포맷팅) + Map params = new java.util.LinkedHashMap<>(); + jobExecution.getJobParameters().getParameters().forEach((key, value) -> { + Object paramValue = value.getValue(); + + // timestamp 파라미터는 포맷팅된 문자열도 함께 표시 + if ("timestamp".equals(key) && paramValue instanceof Long) { + Long timestamp = (Long) paramValue; + java.time.LocalDateTime dateTime = java.time.LocalDateTime.ofInstant( + java.time.Instant.ofEpochMilli(timestamp), + java.time.ZoneId.systemDefault() + ); + String formatted = dateTime.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + params.put(key, timestamp + " (" + formatted + ")"); + } else { + params.put(key, paramValue); + } + }); + + // Step Executions 변환 + List stepDtos = + jobExecution.getStepExecutions().stream() + .map(this::convertStepToDto) + .collect(Collectors.toList()); + + // 전체 통계 계산 + int totalReadCount = stepDtos.stream().mapToInt(s -> s.getReadCount() != null ? s.getReadCount() : 0).sum(); + int totalWriteCount = stepDtos.stream().mapToInt(s -> s.getWriteCount() != null ? s.getWriteCount() : 0).sum(); + int totalSkipCount = stepDtos.stream().mapToInt(s -> + (s.getReadSkipCount() != null ? s.getReadSkipCount() : 0) + + (s.getProcessSkipCount() != null ? s.getProcessSkipCount() : 0) + + (s.getWriteSkipCount() != null ? s.getWriteSkipCount() : 0) + ).sum(); + int totalFilterCount = stepDtos.stream().mapToInt(s -> s.getFilterCount() != null ? s.getFilterCount() : 0).sum(); + + return com.snp.batch.global.dto.JobExecutionDetailDto.builder() + .executionId(jobExecution.getId()) + .jobName(jobExecution.getJobInstance().getJobName()) + .status(jobExecution.getStatus().name()) + .startTime(jobExecution.getStartTime()) + .endTime(jobExecution.getEndTime()) + .exitCode(jobExecution.getExitStatus().getExitCode()) + .exitMessage(jobExecution.getExitStatus().getExitDescription()) + .jobParameters(params) + .jobInstanceId(jobExecution.getJobInstance().getInstanceId()) + .duration(duration) + .readCount(totalReadCount) + .writeCount(totalWriteCount) + .skipCount(totalSkipCount) + .filterCount(totalFilterCount) + .stepExecutions(stepDtos) + .build(); + } + + private com.snp.batch.global.dto.JobExecutionDetailDto.StepExecutionDto convertStepToDto( + org.springframework.batch.core.StepExecution stepExecution) { + + Long duration = null; + if (stepExecution.getStartTime() != null && stepExecution.getEndTime() != null) { + duration = java.time.Duration.between( + stepExecution.getStartTime(), + stepExecution.getEndTime() + ).toMillis(); + } + + // StepExecutionContext에서 API 정보 추출 + com.snp.batch.global.dto.JobExecutionDetailDto.ApiCallInfo apiCallInfo = extractApiCallInfo(stepExecution); + + // batch_api_log 테이블에서 Step별 API 로그 집계 + 개별 로그 조회 + com.snp.batch.global.dto.JobExecutionDetailDto.StepApiLogSummary apiLogSummary = + buildStepApiLogSummary(stepExecution.getId()); + + // Step별 실패 레코드 조회 + List failedRecordDtos = + failedRecordRepository.findByStepExecutionId(stepExecution.getId()).stream() + .map(record -> JobExecutionDetailDto.FailedRecordDto.builder() + .id(record.getId()) + .jobName(record.getJobName()) + .recordKey(record.getRecordKey()) + .errorMessage(record.getErrorMessage()) + .retryCount(record.getRetryCount()) + .status(record.getStatus()) + .createdAt(record.getCreatedAt()) + .build()) + .collect(Collectors.toList()); + + return com.snp.batch.global.dto.JobExecutionDetailDto.StepExecutionDto.builder() + .stepExecutionId(stepExecution.getId()) + .stepName(stepExecution.getStepName()) + .status(stepExecution.getStatus().name()) + .startTime(stepExecution.getStartTime()) + .endTime(stepExecution.getEndTime()) + .readCount((int) stepExecution.getReadCount()) + .writeCount((int) stepExecution.getWriteCount()) + .commitCount((int) stepExecution.getCommitCount()) + .rollbackCount((int) stepExecution.getRollbackCount()) + .readSkipCount((int) stepExecution.getReadSkipCount()) + .processSkipCount((int) stepExecution.getProcessSkipCount()) + .writeSkipCount((int) stepExecution.getWriteSkipCount()) + .filterCount((int) stepExecution.getFilterCount()) + .exitCode(stepExecution.getExitStatus().getExitCode()) + .exitMessage(stepExecution.getExitStatus().getExitDescription()) + .duration(duration) + .apiCallInfo(apiCallInfo) + .apiLogSummary(apiLogSummary) + .failedRecords(failedRecordDtos.isEmpty() ? null : failedRecordDtos) + .build(); + } + + /** + * StepExecutionContext에서 API 호출 정보 추출 + * + * @param stepExecution Step 실행 정보 + * @return API 호출 정보 (없으면 null) + */ + private com.snp.batch.global.dto.JobExecutionDetailDto.ApiCallInfo extractApiCallInfo( + org.springframework.batch.core.StepExecution stepExecution) { + + org.springframework.batch.item.ExecutionContext context = stepExecution.getExecutionContext(); + + // API URL이 없으면 API를 사용하지 않는 Step + if (!context.containsKey("apiUrl")) { + return null; + } + + // API 정보 추출 + String apiUrl = context.getString("apiUrl"); + String method = context.getString("apiMethod", "GET"); + Integer totalCalls = context.getInt("totalApiCalls", 0); + Integer completedCalls = context.getInt("completedApiCalls", 0); + String lastCallTime = context.getString("lastCallTime", ""); + + // API Parameters 추출 + Map parameters = null; + if (context.containsKey("apiParameters")) { + Object paramsObj = context.get("apiParameters"); + if (paramsObj instanceof Map) { + parameters = (Map) paramsObj; + } + } + + return com.snp.batch.global.dto.JobExecutionDetailDto.ApiCallInfo.builder() + .apiUrl(apiUrl) + .method(method) + .parameters(parameters) + .totalCalls(totalCalls) + .completedCalls(completedCalls) + .lastCallTime(lastCallTime) + .build(); + } + + /** + * Step별 batch_api_log 통계 집계 (개별 로그는 별도 API로 페이징 조회) + */ + private com.snp.batch.global.dto.JobExecutionDetailDto.StepApiLogSummary buildStepApiLogSummary(Long stepExecutionId) { + List stats = apiLogRepository.getApiStatsByStepExecutionId(stepExecutionId); + if (stats.isEmpty() || stats.get(0) == null || ((Number) stats.get(0)[0]).longValue() == 0L) { + return null; + } + + Object[] row = stats.get(0); + + return com.snp.batch.global.dto.JobExecutionDetailDto.StepApiLogSummary.builder() + .totalCalls(((Number) row[0]).longValue()) + .successCount(((Number) row[1]).longValue()) + .errorCount(((Number) row[2]).longValue()) + .avgResponseMs(((Number) row[3]).doubleValue()) + .maxResponseMs(((Number) row[4]).longValue()) + .minResponseMs(((Number) row[5]).longValue()) + .totalResponseMs(((Number) row[6]).longValue()) + .totalRecordCount(((Number) row[7]).longValue()) + .build(); + } + + /** + * Step별 API 호출 로그 페이징 조회 (상태 필터 지원) + * + * @param stepExecutionId Step 실행 ID + * @param status 필터: ALL(전체), SUCCESS(2xx), ERROR(4xx+/에러) + * @param pageable 페이징 정보 + */ + @Transactional(readOnly = true) + public JobExecutionDetailDto.ApiLogPageResponse getStepApiLogs(Long stepExecutionId, String status, Pageable pageable) { + Page page = switch (status) { + case "SUCCESS" -> apiLogRepository.findSuccessByStepExecutionId(stepExecutionId, pageable); + case "ERROR" -> apiLogRepository.findErrorByStepExecutionId(stepExecutionId, pageable); + default -> apiLogRepository.findByStepExecutionIdOrderByCreatedAtAsc(stepExecutionId, pageable); + }; + + List content = page.getContent().stream() + .map(apiLog -> JobExecutionDetailDto.ApiLogEntryDto.builder() + .logId(apiLog.getLogId()) + .requestUri(apiLog.getRequestUri()) + .httpMethod(apiLog.getHttpMethod()) + .statusCode(apiLog.getStatusCode()) + .responseTimeMs(apiLog.getResponseTimeMs()) + .responseCount(apiLog.getResponseCount()) + .errorMessage(apiLog.getErrorMessage()) + .createdAt(apiLog.getCreatedAt()) + .build()) + .toList(); + + return JobExecutionDetailDto.ApiLogPageResponse.builder() + .content(content) + .page(page.getNumber()) + .size(page.getSize()) + .totalElements(page.getTotalElements()) + .totalPages(page.getTotalPages()) + .build(); + } + + public com.snp.batch.global.dto.TimelineResponse getTimeline(String view, String dateStr) { + try { + java.time.LocalDate date = java.time.LocalDate.parse(dateStr.substring(0, 10)); + java.util.List periods = new ArrayList<>(); + String periodLabel = ""; + + // 조회 범위 설정 + java.time.LocalDateTime rangeStart; + java.time.LocalDateTime rangeEnd; + + if ("day".equals(view)) { + // 일별: 24시간 + periodLabel = date.format(java.time.format.DateTimeFormatter.ofPattern("yyyy년 MM월 dd일")); + rangeStart = date.atStartOfDay(); + rangeEnd = rangeStart.plusDays(1); + + for (int hour = 0; hour < 24; hour++) { + periods.add(com.snp.batch.global.dto.TimelineResponse.PeriodInfo.builder() + .key(date.toString() + "-" + String.format("%02d", hour)) + .label(String.format("%02d:00", hour)) + .build()); + } + } else if ("week".equals(view)) { + // 주별: 7일 + java.time.LocalDate startOfWeek = date.with(java.time.DayOfWeek.MONDAY); + java.time.LocalDate endOfWeek = startOfWeek.plusDays(6); + periodLabel = String.format("%s ~ %s", + startOfWeek.format(java.time.format.DateTimeFormatter.ofPattern("MM/dd")), + endOfWeek.format(java.time.format.DateTimeFormatter.ofPattern("MM/dd"))); + + rangeStart = startOfWeek.atStartOfDay(); + rangeEnd = endOfWeek.plusDays(1).atStartOfDay(); + + for (int day = 0; day < 7; day++) { + java.time.LocalDate current = startOfWeek.plusDays(day); + periods.add(com.snp.batch.global.dto.TimelineResponse.PeriodInfo.builder() + .key(current.toString()) + .label(current.format(java.time.format.DateTimeFormatter.ofPattern("MM/dd (E)", java.util.Locale.KOREAN))) + .build()); + } + } else if ("month".equals(view)) { + // 월별: 해당 월의 모든 날 + java.time.YearMonth yearMonth = java.time.YearMonth.from(date); + periodLabel = date.format(java.time.format.DateTimeFormatter.ofPattern("yyyy년 MM월")); + + rangeStart = yearMonth.atDay(1).atStartOfDay(); + rangeEnd = yearMonth.atEndOfMonth().plusDays(1).atStartOfDay(); + + for (int day = 1; day <= yearMonth.lengthOfMonth(); day++) { + java.time.LocalDate current = yearMonth.atDay(day); + periods.add(com.snp.batch.global.dto.TimelineResponse.PeriodInfo.builder() + .key(current.toString()) + .label(String.format("%d일", day)) + .build()); + } + } else { + throw new IllegalArgumentException("Invalid view type: " + view); + } + + // 활성 스케줄 조회 + java.util.List activeSchedules = scheduleService.getAllActiveSchedules(); + Map scheduleMap = activeSchedules.stream() + .collect(Collectors.toMap( + com.snp.batch.global.dto.ScheduleResponse::getJobName, + s -> s + )); + + // 모든 Job의 실행 이력을 한 번의 쿼리로 조회 (경량화) + List> allExecutions = timelineRepository.findAllExecutionsByDateRange(rangeStart, rangeEnd); + + // Job별로 그룹화 + Map>> executionsByJob = allExecutions.stream() + .collect(Collectors.groupingBy(exec -> (String) exec.get("jobName"))); + + // 타임라인 스케줄 구성 + java.util.List schedules = new ArrayList<>(); + + // 실행 이력이 있거나 스케줄이 있는 모든 Job 처리 + Set allJobNames = new HashSet<>(executionsByJob.keySet()); + allJobNames.addAll(scheduleMap.keySet()); + + for (String jobName : allJobNames) { + if (!jobMap.containsKey(jobName)) { + continue; // 현재 존재하지 않는 Job은 스킵 + } + + List> jobExecutions = executionsByJob.getOrDefault(jobName, Collections.emptyList()); + Map executions = new HashMap<>(); + + // 각 period에 대해 실행 이력 또는 예정 상태 매핑 + for (com.snp.batch.global.dto.TimelineResponse.PeriodInfo period : periods) { + Map matchedExecution = findExecutionForPeriodFromMap(jobExecutions, period, view); + + if (matchedExecution != null) { + // 과거 실행 이력이 있는 경우 + java.sql.Timestamp startTimestamp = (java.sql.Timestamp) matchedExecution.get("startTime"); + java.sql.Timestamp endTimestamp = (java.sql.Timestamp) matchedExecution.get("endTime"); + + executions.put(period.getKey(), com.snp.batch.global.dto.TimelineResponse.ExecutionInfo.builder() + .executionId(((Number) matchedExecution.get("executionId")).longValue()) + .status((String) matchedExecution.get("status")) + .startTime(startTimestamp != null ? startTimestamp.toLocalDateTime().toString() : null) + .endTime(endTimestamp != null ? endTimestamp.toLocalDateTime().toString() : null) + .build()); + } else if (scheduleMap.containsKey(jobName)) { + // 스케줄이 있고, 실행 이력이 없는 경우 - 미래 예정 시간 체크 + com.snp.batch.global.dto.ScheduleResponse schedule = scheduleMap.get(jobName); + if (isScheduledForPeriod(schedule, period, view)) { + executions.put(period.getKey(), com.snp.batch.global.dto.TimelineResponse.ExecutionInfo.builder() + .status("SCHEDULED") + .startTime(null) + .endTime(null) + .build()); + } + } + } + + if (!executions.isEmpty()) { + schedules.add(com.snp.batch.global.dto.TimelineResponse.ScheduleTimeline.builder() + .jobName(jobName) + .executions(executions) + .build()); + } + } + + return com.snp.batch.global.dto.TimelineResponse.builder() + .periodLabel(periodLabel) + .periods(periods) + .schedules(schedules) + .build(); + + } catch (Exception e) { + log.error("Error generating timeline", e); + throw new RuntimeException("Failed to generate timeline", e); + } + } + + /** + * Map 기반 실행 이력에서 특정 Period에 해당하는 실행 찾기 + */ + private Map findExecutionForPeriodFromMap( + List> executions, + com.snp.batch.global.dto.TimelineResponse.PeriodInfo period, + String view) { + + return executions.stream() + .filter(exec -> exec.get("startTime") != null) + .filter(exec -> { + java.sql.Timestamp timestamp = (java.sql.Timestamp) exec.get("startTime"); + java.time.LocalDateTime startTime = timestamp.toLocalDateTime(); + String periodKey = period.getKey(); + + if ("day".equals(view)) { + // 시간별 매칭 (key format: "2025-10-14-00") + int lastDashIndex = periodKey.lastIndexOf('-'); + String dateStr = periodKey.substring(0, lastDashIndex); + int hour = Integer.parseInt(periodKey.substring(lastDashIndex + 1)); + + java.time.LocalDate periodDate = java.time.LocalDate.parse(dateStr); + + return startTime.toLocalDate().equals(periodDate) && + startTime.getHour() == hour; + } else { + // 일별 매칭 + java.time.LocalDate periodDate = java.time.LocalDate.parse(periodKey); + return startTime.toLocalDate().equals(periodDate); + } + }) + .max(Comparator.comparing(exec -> ((java.sql.Timestamp) exec.get("startTime")).toLocalDateTime())) + .orElse(null); + } + + private boolean isJobScheduled(String jobName) { + // 스케줄이 있는지 확인 + try { + scheduleService.getScheduleByJobName(jobName); + return true; + } catch (Exception e) { + return false; + } + } + + private boolean isScheduledForPeriod(com.snp.batch.global.dto.ScheduleResponse schedule, + com.snp.batch.global.dto.TimelineResponse.PeriodInfo period, + String view) { + if (schedule.getNextFireTime() == null) { + return false; + } + + java.time.LocalDateTime nextFireTime = schedule.getNextFireTime() + .toInstant() + .atZone(java.time.ZoneId.systemDefault()) + .toLocalDateTime(); + + String periodKey = period.getKey(); + + if ("day".equals(view)) { + // 시간별 매칭 (key format: "2025-10-14-00") + int lastDashIndex = periodKey.lastIndexOf('-'); + String dateStr = periodKey.substring(0, lastDashIndex); + int hour = Integer.parseInt(periodKey.substring(lastDashIndex + 1)); + + java.time.LocalDate periodDate = java.time.LocalDate.parse(dateStr); + java.time.LocalDateTime periodStart = periodDate.atTime(hour, 0); + java.time.LocalDateTime periodEnd = periodStart.plusHours(1); + + return !nextFireTime.isBefore(periodStart) && nextFireTime.isBefore(periodEnd); + } else { + // 일별 매칭 + java.time.LocalDate periodDate = java.time.LocalDate.parse(periodKey); + java.time.LocalDateTime periodStart = periodDate.atStartOfDay(); + java.time.LocalDateTime periodEnd = periodStart.plusDays(1); + + return !nextFireTime.isBefore(periodStart) && nextFireTime.isBefore(periodEnd); + } + } + + public List getPeriodExecutions(String jobName, String view, String periodKey) { + List jobInstances = jobExplorer.findJobInstancesByJobName(jobName, 0, 1000); + + return jobInstances.stream() + .flatMap(instance -> jobExplorer.getJobExecutions(instance).stream()) + .filter(exec -> exec.getStartTime() != null) + .filter(exec -> matchesPeriod(exec, view, periodKey)) + .sorted(Comparator.comparing(JobExecution::getStartTime).reversed()) + .map(this::convertToDto) + .collect(Collectors.toList()); + } + + private boolean matchesPeriod(JobExecution execution, String view, String periodKey) { + java.time.LocalDateTime startTime = execution.getStartTime(); + + if ("day".equals(view)) { + // 시간별 매칭 (key format: "2025-10-14-00") + int lastDashIndex = periodKey.lastIndexOf('-'); + String dateStr = periodKey.substring(0, lastDashIndex); + int hour = Integer.parseInt(periodKey.substring(lastDashIndex + 1)); + + java.time.LocalDate periodDate = java.time.LocalDate.parse(dateStr); + + return startTime.toLocalDate().equals(periodDate) && + startTime.getHour() == hour; + } else { + // 일별 매칭 + java.time.LocalDate periodDate = java.time.LocalDate.parse(periodKey); + return startTime.toLocalDate().equals(periodDate); + } + } + + /** + * 대시보드 데이터 조회 (한 번의 호출로 모든 데이터 반환) + */ + public com.snp.batch.global.dto.DashboardResponse getDashboardData() { + // 1. 스케줄 통계 + java.util.List allSchedules = scheduleService.getAllSchedules(); + int totalSchedules = allSchedules.size(); + int activeSchedules = (int) allSchedules.stream().filter(com.snp.batch.global.dto.ScheduleResponse::getActive).count(); + int inactiveSchedules = totalSchedules - activeSchedules; + int totalJobs = jobMap.size(); + + com.snp.batch.global.dto.DashboardResponse.Stats stats = com.snp.batch.global.dto.DashboardResponse.Stats.builder() + .totalSchedules(totalSchedules) + .activeSchedules(activeSchedules) + .inactiveSchedules(inactiveSchedules) + .totalJobs(totalJobs) + .build(); + + // 2. 실행 중인 Job (한 번의 쿼리) + List> runningData = timelineRepository.findRunningExecutions(); + List runningJobs = runningData.stream() + .map(data -> { + java.sql.Timestamp startTimestamp = (java.sql.Timestamp) data.get("startTime"); + return com.snp.batch.global.dto.DashboardResponse.RunningJob.builder() + .jobName((String) data.get("jobName")) + .executionId(((Number) data.get("executionId")).longValue()) + .status((String) data.get("status")) + .startTime(startTimestamp != null ? startTimestamp.toLocalDateTime() : null) + .build(); + }) + .collect(Collectors.toList()); + + // 3. 최근 실행 이력 (한 번의 쿼리로 상위 10개) + List> recentData = timelineRepository.findRecentExecutions(10); + List recentExecutions = recentData.stream() + .map(data -> { + java.sql.Timestamp startTimestamp = (java.sql.Timestamp) data.get("startTime"); + java.sql.Timestamp endTimestamp = (java.sql.Timestamp) data.get("endTime"); + return com.snp.batch.global.dto.DashboardResponse.RecentExecution.builder() + .executionId(((Number) data.get("executionId")).longValue()) + .jobName((String) data.get("jobName")) + .status((String) data.get("status")) + .startTime(startTimestamp != null ? startTimestamp.toLocalDateTime() : null) + .endTime(endTimestamp != null ? endTimestamp.toLocalDateTime() : null) + .build(); + }) + .collect(Collectors.toList()); + + // 4. 최근 실패 이력 (24시간 이내, 최대 10건) + List> failureData = timelineRepository.findRecentFailures(24); + List recentFailures = failureData.stream() + .map(data -> { + java.sql.Timestamp startTs = (java.sql.Timestamp) data.get("startTime"); + java.sql.Timestamp endTs = (java.sql.Timestamp) data.get("endTime"); + return DashboardResponse.RecentFailure.builder() + .executionId(((Number) data.get("executionId")).longValue()) + .jobName((String) data.get("jobName")) + .status((String) data.get("status")) + .startTime(startTs != null ? startTs.toLocalDateTime() : null) + .endTime(endTs != null ? endTs.toLocalDateTime() : null) + .exitMessage((String) data.get("exitMessage")) + .build(); + }) + .collect(Collectors.toList()); + + // 5. 오래된 실행 중 건수 + int staleExecutionCount = timelineRepository.countStaleExecutions(60); + + // 6. 실패 통계 + int last24h = timelineRepository.countFailuresSince(LocalDateTime.now().minusHours(24)); + int last7d = timelineRepository.countFailuresSince(LocalDateTime.now().minusDays(7)); + DashboardResponse.FailureStats failureStats = DashboardResponse.FailureStats.builder() + .last24h(last24h) + .last7d(last7d) + .build(); + + return DashboardResponse.builder() + .stats(stats) + .runningJobs(runningJobs) + .recentExecutions(recentExecutions) + .recentFailures(recentFailures) + .staleExecutionCount(staleExecutionCount) + .failureStats(failureStats) + .build(); + } + + // ── 마지막 수집 성공일시 모니터링 ───────────────────────────── + + /** + * 전체 API의 마지막 수집 성공일시를 조회합니다. + * lastSuccessDate 오름차순 정렬 (오래된 것이 위로 → 모니터링 편의) + */ + @Transactional(readOnly = true) + public List getLastCollectionStatuses() { + LocalDateTime now = LocalDateTime.now(); + return batchLastExecutionRepository.findAll().stream() + .sorted(Comparator.comparing( + BatchLastExecution::getLastSuccessDate, + Comparator.nullsFirst(Comparator.naturalOrder()))) + .map(entity -> new LastCollectionStatusResponse( + entity.getApiKey(), + entity.getApiDesc(), + entity.getLastSuccessDate(), + entity.getUpdatedAt(), + entity.getLastSuccessDate() != null + ? ChronoUnit.MINUTES.between(entity.getLastSuccessDate(), now) + : -1 + )) + .toList(); + } + + // ── F1: 강제 종료(Abandon) 관련 ────────────────────────────── + + public List getStaleExecutions(int thresholdMinutes) { + List> data = timelineRepository.findStaleExecutions(thresholdMinutes); + return data.stream() + .map(this::convertMapToDto) + .collect(Collectors.toList()); + } + + @Transactional + public void abandonExecution(long executionId) { + int stepCount = timelineRepository.abandonStepExecutions(executionId); + int jobCount = timelineRepository.abandonJobExecution(executionId); + log.info("Abandoned execution {}: job={}, steps={}", executionId, jobCount, stepCount); + if (jobCount == 0) { + throw new IllegalArgumentException("실행 중 상태가 아니거나 존재하지 않는 executionId: " + executionId); + } + } + + @Transactional + public int abandonAllStaleExecutions(int thresholdMinutes) { + List> staleExecutions = timelineRepository.findStaleExecutions(thresholdMinutes); + int abandonedCount = 0; + for (Map exec : staleExecutions) { + long executionId = ((Number) exec.get("executionId")).longValue(); + timelineRepository.abandonStepExecutions(executionId); + int updated = timelineRepository.abandonJobExecution(executionId); + abandonedCount += updated; + } + log.info("Abandoned {} stale executions (threshold: {} minutes)", abandonedCount, thresholdMinutes); + return abandonedCount; + } + + // ── F4: 실행 이력 검색 (페이지네이션) ───────────────────────── + + public ExecutionSearchResponse searchExecutions( + List jobNames, String status, + LocalDateTime startDate, LocalDateTime endDate, + int page, int size) { + + int offset = page * size; + List> data = timelineRepository.searchExecutions( + jobNames, status, startDate, endDate, offset, size); + int totalCount = timelineRepository.countExecutions(jobNames, status, startDate, endDate); + + List executions = data.stream() + .map(this::convertMapToDto) + .collect(Collectors.toList()); + + populateFailedRecordCounts(executions); + + return ExecutionSearchResponse.builder() + .executions(executions) + .totalCount(totalCount) + .page(page) + .size(size) + .totalPages((int) Math.ceil((double) totalCount / size)) + .build(); + } + + // ── F7: Job 상세 목록 ──────────────────────────────────────── + + public List getJobsWithDetail() { + // Job별 최근 실행 정보 + List> lastExecutions = timelineRepository.findLastExecutionPerJob(); + Map> lastExecMap = lastExecutions.stream() + .collect(Collectors.toMap( + data -> (String) data.get("jobName"), + data -> data + )); + + // 스케줄 정보 + List schedules = scheduleService.getAllSchedules(); + Map cronMap = schedules.stream() + .collect(Collectors.toMap( + ScheduleResponse::getJobName, + ScheduleResponse::getCronExpression, + (a, b) -> a + )); + + return jobMap.keySet().stream() + .sorted() + .map(jobName -> { + JobDetailDto.LastExecution lastExec = null; + Map execData = lastExecMap.get(jobName); + if (execData != null) { + java.sql.Timestamp startTs = (java.sql.Timestamp) execData.get("startTime"); + java.sql.Timestamp endTs = (java.sql.Timestamp) execData.get("endTime"); + lastExec = JobDetailDto.LastExecution.builder() + .executionId(((Number) execData.get("executionId")).longValue()) + .status((String) execData.get("status")) + .startTime(startTs != null ? startTs.toLocalDateTime() : null) + .endTime(endTs != null ? endTs.toLocalDateTime() : null) + .build(); + } + + return JobDetailDto.builder() + .jobName(jobName) + .displayName(jobDisplayNameCache.get(jobName)) + .lastExecution(lastExec) + .scheduleCron(cronMap.get(jobName)) + .build(); + }) + .collect(Collectors.toList()); + } + + // ── F8: 실행 통계 ────────────────────────────────────────── + + public ExecutionStatisticsDto getStatistics(int days) { + List> dailyData = timelineRepository.findDailyStatistics(days); + return buildStatisticsDto(dailyData); + } + + public ExecutionStatisticsDto getJobStatistics(String jobName, int days) { + List> dailyData = timelineRepository.findDailyStatisticsForJob(jobName, days); + return buildStatisticsDto(dailyData); + } + + private ExecutionStatisticsDto buildStatisticsDto(List> dailyData) { + List dailyStats = dailyData.stream() + .map(data -> { + Object dateObj = data.get("execDate"); + String dateStr = dateObj != null ? dateObj.toString() : ""; + Number avgMs = (Number) data.get("avgDurationMs"); + return ExecutionStatisticsDto.DailyStat.builder() + .date(dateStr) + .successCount(((Number) data.get("successCount")).intValue()) + .failedCount(((Number) data.get("failedCount")).intValue()) + .otherCount(((Number) data.get("otherCount")).intValue()) + .avgDurationMs(avgMs != null ? avgMs.doubleValue() : 0) + .build(); + }) + .collect(Collectors.toList()); + + int totalSuccess = dailyStats.stream().mapToInt(ExecutionStatisticsDto.DailyStat::getSuccessCount).sum(); + int totalFailed = dailyStats.stream().mapToInt(ExecutionStatisticsDto.DailyStat::getFailedCount).sum(); + int totalOther = dailyStats.stream().mapToInt(ExecutionStatisticsDto.DailyStat::getOtherCount).sum(); + double avgDuration = dailyStats.stream() + .mapToDouble(ExecutionStatisticsDto.DailyStat::getAvgDurationMs) + .filter(d -> d > 0) + .average() + .orElse(0); + + return ExecutionStatisticsDto.builder() + .dailyStats(dailyStats) + .totalExecutions(totalSuccess + totalFailed + totalOther) + .totalSuccess(totalSuccess) + .totalFailed(totalFailed) + .avgDurationMs(avgDuration) + .build(); + } + + // ── 공통: 실패 레코드 건수 세팅 ──────────────────────────────── + + private void populateFailedRecordCounts(List executions) { + List executionIds = executions.stream() + .map(JobExecutionDto::getExecutionId) + .filter(java.util.Objects::nonNull) + .toList(); + + if (executionIds.isEmpty()) { + return; + } + + Map countMap = failedRecordRepository.countFailedByJobExecutionIds(executionIds) + .stream() + .collect(Collectors.toMap( + row -> ((Number) row[0]).longValue(), + row -> ((Number) row[1]).longValue() + )); + + executions.forEach(exec -> exec.setFailedRecordCount( + countMap.getOrDefault(exec.getExecutionId(), 0L))); + } + + // ── 공통: Map → DTO 변환 헬퍼 ──────────────────────────────── + + private JobExecutionDto convertMapToDto(Map data) { + java.sql.Timestamp startTimestamp = (java.sql.Timestamp) data.get("startTime"); + java.sql.Timestamp endTimestamp = (java.sql.Timestamp) data.get("endTime"); + return JobExecutionDto.builder() + .executionId(((Number) data.get("executionId")).longValue()) + .jobName((String) data.get("jobName")) + .status((String) data.get("status")) + .startTime(startTimestamp != null ? startTimestamp.toLocalDateTime() : null) + .endTime(endTimestamp != null ? endTimestamp.toLocalDateTime() : null) + .exitCode((String) data.get("exitCode")) + .exitMessage((String) data.get("exitMessage")) + .build(); + } +} diff --git a/src/main/java/com/snp/batch/service/QuartzJobService.java b/src/main/java/com/snp/batch/service/QuartzJobService.java new file mode 100644 index 0000000..b94315f --- /dev/null +++ b/src/main/java/com/snp/batch/service/QuartzJobService.java @@ -0,0 +1,68 @@ +package com.snp.batch.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.stereotype.Service; + +import java.util.Map; + +/** + * Quartz Job과 Spring Batch Job을 연동하는 서비스 + * Quartz 스케줄러에서 호출되어 실제 배치 작업을 실행 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class QuartzJobService { + + private final JobLauncher jobLauncher; + private final Map jobMap; + + /** + * 배치 작업 실행 + * + * @param jobName 실행할 Job 이름 + * @throws Exception Job 실행 중 발생한 예외 + */ + public void executeBatchJob(String jobName) throws Exception { + log.info("스케줄러에 의해 배치 작업 실행 시작: {}", jobName); + + // Job Bean 조회 + Job job = jobMap.get(jobName); + if (job == null) { + log.error("배치 작업을 찾을 수 없습니다: {}", jobName); + throw new IllegalArgumentException("Job not found: " + jobName); + } + + // JobParameters 생성 (timestamp를 포함하여 매번 다른 JobInstance 생성) + JobParameters jobParameters = new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis()) + .addString("triggeredBy", "SCHEDULER") + .toJobParameters(); + + try { + // 배치 작업 실행 + var jobExecution = jobLauncher.run(job, jobParameters); + log.info("배치 작업 실행 완료: {} (Execution ID: {})", jobName, jobExecution.getId()); + log.info("실행 상태: {}", jobExecution.getStatus()); + + } catch (Exception e) { + log.error("배치 작업 실행 중 에러 발생: {}", jobName, e); + throw e; + } + } + + /** + * Job 이름 유효성 검사 + * + * @param jobName 검사할 Job 이름 + * @return boolean Job 존재 여부 + */ + public boolean isValidJob(String jobName) { + return jobMap.containsKey(jobName); + } +} diff --git a/src/main/java/com/snp/batch/service/RecollectionHistoryService.java b/src/main/java/com/snp/batch/service/RecollectionHistoryService.java new file mode 100644 index 0000000..7d4e44d --- /dev/null +++ b/src/main/java/com/snp/batch/service/RecollectionHistoryService.java @@ -0,0 +1,519 @@ +package com.snp.batch.service; + +import com.snp.batch.global.dto.JobExecutionDetailDto; +import com.snp.batch.global.model.BatchCollectionPeriod; +import com.snp.batch.global.model.BatchRecollectionHistory; +import com.snp.batch.global.model.BatchFailedRecord; +import com.snp.batch.global.model.JobDisplayNameEntity; +import com.snp.batch.global.repository.BatchApiLogRepository; +import com.snp.batch.global.repository.BatchCollectionPeriodRepository; +import com.snp.batch.global.repository.BatchFailedRecordRepository; +import com.snp.batch.global.repository.BatchLastExecutionRepository; +import com.snp.batch.global.repository.BatchRecollectionHistoryRepository; +import com.snp.batch.global.repository.JobDisplayNameRepository; +import jakarta.persistence.criteria.Predicate; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.explore.JobExplorer; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import com.snp.batch.global.model.BatchApiLog; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RecollectionHistoryService { + + private final BatchRecollectionHistoryRepository historyRepository; + private final BatchCollectionPeriodRepository periodRepository; + private final BatchLastExecutionRepository lastExecutionRepository; + private final BatchApiLogRepository apiLogRepository; + private final BatchFailedRecordRepository failedRecordRepository; + private final JobExplorer jobExplorer; + private final JobDisplayNameRepository jobDisplayNameRepository; + + /** + * 재수집 실행 시작 기록 + * REQUIRES_NEW: Job 실패해도 이력은 보존 + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public BatchRecollectionHistory recordStart( + String jobName, + Long jobExecutionId, + String apiKey, + String executor, + String reason) { + + if (apiKey == null || apiKey.isBlank()) { + log.warn("[RecollectionHistory] apiKey가 null이므로 이력 미생성: jobName={}, executor={}", jobName, executor); + return null; + } + + boolean isRetryByRecordKeys = "AUTO_RETRY".equals(executor) || "MANUAL_RETRY".equals(executor); + + LocalDateTime rangeFrom = null; + LocalDateTime rangeTo = null; + String apiKeyName = null; + boolean hasOverlap = false; + String overlapIds = null; + + if (isRetryByRecordKeys) { + // 실패 건 재수집 (자동/수동): 날짜 범위가 아닌 실패 레코드 키 기반이므로 날짜 없이 이력 생성 + if (apiKey != null) { + apiKeyName = jobDisplayNameRepository.findByApiKey(apiKey) + .map(JobDisplayNameEntity::getDisplayName) + .orElseGet(() -> periodRepository.findById(apiKey) + .map(BatchCollectionPeriod::getApiKeyName) + .orElse(null)); + } + log.info("[RecollectionHistory] 실패 건 재수집 이력 생성 (날짜 범위 없음): executor={}, apiKey={}, apiKeyName={}", executor, apiKey, apiKeyName); + } else { + // 수동 재수집: BatchCollectionPeriod에서 날짜 범위 조회 + Optional period = periodRepository.findById(apiKey); + if (period.isEmpty()) { + log.warn("[RecollectionHistory] apiKey {} 에 대한 수집기간 없음, 이력 미생성", apiKey); + return null; + } + + BatchCollectionPeriod cp = period.get(); + rangeFrom = cp.getRangeFromDate(); + rangeTo = cp.getRangeToDate(); + apiKeyName = jobDisplayNameRepository.findByApiKey(apiKey) + .map(JobDisplayNameEntity::getDisplayName) + .orElseGet(cp::getApiKeyName); + + // 기간 중복 검출 + List overlaps = historyRepository + .findOverlappingHistories(apiKey, rangeFrom, rangeTo, -1L); + hasOverlap = !overlaps.isEmpty(); + overlapIds = overlaps.stream() + .map(h -> String.valueOf(h.getHistoryId())) + .collect(Collectors.joining(",")); + if (overlapIds.length() > 490) { + overlapIds = overlapIds.substring(0, 490) + "..."; + } + } + + LocalDateTime now = LocalDateTime.now(); + BatchRecollectionHistory history = BatchRecollectionHistory.builder() + .apiKey(apiKey) + .apiKeyName(apiKeyName) + .jobName(jobName) + .jobExecutionId(jobExecutionId) + .rangeFromDate(rangeFrom) + .rangeToDate(rangeTo) + .executionStatus("STARTED") + .executionStartTime(now) + .executor(executor != null ? executor : "SYSTEM") + .recollectionReason(reason) + .hasOverlap(hasOverlap) + .overlappingHistoryIds(hasOverlap ? overlapIds : null) + .createdAt(now) + .updatedAt(now) + .build(); + + BatchRecollectionHistory saved = historyRepository.save(history); + log.info("[RecollectionHistory] 재수집 이력 생성: historyId={}, apiKey={}, jobExecutionId={}, range={}~{}", + saved.getHistoryId(), apiKey, jobExecutionId, rangeFrom, rangeTo); + return saved; + } + + /** + * 재수집 실행 완료 기록 + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void recordCompletion( + Long jobExecutionId, + String status, + Long readCount, + Long writeCount, + Long skipCount, + Integer apiCallCount, + Long totalResponseTimeMs, + String failureReason) { + + Optional opt = + historyRepository.findByJobExecutionId(jobExecutionId); + + if (opt.isEmpty()) { + log.warn("[RecollectionHistory] jobExecutionId {} 에 해당하는 이력 없음", jobExecutionId); + return; + } + + BatchRecollectionHistory history = opt.get(); + LocalDateTime now = LocalDateTime.now(); + + history.setExecutionStatus(status); + history.setExecutionEndTime(now); + history.setReadCount(readCount); + history.setWriteCount(writeCount); + history.setSkipCount(skipCount); + history.setApiCallCount(apiCallCount); + history.setTotalResponseTimeMs(totalResponseTimeMs); + history.setFailureReason(failureReason); + history.setUpdatedAt(now); + + if (history.getExecutionStartTime() != null) { + history.setDurationMs(Duration.between(history.getExecutionStartTime(), now).toMillis()); + } + + historyRepository.save(history); + log.info("[RecollectionHistory] 재수집 완료 기록: jobExecutionId={}, status={}, read={}, write={}", + jobExecutionId, status, readCount, writeCount); + } + + /** + * 동적 필터링 + 페이징 목록 조회 + */ + @Transactional(readOnly = true) + public Page getHistories( + String apiKey, String jobName, String status, + LocalDateTime from, LocalDateTime to, + Pageable pageable) { + + Specification spec = (root, query, cb) -> { + List predicates = new ArrayList<>(); + + if (apiKey != null && !apiKey.isEmpty()) { + predicates.add(cb.equal(root.get("apiKey"), apiKey)); + } + if (jobName != null && !jobName.isEmpty()) { + predicates.add(cb.equal(root.get("jobName"), jobName)); + } + if (status != null && !status.isEmpty()) { + predicates.add(cb.equal(root.get("executionStatus"), status)); + } + if (from != null) { + predicates.add(cb.greaterThanOrEqualTo(root.get("executionStartTime"), from)); + } + if (to != null) { + predicates.add(cb.lessThanOrEqualTo(root.get("executionStartTime"), to)); + } + + query.orderBy(cb.desc(root.get("createdAt"))); + return cb.and(predicates.toArray(new Predicate[0])); + }; + + return historyRepository.findAll(spec, pageable); + } + + /** + * CSV 내보내기용 전체 목록 조회 (최대 10,000건) + */ + @Transactional(readOnly = true) + public List getHistoriesForExport( + String apiKey, String jobName, String status, + LocalDateTime from, LocalDateTime to) { + + Specification spec = (root, query, cb) -> { + List predicates = new ArrayList<>(); + + if (apiKey != null && !apiKey.isEmpty()) { + predicates.add(cb.equal(root.get("apiKey"), apiKey)); + } + if (jobName != null && !jobName.isEmpty()) { + predicates.add(cb.equal(root.get("jobName"), jobName)); + } + if (status != null && !status.isEmpty()) { + predicates.add(cb.equal(root.get("executionStatus"), status)); + } + if (from != null) { + predicates.add(cb.greaterThanOrEqualTo(root.get("executionStartTime"), from)); + } + if (to != null) { + predicates.add(cb.lessThanOrEqualTo(root.get("executionStartTime"), to)); + } + + query.orderBy(cb.desc(root.get("createdAt"))); + return cb.and(predicates.toArray(new Predicate[0])); + }; + + return historyRepository.findAll(spec, org.springframework.data.domain.PageRequest.of(0, 10000)).getContent(); + } + + /** + * 상세 조회 (중복 이력 실시간 재검사 포함) + */ + @Transactional(readOnly = true) + public Map getHistoryDetail(Long historyId) { + BatchRecollectionHistory history = historyRepository.findById(historyId) + .orElseThrow(() -> new IllegalArgumentException("이력을 찾을 수 없습니다: " + historyId)); + + // 중복 이력 실시간 재검사 + List currentOverlaps; + if (history.getRangeFromDate() != null && history.getRangeToDate() != null) { + currentOverlaps = historyRepository.findOverlappingHistories( + history.getApiKey(), history.getRangeFromDate(), history.getRangeToDate(), + history.getHistoryId()); + } else { + currentOverlaps = Collections.emptyList(); + } + + // API 응답시간 통계 + Map apiStats = null; + if (history.getJobExecutionId() != null) { + apiStats = getApiStats(history.getJobExecutionId()); + } + + Map result = new LinkedHashMap<>(); + result.put("history", history); + result.put("overlappingHistories", currentOverlaps); + result.put("apiStats", apiStats); + return result; + } + + /** + * 상세 조회 + Step Execution + Collection Period 포함 + * job_execution_id로 batch_step_execution, batch_collection_period를 조인 + */ + @Transactional(readOnly = true) + public Map getHistoryDetailWithSteps(Long historyId) { + BatchRecollectionHistory history = historyRepository.findById(historyId) + .orElseThrow(() -> new IllegalArgumentException("이력을 찾을 수 없습니다: " + historyId)); + + // 중복 이력 실시간 재검사 + List currentOverlaps; + if (history.getRangeFromDate() != null && history.getRangeToDate() != null) { + currentOverlaps = historyRepository.findOverlappingHistories( + history.getApiKey(), history.getRangeFromDate(), history.getRangeToDate(), + history.getHistoryId()); + } else { + currentOverlaps = Collections.emptyList(); + } + + // API 응답시간 통계 + Map apiStats = null; + if (history.getJobExecutionId() != null) { + apiStats = getApiStats(history.getJobExecutionId()); + } + + // Collection Period 조회 + BatchCollectionPeriod collectionPeriod = periodRepository + .findById(history.getApiKey()).orElse(null); + + // Step Execution 조회 (job_execution_id 기반) + List stepExecutions = new ArrayList<>(); + if (history.getJobExecutionId() != null) { + JobExecution jobExecution = jobExplorer.getJobExecution(history.getJobExecutionId()); + if (jobExecution != null) { + // N+1 방지: stepExecutionId 목록을 일괄 조회 후 Map으로 변환 + List stepIds = jobExecution.getStepExecutions().stream() + .map(StepExecution::getId) + .toList(); + Map> failedRecordsMap = failedRecordRepository + .findByStepExecutionIdIn(stepIds).stream() + .collect(Collectors.groupingBy(BatchFailedRecord::getStepExecutionId)); + + stepExecutions = jobExecution.getStepExecutions().stream() + .map(step -> convertStepToDto(step, failedRecordsMap.getOrDefault(step.getId(), Collections.emptyList()))) + .collect(Collectors.toList()); + } + } + + Map result = new LinkedHashMap<>(); + result.put("history", history); + result.put("overlappingHistories", currentOverlaps); + result.put("apiStats", apiStats); + result.put("collectionPeriod", collectionPeriod); + result.put("stepExecutions", stepExecutions); + return result; + } + + private JobExecutionDetailDto.StepExecutionDto convertStepToDto(StepExecution stepExecution, + List failedRecords) { + Long duration = null; + if (stepExecution.getStartTime() != null && stepExecution.getEndTime() != null) { + duration = Duration.between(stepExecution.getStartTime(), stepExecution.getEndTime()).toMillis(); + } + + // StepExecutionContext에서 API 정보 추출 (ExecutionDetail 호환) + JobExecutionDetailDto.ApiCallInfo apiCallInfo = null; + var context = stepExecution.getExecutionContext(); + if (context.containsKey("apiUrl")) { + apiCallInfo = JobExecutionDetailDto.ApiCallInfo.builder() + .apiUrl(context.getString("apiUrl", "")) + .method(context.getString("apiMethod", "")) + .totalCalls(context.containsKey("totalApiCalls") ? context.getInt("totalApiCalls", 0) : null) + .completedCalls(context.containsKey("completedApiCalls") ? context.getInt("completedApiCalls", 0) : null) + .lastCallTime(context.containsKey("lastCallTime") ? context.getString("lastCallTime", "") : null) + .build(); + } + + // batch_api_log 테이블에서 Step별 API 로그 집계 + 개별 로그 조회 + JobExecutionDetailDto.StepApiLogSummary apiLogSummary = + buildStepApiLogSummary(stepExecution.getId()); + + // Step별 실패 레코드 DTO 변환 (사전 일괄 조회된 목록 사용) + List failedRecordDtos = failedRecords.stream() + .map(record -> JobExecutionDetailDto.FailedRecordDto.builder() + .id(record.getId()) + .jobName(record.getJobName()) + .recordKey(record.getRecordKey()) + .errorMessage(record.getErrorMessage()) + .retryCount(record.getRetryCount()) + .status(record.getStatus()) + .createdAt(record.getCreatedAt()) + .build()) + .collect(Collectors.toList()); + + return JobExecutionDetailDto.StepExecutionDto.builder() + .stepExecutionId(stepExecution.getId()) + .stepName(stepExecution.getStepName()) + .status(stepExecution.getStatus().name()) + .startTime(stepExecution.getStartTime()) + .endTime(stepExecution.getEndTime()) + .readCount((int) stepExecution.getReadCount()) + .writeCount((int) stepExecution.getWriteCount()) + .commitCount((int) stepExecution.getCommitCount()) + .rollbackCount((int) stepExecution.getRollbackCount()) + .readSkipCount((int) stepExecution.getReadSkipCount()) + .processSkipCount((int) stepExecution.getProcessSkipCount()) + .writeSkipCount((int) stepExecution.getWriteSkipCount()) + .filterCount((int) stepExecution.getFilterCount()) + .exitCode(stepExecution.getExitStatus().getExitCode()) + .exitMessage(stepExecution.getExitStatus().getExitDescription()) + .duration(duration) + .apiCallInfo(apiCallInfo) + .apiLogSummary(apiLogSummary) + .failedRecords(failedRecordDtos.isEmpty() ? null : failedRecordDtos) + .build(); + } + + /** + * Step별 batch_api_log 통계 집계 (개별 로그는 별도 API로 페이징 조회) + */ + private JobExecutionDetailDto.StepApiLogSummary buildStepApiLogSummary(Long stepExecutionId) { + List stats = apiLogRepository.getApiStatsByStepExecutionId(stepExecutionId); + if (stats.isEmpty() || stats.get(0) == null || ((Number) stats.get(0)[0]).longValue() == 0L) { + return null; + } + + Object[] row = stats.get(0); + + return JobExecutionDetailDto.StepApiLogSummary.builder() + .totalCalls(((Number) row[0]).longValue()) + .successCount(((Number) row[1]).longValue()) + .errorCount(((Number) row[2]).longValue()) + .avgResponseMs(((Number) row[3]).doubleValue()) + .maxResponseMs(((Number) row[4]).longValue()) + .minResponseMs(((Number) row[5]).longValue()) + .totalResponseMs(((Number) row[6]).longValue()) + .totalRecordCount(((Number) row[7]).longValue()) + .build(); + } + + /** + * 재수집 이력 목록의 jobExecutionId별 FAILED 상태 실패건수 조회 + */ + @Transactional(readOnly = true) + public Map getFailedRecordCounts(List jobExecutionIds) { + if (jobExecutionIds.isEmpty()) { + return Collections.emptyMap(); + } + return failedRecordRepository.countFailedByJobExecutionIds(jobExecutionIds).stream() + .collect(Collectors.toMap( + row -> ((Number) row[0]).longValue(), + row -> ((Number) row[1]).longValue() + )); + } + + /** + * 대시보드용 최근 10건 + */ + @Transactional(readOnly = true) + public List getRecentHistories() { + return historyRepository.findTop10ByOrderByCreatedAtDesc(); + } + + /** + * 통계 조회 + */ + @Transactional(readOnly = true) + public Map getHistoryStats() { + Map stats = new LinkedHashMap<>(); + stats.put("totalCount", historyRepository.count()); + stats.put("completedCount", historyRepository.countByExecutionStatus("COMPLETED")); + stats.put("failedCount", historyRepository.countByExecutionStatus("FAILED")); + stats.put("runningCount", historyRepository.countByExecutionStatus("STARTED")); + stats.put("overlapCount", historyRepository.countByHasOverlapTrue()); + return stats; + } + + /** + * API 응답시간 통계 (BatchApiLog 집계) + */ + @Transactional(readOnly = true) + public Map getApiStats(Long jobExecutionId) { + List results = apiLogRepository.getApiStatsByJobExecutionId(jobExecutionId); + if (results.isEmpty() || results.get(0) == null) { + return null; + } + + Object[] row = results.get(0); + Map stats = new LinkedHashMap<>(); + stats.put("callCount", row[0]); + stats.put("totalMs", row[1]); + stats.put("avgMs", row[2]); + stats.put("maxMs", row[3]); + stats.put("minMs", row[4]); + return stats; + } + + /** + * jobName으로 BatchCollectionPeriod에서 apiKey를 조회합니다. + */ + @Transactional(readOnly = true) + public String findApiKeyByJobName(String jobName) { + return periodRepository.findByJobName(jobName) + .map(BatchCollectionPeriod::getApiKey) + .orElse(null); + } + + /** + * 수집 기간 전체 조회 + */ + @Transactional(readOnly = true) + public List getAllCollectionPeriods() { + return periodRepository.findAllByOrderByOrderSeqAsc(); + } + + /** + * 수집 기간 수정 + */ + @Transactional + public BatchCollectionPeriod updateCollectionPeriod(String apiKey, + LocalDateTime rangeFromDate, + LocalDateTime rangeToDate) { + BatchCollectionPeriod period = periodRepository.findById(apiKey) + .orElseGet(() -> new BatchCollectionPeriod(apiKey, rangeFromDate, rangeToDate)); + + period.setRangeFromDate(rangeFromDate); + period.setRangeToDate(rangeToDate); + return periodRepository.save(period); + } + + /** + * 수집 기간 초기화 (rangeFromDate, rangeToDate를 null로) + */ + @Transactional + public void resetCollectionPeriod(String apiKey) { + periodRepository.findById(apiKey).ifPresent(period -> { + period.setRangeFromDate(null); + period.setRangeToDate(null); + periodRepository.save(period); + log.info("[RecollectionHistory] 수집 기간 초기화: apiKey={}", apiKey); + }); + } +} diff --git a/src/main/java/com/snp/batch/service/ScheduleService.java b/src/main/java/com/snp/batch/service/ScheduleService.java new file mode 100644 index 0000000..c19fa38 --- /dev/null +++ b/src/main/java/com/snp/batch/service/ScheduleService.java @@ -0,0 +1,357 @@ +package com.snp.batch.service; + +import com.snp.batch.global.dto.ScheduleRequest; +import com.snp.batch.global.dto.ScheduleResponse; +import com.snp.batch.global.model.JobScheduleEntity; +import com.snp.batch.global.repository.JobScheduleRepository; +import com.snp.batch.scheduler.QuartzBatchJob; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.quartz.*; +import org.springframework.batch.core.Job; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * DB 영속화를 지원하는 스케줄 관리 서비스 + * Quartz 스케줄러와 DB를 동기화하여 재시작 후에도 스케줄 유지 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ScheduleService { + + private final JobScheduleRepository scheduleRepository; + private final Scheduler scheduler; + private final Map jobMap; + private final QuartzJobService quartzJobService; + + /** + * 스케줄 생성 (DB 저장 + Quartz 등록) + * + * @param request 스케줄 요청 정보 + * @return ScheduleResponse 생성된 스케줄 정보 + * @throws Exception 스케줄 생성 중 발생한 예외 + */ + @Transactional + public ScheduleResponse createSchedule(ScheduleRequest request) throws Exception { + String jobName = request.getJobName(); + + log.info("스케줄 생성 시작: {}", jobName); + + // 1. Job 이름 유효성 검사 + if (!quartzJobService.isValidJob(jobName)) { + throw new IllegalArgumentException("Invalid job name: " + jobName + ". Job does not exist."); + } + + // 2. 중복 체크 + if (scheduleRepository.existsByJobName(jobName)) { + throw new IllegalArgumentException("Schedule already exists for job: " + jobName); + } + + // 3. Cron 표현식 유효성 검사 + try { + CronScheduleBuilder.cronSchedule(request.getCronExpression()); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid cron expression: " + request.getCronExpression(), e); + } + + // 4. DB에 저장 + JobScheduleEntity entity = JobScheduleEntity.builder() + .jobName(jobName) + .cronExpression(request.getCronExpression()) + .description(request.getDescription()) + .active(request.getActive() != null ? request.getActive() : true) + .build(); + + // BaseEntity 필드는 setter로 설정하거나 PrePersist에서 자동 설정됨 + // (PrePersist가 자동으로 SYSTEM으로 설정) + + entity = scheduleRepository.save(entity); + log.info("DB에 스케줄 저장 완료: ID={}, Job={}", entity.getId(), jobName); + + // 5. Quartz에 등록 (active=true인 경우만) + if (entity.getActive()) { + try { + registerQuartzJob(entity); + log.info("Quartz 등록 완료: {}", jobName); + } catch (Exception e) { + log.error("Quartz 등록 실패 (DB 저장은 완료됨): {}", jobName, e); + } + } + + // 6. 응답 생성 + return convertToResponse(entity); + } + + /** + * 스케줄 수정 (Cron 표현식과 설명 업데이트) + * + * @param jobName Job 이름 + * @param cronExpression 새로운 Cron 표현식 + * @param description 새로운 설명 + * @return ScheduleResponse 수정된 스케줄 정보 + * @throws Exception 스케줄 수정 중 발생한 예외 + */ + @Transactional + public ScheduleResponse updateSchedule(String jobName, String cronExpression, String description) throws Exception { + log.info("스케줄 수정 시작: {} -> {}", jobName, cronExpression); + + // 1. 기존 스케줄 조회 + JobScheduleEntity entity = scheduleRepository.findByJobName(jobName) + .orElseThrow(() -> new IllegalArgumentException("Schedule not found for job: " + jobName)); + + // 2. Cron 표현식 유효성 검사 + try { + CronScheduleBuilder.cronSchedule(cronExpression); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid cron expression: " + cronExpression, e); + } + + // 3. DB 업데이트 + entity.setCronExpression(cronExpression); + if (description != null) { + entity.setDescription(description); + } + entity = scheduleRepository.save(entity); + log.info("DB 스케줄 업데이트 완료: {}", jobName); + + // 4. Quartz 스케줄 재등록 + if (entity.getActive()) { + try { + unregisterQuartzJob(jobName); + registerQuartzJob(entity); + log.info("Quartz 재등록 완료: {}", jobName); + } catch (Exception e) { + log.error("Quartz 재등록 실패 (DB 업데이트는 완료됨): {}", jobName, e); + } + } + + // 5. 응답 생성 + return convertToResponse(entity); + } + + /** + * 스케줄 수정 (Cron 표현식만 업데이트) + * + * @param jobName Job 이름 + * @param cronExpression 새로운 Cron 표현식 + * @return ScheduleResponse 수정된 스케줄 정보 + * @throws Exception 스케줄 수정 중 발생한 예외 + * @deprecated updateSchedule(jobName, cronExpression, description) 사용 권장 + */ + @Deprecated + @Transactional + public ScheduleResponse updateScheduleByCron(String jobName, String cronExpression) throws Exception { + return updateSchedule(jobName, cronExpression, null); + } + + /** + * 스케줄 삭제 (DB + Quartz) + * + * @param jobName Job 이름 + * @throws Exception 스케줄 삭제 중 발생한 예외 + */ + @Transactional + public void deleteSchedule(String jobName) throws Exception { + log.info("스케줄 삭제 시작: {}", jobName); + + // 1. Quartz에서 제거 + try { + unregisterQuartzJob(jobName); + log.info("Quartz 스케줄 제거 완료: {}", jobName); + } catch (Exception e) { + log.warn("Quartz에서 스케줄 제거 실패 (무시하고 계속): {}", jobName, e); + } + + // 2. DB에서 삭제 + scheduleRepository.deleteByJobName(jobName); + log.info("DB에서 스케줄 삭제 완료: {}", jobName); + } + + /** + * 특정 Job의 스케줄 조회 + * + * @param jobName Job 이름 + * @return ScheduleResponse 스케줄 정보 + */ + @Transactional(readOnly = true) + public ScheduleResponse getScheduleByJobName(String jobName) { + JobScheduleEntity entity = scheduleRepository.findByJobName(jobName) + .orElseThrow(() -> new IllegalArgumentException("Schedule not found for job: " + jobName)); + + return convertToResponse(entity); + } + + /** + * 전체 스케줄 목록 조회 + * + * @return List 스케줄 목록 + */ + @Transactional(readOnly = true) + public List getAllSchedules() { + return scheduleRepository.findAll().stream() + .map(this::convertToResponse) + .collect(Collectors.toList()); + } + + /** + * 활성화된 스케줄 목록 조회 + * + * @return List 활성 스케줄 목록 + */ + @Transactional(readOnly = true) + public List getAllActiveSchedules() { + return scheduleRepository.findAllActive().stream() + .map(this::convertToResponse) + .collect(Collectors.toList()); + } + + /** + * 스케줄 활성화/비활성화 토글 + * + * @param jobName Job 이름 + * @param active 활성화 여부 + * @return ScheduleResponse 수정된 스케줄 정보 + * @throws Exception 스케줄 토글 중 발생한 예외 + */ + @Transactional + public ScheduleResponse toggleScheduleActive(String jobName, boolean active) throws Exception { + log.info("스케줄 활성화 상태 변경: {} -> {}", jobName, active); + + // 1. 기존 스케줄 조회 + JobScheduleEntity entity = scheduleRepository.findByJobName(jobName) + .orElseThrow(() -> new IllegalArgumentException("Schedule not found for job: " + jobName)); + + // 2. DB 업데이트 + entity.setActive(active); + entity = scheduleRepository.save(entity); + + // 3. Quartz 동기화 + try { + if (active) { + // 활성화: Quartz에 등록 + registerQuartzJob(entity); + log.info("Quartz 활성화 완료: {}", jobName); + } else { + // 비활성화: Quartz에서 제거 + unregisterQuartzJob(jobName); + log.info("Quartz 비활성화 완료: {}", jobName); + } + } catch (Exception e) { + log.error("Quartz 동기화 중 예외 발생 (DB 업데이트는 완료됨): {}", jobName, e); + } + + // 4. 응답 생성 + return convertToResponse(entity); + } + + /** + * Quartz에 Job 등록 + * Trigger → Job 순서로 명시적 제거 후 새로 등록하여 orphan trigger 방지 + * + * @param entity JobScheduleEntity + * @throws SchedulerException Quartz 스케줄러 예외 + */ + private void registerQuartzJob(JobScheduleEntity entity) throws SchedulerException { + String jobName = entity.getJobName(); + JobKey jobKey = new JobKey(jobName, "batch-jobs"); + TriggerKey triggerKey = new TriggerKey(jobName + "-trigger", "batch-triggers"); + + // 1. 기존 Trigger 명시적 제거 (orphan trigger 방지) + if (scheduler.checkExists(triggerKey)) { + scheduler.unscheduleJob(triggerKey); + log.debug("기존 Quartz Trigger 제거: {}", triggerKey); + } + + // 2. 기존 Job 삭제 (연결된 trigger도 함께 삭제됨) + if (scheduler.checkExists(jobKey)) { + scheduler.deleteJob(jobKey); + log.debug("기존 Quartz Job 삭제: {}", jobName); + } + + // JobDetail 생성 + JobDetail jobDetail = JobBuilder.newJob(QuartzBatchJob.class) + .withIdentity(jobKey) + .usingJobData("jobName", jobName) + .storeDurably(true) + .build(); + + // CronTrigger 생성 + CronTrigger trigger = TriggerBuilder.newTrigger() + .withIdentity(triggerKey) + .withSchedule(CronScheduleBuilder.cronSchedule(entity.getCronExpression()) + .withMisfireHandlingInstructionDoNothing()) + .forJob(jobKey) + .build(); + + // Quartz에 스케줄 등록 + scheduler.scheduleJob(jobDetail, trigger); + log.info("Quartz에 스케줄 등록 완료: {} (Cron: {})", jobName, entity.getCronExpression()); + } + + /** + * Quartz에서 Job 제거 + * + * @param jobName Job 이름 + * @throws SchedulerException Quartz 스케줄러 예외 + */ + private void unregisterQuartzJob(String jobName) throws SchedulerException { + JobKey jobKey = new JobKey(jobName, "batch-jobs"); + + if (scheduler.checkExists(jobKey)) { + scheduler.deleteJob(jobKey); + log.info("Quartz에서 스케줄 제거 완료: {}", jobName); + } + } + + /** + * Entity를 Response DTO로 변환 + * + * @param entity JobScheduleEntity + * @return ScheduleResponse + */ + private ScheduleResponse convertToResponse(JobScheduleEntity entity) { + ScheduleResponse.ScheduleResponseBuilder builder = ScheduleResponse.builder() + .id(entity.getId()) + .jobName(entity.getJobName()) + .cronExpression(entity.getCronExpression()) + .description(entity.getDescription()) + .active(entity.getActive()) + .createdAt(entity.getCreatedAt()) + .updatedAt(entity.getUpdatedAt()) + .createdBy(entity.getCreatedBy()) + .updatedBy(entity.getUpdatedBy()); + + // Quartz 트리거에서 실행 시간 정보 조회 + if (entity.getActive() && entity.getCronExpression() != null) { + try { + TriggerKey triggerKey = new TriggerKey(entity.getJobName() + "-trigger", "batch-triggers"); + Trigger quartzTrigger = scheduler.getTrigger(triggerKey); + + if (quartzTrigger != null) { + builder.nextFireTime(quartzTrigger.getNextFireTime()); + builder.previousFireTime(quartzTrigger.getPreviousFireTime()); + builder.triggerState( + scheduler.getTriggerState(triggerKey).name()); + } else { + // 트리거 미등록 시 Cron 표현식 기반 계산 + CronTrigger tempTrigger = TriggerBuilder.newTrigger() + .withSchedule(CronScheduleBuilder.cronSchedule(entity.getCronExpression())) + .build(); + builder.nextFireTime(tempTrigger.getFireTimeAfter(new Date())); + builder.triggerState("NONE"); + } + } catch (Exception e) { + log.debug("Quartz 트리거 정보 조회 실패: {}", entity.getJobName(), e); + } + } + + return builder.build(); + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..7e68b01 --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,105 @@ +spring: + application: + name: snp-batch-validation + + # PostgreSQL Database Configuration + datasource: + url: jdbc:postgresql://211.208.115.83:5432/snpdb + username: snp + password: snp#8932 + driver-class-name: org.postgresql.Driver + hikari: + maximum-pool-size: 10 + minimum-idle: 5 + connection-timeout: 30000 + + # JPA Configuration + jpa: + hibernate: + ddl-auto: update + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true + default_schema: std_snp_data + + # Batch Configuration + batch: + jdbc: + table-prefix: "std_snp_data.batch_" + initialize-schema: never # Changed to 'never' as tables already exist + job: + enabled: false # Prevent auto-run on startup + + # Thymeleaf Configuration + thymeleaf: + cache: false + prefix: classpath:/templates/ + suffix: .html + + # Quartz Scheduler Configuration - Using JDBC Store for persistence + quartz: + job-store-type: jdbc # JDBC store for schedule persistence + jdbc: + initialize-schema: never # Quartz tables manually created in std_snp_data schema + properties: + org.quartz.scheduler.instanceName: SNPBatchScheduler + org.quartz.scheduler.instanceId: AUTO + org.quartz.threadPool.threadCount: 10 + org.quartz.jobStore.class: org.quartz.impl.jdbcjobstore.JobStoreTX + org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate + org.quartz.jobStore.tablePrefix: std_snp_data.QRTZ_ + org.quartz.jobStore.isClustered: false + org.quartz.jobStore.misfireThreshold: 60000 + + +# Server Configuration +server: + port: 8041 +# port: 8041 + servlet: + context-path: /snp-api + +# Actuator Configuration +management: + endpoints: + web: + exposure: + include: health,info,metrics,prometheus,batch + endpoint: + health: + show-details: always + +# Logging Configuration (logback-spring.xml에서 상세 설정) +logging: + config: classpath:logback-spring.xml + +# Custom Application Properties +app: + environment: dev + batch: + chunk-size: 1000 + schedule: + enabled: true + cron: "0 0 * * * ?" # Every hour + + # LAST_EXECUTION 버퍼 시간 (시간 단위) - 외부 DB 동기화 지연 대응 + last-execution-buffer-hours: 24 + + # ShipDetailUpdate 배치 설정 (dev - 기존과 동일하게 20건 유지) + ship-detail-update: + batch-size: 10 # dev에서는 문제 없으므로 기존 20건 유지 + delay-on-success-ms: 300 + delay-on-failure-ms: 2000 + max-retry-count: 3 + partition-count: 4 # dev: 2개 파티션 + + # 파티션 관리 설정 + partition: + daily-tables: [] + monthly-tables: [] + retention: + daily-default-days: 14 + monthly-default-months: 1 + custom: [] diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..7b5b5b2 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,106 @@ +spring: + application: + name: snp-batch-validation + + # PostgreSQL Database Configuration + datasource: + url: jdbc:postgresql://211.208.115.83:5432/snpdb + username: snp + password: snp#8932 + driver-class-name: org.postgresql.Driver + hikari: + maximum-pool-size: 10 + minimum-idle: 5 + connection-timeout: 30000 + + # JPA Configuration + jpa: + hibernate: + ddl-auto: update + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true + default_schema: std_snp_data + + # Batch Configuration + batch: + jdbc: + table-prefix: "std_snp_data.batch_" + initialize-schema: never # Changed to 'never' as tables already exist + job: + enabled: false # Prevent auto-run on startup + + # Thymeleaf Configuration + thymeleaf: + cache: false + prefix: classpath:/templates/ + suffix: .html + + # Quartz Scheduler Configuration - Using JDBC Store for persistence + quartz: + job-store-type: jdbc # JDBC store for schedule persistence + jdbc: + initialize-schema: never # Create Quartz tables if not exist + properties: + org.quartz.scheduler.instanceName: SNPBatchScheduler + org.quartz.scheduler.instanceId: AUTO + org.quartz.threadPool.threadCount: 10 + org.quartz.jobStore.class: org.quartz.impl.jdbcjobstore.JobStoreTX + org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate + org.quartz.jobStore.tablePrefix: std_snp_data.QRTZ_ + org.quartz.jobStore.isClustered: false + org.quartz.jobStore.misfireThreshold: 60000 + + +# Server Configuration +server: + port: 8041 + servlet: + context-path: /snp-api + +# Actuator Configuration +management: + endpoints: + web: + exposure: + include: health,info,metrics,prometheus,batch + endpoint: + health: + show-details: always + + +# Logging Configuration (logback-spring.xml에서 상세 설정) +logging: + config: classpath:logback-spring.xml + + +# Custom Application Properties +app: + environment: prod + batch: + chunk-size: 1000 + schedule: + enabled: true + cron: "0 0 * * * ?" # Every hour + + # LAST_EXECUTION 버퍼 시간 (시간 단위) - 외부 DB 동기화 지연 대응 + last-execution-buffer-hours: 24 + + # ShipDetailUpdate 배치 설정 (prod 튜닝) + ship-detail-update: + batch-size: 10 # API 요청 당 IMO 건수 (프록시 타임아웃 방지) + delay-on-success-ms: 300 # 성공 시 딜레이 (ms) + delay-on-failure-ms: 2000 # 실패 시 딜레이 (ms) + max-retry-count: 3 # 최대 재시도 횟수 + partition-count: 4 # prod: 4개 파티션 + + # 파티션 관리 설정 + partition: + daily-tables: [] + monthly-tables: [] + retention: + daily-default-days: 14 + monthly-default-months: 1 + custom: [] diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..c2ad539 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,192 @@ +spring: + application: + name: snp-collector + + # PostgreSQL Database Configuration + datasource: + url: jdbc:postgresql://211.208.115.83:5432/snpdb + username: snp + password: snp#8932 + driver-class-name: org.postgresql.Driver + hikari: + maximum-pool-size: 10 + minimum-idle: 5 + connection-timeout: 30000 + + # JPA Configuration + jpa: + hibernate: + ddl-auto: update + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true + default_schema: std_snp_data + + # Batch Configuration + batch: + jdbc: + table-prefix: "std_snp_data.batch_" + initialize-schema: never # Changed to 'never' as tables already exist + job: + enabled: false # Prevent auto-run on startup + + # Thymeleaf Configuration + thymeleaf: + cache: false + prefix: classpath:/templates/ + suffix: .html + + # Quartz Scheduler Configuration - Using JDBC Store for persistence + quartz: + job-store-type: jdbc # JDBC store for schedule persistence + jdbc: + initialize-schema: never # Quartz tables manually created in std_snp_data schema + properties: + org.quartz.scheduler.instanceName: SNPBatchScheduler + org.quartz.scheduler.instanceId: AUTO + org.quartz.threadPool.threadCount: 10 + org.quartz.jobStore.class: org.quartz.impl.jdbcjobstore.JobStoreTX + org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate + org.quartz.jobStore.tablePrefix: std_snp_data.QRTZ_ + org.quartz.jobStore.isClustered: false + org.quartz.jobStore.misfireThreshold: 60000 + + +# Server Configuration +server: + port: 8041 + servlet: + context-path: /snp-collector + +# Actuator Configuration +management: + endpoints: + web: + exposure: + include: health,info,metrics,prometheus,batch + endpoint: + health: + show-details: always + +# Springdoc / Swagger UI +springdoc: + swagger-ui: + deep-linking: true + display-request-duration: true + +# Logging Configuration (logback-spring.xml에서 상세 설정) +logging: + config: classpath:logback-spring.xml + +# Custom Application Properties +app: + environment: dev + batch: + chunk-size: 1000 + target-schema: + name: std_snp_data + tables: + ship-001: tb_ship_default_info + ship-002: tb_ship_info_mst + ship-003: tb_ship_add_info + ship-004: tb_ship_bbctr_hstry + ship-005: tb_ship_idntf_info_hstry + ship-006: tb_ship_clfic_hstry + ship-007: tb_ship_company_rel + ship-008: tb_ship_crew_list + ship-009: tb_ship_dark_actv_idnty + ship-010: tb_ship_country_hstry + ship-011: tb_ship_group_revn_ownr_hstry + ship-012: tb_ship_ice_grd + ship-013: tb_ship_nm_chg_hstry + ship-014: tb_ship_operator_hstry + ship-015: tb_ship_ownr_hstry + ship-016: tb_ship_prtc_rpn_hstry + ship-017: tb_ship_sfty_mng_evdc_hstry + ship-018: tb_ship_mng_company_hstry + ship-019: tb_ship_sstrvsl_rel + ship-020: tb_ship_spc_fetr + ship-021: tb_ship_status_hstry + ship-022: tb_ship_cargo_capacity + ship-023: tb_ship_inspection_ymd + ship-024: tb_ship_inspection_ymd_hstry + ship-025: tb_ship_tech_mng_company_hstry + ship-026: tb_ship_thrstr_info + company-001: tb_company_dtl_info + event-001: tb_event_mst + event-002: tb_event_cargo + event-003: tb_event_humn_acdnt + event-004: tb_event_rel + facility-001: tb_port_facility_info + psc-001: tb_psc_mst + psc-002: tb_psc_defect + psc-003: tb_psc_oa_certf + movements-001: tb_ship_anchrgcall_hstry + movements-002: tb_ship_berthcall_hstry + movements-003: tb_ship_now_status_hstry + movements-004: tb_ship_dest_hstry + movements-005: tb_ship_prtcll_hstry + movements-006: tb_ship_sts_opert_hstry + movements-007: tb_ship_teminalcall_hstry + movements-008: tb_ship_trnst_hstry + code-001: tb_ship_type_cd + code-002: tb_ship_country_cd + risk-compliance-001: tb_ship_risk_detail_info + risk-compliance-002: tb_ship_compliance_info + risk-compliance-003: tb_company_compliance_info + ship-028: ship_detail_hash_json + imo-meta-001: tb_ship_default_info + service-schema: + name: std_snp_svc + tables: + service-001: tb_ship_main_info + api-auth: + username: 7cc0517d-5ed6-452e-a06f-5bbfd6ab6ade + password: 2LLzSJNqtxWVD8zC + ship-api: + url: https://shipsapi.maritime.spglobal.com + ais-api: + url: https://aisapi.maritime.spglobal.com + webservice-api: + url: https://webservices.maritime.spglobal.com + schedule: + enabled: true + cron: "0 0 * * * ?" # Every hour + + # LAST_EXECUTION 버퍼 시간 (시간 단위) - 외부 DB 동기화 지연 대응 + last-execution-buffer-hours: 24 + + # RiskDetail 배치 설정 + risk-detail: + partition-count: 4 # 병렬 파티션 수 + delay-on-success-ms: 300 # 성공 시 딜레이 (ms) + delay-on-failure-ms: 2000 # 실패 시 딜레이 (ms) + + # ShipDetailUpdate 배치 설정 + ship-detail-update: + batch-size: 10 # API 요청 당 IMO 건수 + delay-on-success-ms: 300 # 성공 시 딜레이 (ms) + delay-on-failure-ms: 2000 # 실패 시 딜레이 (ms) + max-retry-count: 3 # 최대 재시도 횟수 + partition-count: 4 # 병렬 파티션 수 + + # 배치 로그 정리 설정 + log-cleanup: + api-log-retention-days: 30 # batch_api_log 보존 기간 + batch-meta-retention-days: 90 # Spring Batch 메타 테이블 보존 기간 + failed-record-retention-days: 90 # batch_failed_record (RESOLVED) 보존 기간 + recollection-history-retention-days: 90 # batch_recollection_history 보존 기간 + + # 파티션 관리 설정 + partition: + # 일별 파티션 테이블 목록 (네이밍: {table}_YYMMDD) + daily-tables: [] + # 월별 파티션 테이블 목록 (네이밍: {table}_YYYY_MM) + monthly-tables: [] + # 기본 보관기간 + retention: + daily-default-days: 14 # 일별 파티션 기본 보관기간 (14일) + monthly-default-months: 1 # 월별 파티션 기본 보관기간 (1개월) + custom: [] diff --git a/src/main/resources/data/shipDetailSample.json b/src/main/resources/data/shipDetailSample.json new file mode 100644 index 0000000..2d4d7e4 --- /dev/null +++ b/src/main/resources/data/shipDetailSample.json @@ -0,0 +1,5616 @@ +{ + "shipCount": 5, + "ShipResult": [ + { + "shipCount": 1, + "APSShipDetail": { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "AuxiliaryGeneratorsDescriptiveNarrative": "2 x 211kW 220/380V 50Hz a.c., 1 x 100kW 220/380V 50Hz a.c.", + "BaleCapacity": "0", + "BollardPull": "0", + "BulbousBow": "N", + "BreadthExtreme": "8.800", + "BreadthMoulded": "0.000", + "BunkersDescriptiveNarrative": "Fuel: distillate fuel: 90 cu m", + "CargoCapacitiesNarrative": "", + "CargoGradesSegregations": "0", + "ClassNarrative": "", + "ClassificationSociety": "", + "ClassificationSocietyCode": "", + "CleanBallastCapacity": "0", + "ClearHeightOfROROLanes": "0.00", + "CompensatedGrossTonnageCGT": "0", + "ContractDate": "19590701", + "ConstructionDescriptiveNarrative": "Statcode5:X11A2YP; Hull Type:Single Hull; Hull Material:Steel; Hull Connections:Welded; Decks:1 dk", + "CoreShipInd": "1", + "CountryOfBuild": "United Kingdom", + "CountryOfBuildCode": "GBI", + "DateOfBuild": "196107", + "Deadweight": "164", + "DeliveryDate": "19610000", + "Depth": "4.080", + "DischargeDiameterOfCargoManifold": "0.00", + "Displacement": "0", + "DOCCompany": " Unknown", + "DocumentOfComplianceDOCCompanyCode": "9991001", + "Draught": "3.950", + "ExName": "Princess Tanya", + "FlagCode": "PAN", + "FlagEffectiveDate": "197104", + "FlagName": "Panama", + "FuelType1Capacity": "90.00", + "FuelType1Code": "DF", + "FuelType1First": "Distillate Fuel", + "FuelType2Capacity": "0.00", + "FuelType2Code": "NN", + "FuelType2Second": "Not Applicable", + "GasCapacity": "0", + "GrainCapacity": "0", + "GrossTonnage": "551", + "GroupBeneficialOwner": " Unknown", + "GroupBeneficialOwnerCompanyCode": "9991001", + "HeatingCoilsInCargoTanks": "No", + "HullMaterial": "Steel (Unspecified)", + "HullMaterialCode": "ST", + "HullShapeCode": "N1", + "HullType": "Single Hull", + "HullTypeCode": "SH", + "InsulatedCapacity": "0", + "KeelToMastHeight": "0.000", + "LanesDoorsRampsNarrative": "", + "LastUpdateDate": "2025-07-04T23:55:25.817", + "LegalOverall": "0", + "ShipOverallComplianceStatus": "0", + "ShipDarkActivityIndicator": "0", + "ShipFlagDisputed": "0", + "ShipFlagSanctionedCountry": "0", + "ShipHistoricalFlagSanctionedCountry": "0", + "ShipOwnerHistoricalOFACSanctionedCountry": "0", + "ShipOwnerFATFJurisdiction": "0", + "ShipOwnerOFACSanctionedCountry": "0", + "ShipOwnerBESSanctionList": "0", + "ShipOwnerEUSanctionList": "0", + "ShipOwnerOFACSanctionList": "0", + "ShipOwnerUNSanctionList": "0", + "ShipSanctionedCountryPortCallLast12m": "0", + "ShipSanctionedCountryPortCallLast3m": "0", + "ShipSanctionedCountryPortCallLast6m": "0", + "ShipEUSanctionList": "0", + "ShipOFACNonSDNSanctionList": "0", + "ShipOFACSanctionList": "0", + "ShipUNSanctionList": "0", + "ShipOFACSSIList": "0", + "ShipOwnerCanadianSanctionList": "0", + "ShipOwnerAustralianSanctionList": "0", + "ShipUSTreasuryOFACAdvisoryList": "0", + "ShipSwissSanctionList": "0", + "ShipOwnerSwissSanctionList": "0", + "ShipSTSPartnerNonComplianceLast12m": "0", + "ShipSecurityLegalDisputeEventLast12m": "0", + "ShipDetailsNoLongerMaintained": "0", + "ShipBESSanctionList": "0", + "ShipOwnerParentCompanyNonCompliance": "0", + "ShipOwnerUAESanctionList": "0", + "LengthBetweenPerpendicularsLBP": "51.800", + "LengthOfROROLanes": "0", + "LengthOverallLOA": "57.600", + "LengthRegistered": "0.000", + "LightDisplacementTonnage": "0", + "LiquidCapacity": "0", + "IHSLRorIMOShipNo": "1000019", + "MainEngineBore": "240", + "MainEngineBuilder": "Sulzer Bros Ltd - Switzerland", + "MainEngineBuilderCode": "SWZ501551", + "MainEngineDesigner": "Sulzer", + "MainEngineDesignerCode": "SUL", + "MainEngineDesignerGroup": "Wartsila", + "MainEngineDesignerGroupCode": "WAR", + "MainEngineModel": "8TAD24", + "MainEngineNumberOfCylinders": "8", + "MainEngineStrokeType": "2", + "MainEngineType": "Oil", + "NetTonnage": "165", + "NewbuildPriceUSD": "0", + "NewconstructionEntryDate": "195907", + "NumberOfAllEngines": "2", + "NumberOfCabins": "0", + "NumberOfDecks": "1", + "NumberOfMainEngines": "2", + "NumberOfPropulsionUnits": "2", + "NumberOfROROLanes": "0", + "Operator": "Rptd Sold Undisclosed Interest", + "OperatorCompanyCode": "9991942", + "PanamaCanalNetTonnagePCNT": "0", + "PandIClub": "Shipowners' Club", + "PandIClubCode": "6219108", + "PassengerCapacity": "0", + "PassengersBerthed": "0", + "PortOfRegistryCode": "1010", + "PortOfRegistry": "Panama", + "PortOfRegistryFullCode": "PAN1010", + "PowerBHPIHPSHPMax": "1680", + "PowerBHPIHPSHPService": "0", + "PowerKWMax": "1236", + "PowerKWService": "0", + "PrimeMoverDescriptiveNarrative": "2 oil engines with flexible couplings & reduction geared to screw shafts driving 2 FP propellers Total Power: Mcr 1,236kW (1,680hp)Max. Speed: 15.00kts, Service Speed: 13.50kts", + "PrimeMoverDescriptiveOverviewNarrative": "Design: Sulzer (Group: Wartsila), Engine Builder: Sulzer Bros Ltd - Switzerland 2 x 8TAD24, 2 Stroke, Single Acting, In-Line (Vertical) 8 Cy. 240 x 400, Mcr: 618 kW (840 hp) , Made 1961-00", + "PropellerType": "Fixed Pitch", + "PropulsionType": "Oil Engine(s), Direct Drive", + "PropulsionTypeCode": "DD", + "ReeferPoints": "0", + "RegisteredOwner": "Rptd Sold Undisclosed Interest", + "RegisteredOwnerCode": "9991942", + "SafetyManagementCertificateAuditor": "", + "SafetyManagementCertificateConventionOrVol": "", + "SafetyManagementCertificateDateIssued": "20051108", + "SafetyManagementCertificateDOCCompany": "Unknown", + "SafetyManagementCertificateFlag": "", + "SafetyManagementCertificateIssuer": "", + "SafetyManagementCertificateOtherDescription": "", + "SafetyManagementCertificateShipType": "", + "SafetyManagementCertificateSource": "LRF", + "SaleDate": "20040615", + "SegregatedBallastCapacity": "0", + "ShipManager": "Rptd Sold Undisclosed Interest", + "ShipManagerCompanyCode": "9991942", + "ShipName": "LADY K II", + "ShipStatus": "In Service/Commission", + "ShipStatusCode": "S", + "ShipStatusEffectiveDate": "19610000", + "Shipbuilder": "Austin & Pickersgll", + "ShipbuilderCompanyCode": "GBI004351", + "ShipbuilderFullStyle": "Austin & Pickersgill Ltd. - Sunderland", + "ShiptypeLevel2": "Non Merchant", + "ShiptypeLevel3": "Non Merchant", + "ShiptypeLevel4": "Yacht", + "ShiptypeLevel5": "Yacht", + "ShiptypeLevel5HullType": "Ship Shape Including Multi-Hulls", + "ShiptypeLevel5SubGroup": "Yacht", + "ShiptypeLevel5SubType": "Yacht, Private", + "SpeedMax": "15.00", + "SpeedService": "13.50", + "StatCode5": "X11A2YP", + "SuezCanalNetTonnageSCNT": "0", + "TechnicalManager": " Unknown", + "TechnicalManagerCode": "9991001", + "TEU": "0", + "TEUCapacity14THomogenous": "0", + "TonnageEffectiveDate": "196100", + "TonnageSystem69Convention": "I", + "TonnesPerCentimetreImmersionTPCI": "0.000", + "TotalHorsepowerOfAuxiliaryGenerators": "700", + "TotalHorsepowerOfMainEngines": "1680", + "TotalKilowattsOfMainEngines": "1236", + "TotalPowerOfAllEngines": "1236", + "YardNumber": "819", + "YearOfBuild": "1961", + "MainEngineTypeCode": "01", + "CargoOtherType": "Not Applicable", + "CargoOtherCapacity": "", + "NuclearPowerIndicator": "N", + "AuxPropulsionIndicator": "N", + "MainEngineReEngineIndicator": "N", + "MainEngineTypeOfInstallation": "O", + "MainEngineTypeOfInstallationDecode": "ORIGINAL", + "MainEngineStrokeCycle": "SINGLE-ACTING", + "AdditionalInformation": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "TweenDeckFixed": "N", + "TweenDeckPortable": "N", + "DrillBargeInd": "N", + "LRNO": "1000019", + "ProductionVesselInd": "N" + } + ], + "AuxGenerator": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "ACDC": "AC", + "Frequency": "50", + "KWEach": "100.00", + "LRNO": "1000019", + "MainEngineDriven": "N", + "Number": "1", + "SEQ": "02", + "Voltage1": "220", + "Voltage2": "380" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "ACDC": "AC", + "Frequency": "50", + "KWEach": "211.00", + "LRNO": "1000019", + "MainEngineDriven": "N", + "Number": "2", + "SEQ": "01", + "Voltage1": "220", + "Voltage2": "380" + } + ], + "BuilderAddress": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "BuilderCode": "GBI004351", + "CountryCode": "GBI", + "CountryName": "United Kingdom", + "ShipBuilder": "Austin & Pickersgll", + "ShipBuilderFullStyle": "Austin & Pickersgill Ltd. - Sunderland", + "Town": "Sunderland", + "TownCode": "6955", + "BuilderStatus": "Closed" + } + ], + "Capacities": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000019", + "Bale": "0", + "Horsepower": "1680", + "BollardPull": "0", + "GasCapacity": "0", + "GrainCapacity": "0", + "LiquidCapacity": "0", + "NumberOfPassengers": "0", + "NumberRefrigeratedContainers": "0", + "NumberOfTEU": "0" + } + ], + "CargoPump": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CubicMetersCapacity": "0", + "CubicTonsCapacity": "0", + "LRNO": "1000019", + "NumberOfPumps": "0", + "Sequence": "1" + } + ], + "ClassCurrent": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Class": "Not Applicable", + "ClassCode": "NN", + "ClassIndicator": "Not Applicable", + "EffectiveDate": "19610000", + "LRNO": "1000019" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Class": "Lloyd's Register", + "ClassCode": "LR", + "ClassIndicator": "Disclassed", + "EffectiveDate": "20100225", + "LRNO": "1000019" + } + ], + "ClassHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Class": "Not Applicable", + "ClassCode": "NN", + "ClassIndicator": "Not Applicable", + "CurrentIndicator": "Current", + "EffectiveDate": "19610000", + "LRNO": "1000019", + "Sequence": "00" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Class": "Lloyd's Register", + "ClassCode": "LR", + "ClassIndicator": "Disclassed", + "CurrentIndicator": "Current", + "EffectiveDate": "20100225", + "LRNO": "1000019", + "Sequence": "00" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Class": "Lloyd's Register", + "ClassCode": "LR", + "ClassIndicator": "Classed", + "CurrentIndicator": "Historical", + "EffectiveDate": "20050413", + "LRNO": "1000019", + "Sequence": "92" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Class": "Lloyd's Register", + "ClassCode": "LR", + "ClassIndicator": "Disclassed", + "CurrentIndicator": "Historical", + "EffectiveDate": "20041210", + "LRNO": "1000019", + "Sequence": "93" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Class": "Lloyd's Register", + "ClassCode": "LR", + "ClassIndicator": "Classed", + "CurrentIndicator": "Historical", + "EffectiveDate": "19610700", + "LRNO": "1000019", + "Sequence": "94" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Class": "Lloyd's Register", + "ClassCode": "LR", + "ClassIndicator": "Class contemplated", + "CurrentIndicator": "Historical", + "EffectiveDate": "19610600", + "LRNO": "1000019", + "Sequence": "95" + } + ], + "CompanyComplianceDetails": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "OwCode": "9991001", + "ShortCompanyName": "Unknown", + "CompanyOnOFACSanctionList": "0", + "CompanyOnUNSanctionList": "0", + "CompanyOnEUSanctionList": "0", + "CompanyOnBESSanctionList": "0", + "CompanyInOFACSanctionedCountry": "0", + "CompanyInFATFJurisdiction": "0", + "CompanyOverallComplianceStatus": "0", + "CompanyOnAustralianSanctionList": "0", + "CompanyOnCanadianSanctionList": "0", + "CompanyOnSwissSanctionList": "0", + "CompanyOnOFACSSIList": "0", + "CompanyOnOFACNonSDNSanctionList": "0", + "CompanyOnUAESanctionList": "0" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "OwCode": "9991942", + "ShortCompanyName": "Rptd Sold Undisclosed Interest", + "CompanyOnOFACSanctionList": "0", + "CompanyOnUNSanctionList": "0", + "CompanyOnEUSanctionList": "0", + "CompanyOnBESSanctionList": "0", + "CompanyInOFACSanctionedCountry": "0", + "CompanyInFATFJurisdiction": "0", + "CompanyOverallComplianceStatus": "0", + "CompanyOnAustralianSanctionList": "0", + "CompanyOnCanadianSanctionList": "0", + "CompanyOnSwissSanctionList": "0", + "CompanyOnOFACSSIList": "0", + "CompanyOnOFACNonSDNSanctionList": "0", + "CompanyOnUAESanctionList": "0" + } + ], + "CompanyDetailsComplexWithCodesAndParent": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "Active", + "CountryName": "Unknown", + "FoundedDate": "2018", + "FullAddress": "Unknown", + "FullName": "Unknown", + "LastChangeDate": "20251106", + "LocationCode": "UNK", + "NationalityofRegistration": "Unknown", + "NationalityofRegistrationCode": "UNK", + "OWCODE": "9991001", + "ParentCompany": "9991001", + "ShortCompanyName": "UNKNOWN" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "Active", + "CountryName": "Unknown", + "FoundedDate": "2011", + "FullAddress": "Unknown", + "FullName": "Rptd Sold Undisclosed Interest", + "LastChangeDate": "20251106", + "LocationCode": "UNK", + "NationalityofRegistration": "Unknown", + "NationalityofRegistrationCode": "UNK", + "OWCODE": "9991942", + "ParentCompany": "9991942", + "ShortCompanyName": "RPTD SOLD UNDISCLOSED INTEREST" + } + ], + "CompanyVesselRelationships": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "DOCCode": "9991001", + "DOCCompany": " Unknown", + "GroupBeneficialOwner": " Unknown", + "GroupBeneficialOwnerCode": "9991001", + "LRNO": "1000019", + "Operator": "Rptd Sold Undisclosed Interest", + "OperatorCode": "9991942", + "RegisteredOwner": "Rptd Sold Undisclosed Interest", + "RegisteredOwnerCode": "9991942", + "ShipManager": "Rptd Sold Undisclosed Interest", + "ShipManagerCode": "9991942", + "TechnicalManager": " Unknown", + "TechnicalManagerCode": "9991001" + } + ], + "EngineBuilder": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "EngineBuilderLargestCode": "SWZ501551", + "EngineBuilderShortName": "SULZER BROS LTD", + "EngineBuilderFullName": "Sulzer Bros Ltd -Switzerland ", + "CountryName": "Switzerland", + "CountryCode": "SWZ" + } + ], + "FlagHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "EffectiveDate": "197104", + "Flag": "Panama ", + "FlagCode": "PAN", + "LRNO": "1000019", + "Sequence": "00" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "EffectiveDate": "196100", + "Flag": "United Kingdom ", + "FlagCode": "GBI", + "LRNO": "1000019", + "Sequence": "95" + } + ], + "GrossTonnageHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "EffectiveDate": "200001", + "GT": "551", + "LRNO": "1000019", + "Sequence": "00" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "EffectiveDate": "196100", + "GT": "676", + "LRNO": "1000019", + "Sequence": "95" + } + ], + "GroupBeneficialOwnerHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20180405", + "GroupBeneficialOwner": "Unknown", + "GroupBeneficialOwnerCode": "9991001", + "LRNO": "1000019", + "Sequence": "00" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "Inactive", + "EffectiveDate": "20180405", + "GroupBeneficialOwner": "Monte Zoncolan Voornediep BV", + "GroupBeneficialOwnerCode": "5976406", + "LRNO": "1000019", + "Sequence": "94" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "19939999", + "GroupBeneficialOwner": "Unknown", + "GroupBeneficialOwnerCode": "9991001", + "LRNO": "1000019", + "Sequence": "95" + } + ], + "LiftingGear": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "GearType": "Unknown", + "LRNO": "1000019", + "MaxSWLOfGear": "0.00", + "NumberOfGears": "0", + "Sequence": "01" + } + ], + "MainEngine": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "BHPOfMainOilEngines": "1680", + "Bore": "240", + "CylinderArrangementCode": "L", + "CylinderArrangementDecode": "IN-LINE, VERTICAL", + "EngineBuilder": "Sulzer Bros Ltd - Switzerland", + "EngineBuilderCode": "SUL", + "EngineDesigner": "Sulzer", + "EngineMakerCode": "SWZ501551", + "EngineModel": "8TAD24", + "EngineType": "Oil", + "LRNO": "1000019", + "NumberOfCylinders": "8", + "Position": "PORT", + "PowerBHP": "840", + "PowerKW": "618", + "RPM": "0", + "Stroke": "400", + "StrokeType": "2" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "BHPOfMainOilEngines": "1680", + "Bore": "240", + "CylinderArrangementCode": "L", + "CylinderArrangementDecode": "IN-LINE, VERTICAL", + "EngineBuilder": "Sulzer Bros Ltd - Switzerland", + "EngineBuilderCode": "SUL", + "EngineDesigner": "Sulzer", + "EngineMakerCode": "SWZ501551", + "EngineModel": "8TAD24", + "EngineType": "Oil", + "LRNO": "1000019", + "NumberOfCylinders": "8", + "Position": "STARBOARD", + "PowerBHP": "840", + "PowerKW": "618", + "RPM": "0", + "Stroke": "400", + "StrokeType": "2" + } + ], + "NameHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Effective_Date": "200902", + "LRNO": "1000019", + "Sequence": "00", + "VesselName": "LADY K II" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Effective_Date": "000000", + "LRNO": "1000019", + "Sequence": "92", + "VesselName": "Princess Tanya" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Effective_Date": "000000", + "LRNO": "1000019", + "Sequence": "93", + "VesselName": "Lisboa II" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Effective_Date": "000000", + "LRNO": "1000019", + "Sequence": "94", + "VesselName": "Radiant I" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Effective_Date": "196100", + "LRNO": "1000019", + "Sequence": "95", + "VesselName": "Radiant II" + } + ], + "OperatorHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20250702", + "LRNO": "1000019", + "Operator": "Rptd Sold Undisclosed Interest", + "OperatorCode": "9991942", + "Sequence": "00" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "Inactive", + "EffectiveDate": "20180405", + "LRNO": "1000019", + "Operator": "Monte Zoncolan Voornediep BV", + "OperatorCode": "5976406", + "Sequence": "92" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20041210", + "LRNO": "1000019", + "Operator": "Azure Maritime Ltd", + "OperatorCode": "3025210", + "Sequence": "93" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20020201", + "LRNO": "1000019", + "Operator": "Liveras Yachts SARL", + "OperatorCode": "1960753", + "Sequence": "94" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20029999", + "LRNO": "1000019", + "Operator": "Royal Coast Maritime", + "OperatorCode": "1979237", + "Sequence": "95" + } + ], + "OwnerHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20250702", + "LRNO": "1000019", + "Owner": "Rptd Sold Undisclosed Interest", + "OwnerCode": "9991942", + "Sequence": "00" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "Inactive", + "EffectiveDate": "20180405", + "LRNO": "1000019", + "Owner": "Monte Zoncolan Voornediep BV", + "OwnerCode": "5976406", + "Sequence": "91" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20071210", + "LRNO": "1000019", + "Owner": "Tyvek Ltd", + "OwnerCode": "5357894", + "Sequence": "92" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20041210", + "LRNO": "1000019", + "Owner": "Azure Maritime Ltd", + "OwnerCode": "3025210", + "Sequence": "93" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20029999", + "LRNO": "1000019", + "Owner": "Royal Coast Maritime", + "OwnerCode": "1979237", + "Sequence": "94" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "19939999", + "LRNO": "1000019", + "Owner": "Orisdan Shipping", + "OwnerCode": "3013058", + "Sequence": "95" + } + ], + "PandIHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000019", + "Sequence": "00", + "PandIClubCode": "6219108", + "PandIClubDecode": "Shipowners' Club", + "EffectiveDate": "20070401" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000019", + "Sequence": "95", + "PandIClubCode": "9991001", + "PandIClubDecode": "Unknown", + "EffectiveDate": "20060220" + } + ], + "Propellers": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000019", + "NozzleType": "Not Applicable", + "PropellerPosition": "Port", + "PropellerType": "Fixed Pitch", + "PropellerTypeCode": "FP", + "RPMMaximum": "0", + "RPMService": "0", + "Sequence": "01" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000019", + "NozzleType": "Not Applicable", + "PropellerPosition": "Starboard", + "PropellerType": "Fixed Pitch", + "PropellerTypeCode": "FP", + "RPMMaximum": "0", + "RPMService": "0", + "Sequence": "02" + } + ], + "Sales": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000019", + "SaleDate": "20040615", + "Sequence": "00" + } + ], + "SafetyManagementCertificateHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000019", + "SafetyManagementCertificateAuditor": "", + "SafetyManagementCertificateConventionOrVol": "", + "SafetyManagementCertificateDateIssued": "20051108", + "SafetyManagementCertificateDOCCompany": "Unknown", + "SafetyManagementCertificateFlag": "", + "SafetyManagementCertificateIssuer": "", + "SafetyManagementCertificateOtherDescription": "", + "SafetyManagementCertificateShipType": "", + "SafetyManagementCertificateSource": "LRF", + "SafetyManagementCertificateCompanyCode": "9991001", + "Sequence": "00" + } + ], + "ShipBuilderDetail": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "BuilderCode": "GBI004351", + "BuilderStatus": "Closed", + "CountryCode": "GBI", + "CountryName": "United Kingdom", + "Shipbuilder": "Austin & Pickersgll", + "ShipbuilderFullStyle": "Austin & Pickersgill Ltd. - Sunderland", + "TownCode": "6955" + } + ], + "ShipBuilderAndSubContractor": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "BuilderCode": "GBI004351", + "LRNO": "1000019", + "Section": "Whole Ship", + "SequenceNumber": "01" + } + ], + "ShipBuilderHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "BuilderCode": "GBI004351", + "BuilderHistory": "(The Southwick and South Dock yards operated under the title of Austin & Pickersgill Ltd. until 1986. The company was formed by the successive amalgamations of S. P. Austin & Son Ltd., William Austin & Son and William Pickersgill & Sons Ltd. and incorporating the subsidiary company Bartram & Sons Ltd., which see.): (British Shipbuilders merged this yard with Sunderland Shipbuilders Ltd. under the new style of North East Shipbuilders Ltd. w.e.f. 1.4.86.", + "BuilderType": "Shipbuilder" + } + ], + "ShipManagerHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20250702", + "LRNO": "1000019", + "Sequence": "00", + "ShipManager": "Rptd Sold Undisclosed Interest", + "ShipManagerCode": "9991942" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "Inactive", + "EffectiveDate": "20180405", + "LRNO": "1000019", + "Sequence": "92", + "ShipManager": "Monte Zoncolan Voornediep BV", + "ShipManagerCode": "5976406" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20041210", + "LRNO": "1000019", + "Sequence": "93", + "ShipManager": "Azure Maritime Ltd", + "ShipManagerCode": "3025210" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20020200", + "LRNO": "1000019", + "Sequence": "94", + "ShipManager": "Liveras Yachts SARL", + "ShipManagerCode": "1960753" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20029999", + "LRNO": "1000019", + "Sequence": "95", + "ShipManager": "Royal Coast Maritime", + "ShipManagerCode": "1979237" + } + ], + "ShipTypeHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "EffDate": "196107", + "LRNO": "1000019", + "Sequence": "00", + "Shiptype": "Yacht", + "ShiptypeCode": "X11A2YP" + } + ], + "StatusHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000019", + "Sequence": "00", + "Status": "IN SERVICE/COMMISSION", + "StatusCode": "S", + "StatusDate": "19610000" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000019", + "Sequence": "95", + "Status": "ON ORDER/NOT COMMENCED", + "StatusCode": "O", + "StatusDate": "19590701" + } + ], + "SurveyDates": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "ClassSociety": "Lloyd's Register", + "ClassSocietyCode": "LR", + "DockingSurvey": "Not recorded", + "LRNO": "1000019", + "SpecialSurvey": "Not recorded", + "TailShaftSurvey": "Not recorded" + } + ], + "TechnicalManagerHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20051108", + "LRNO": "1000019", + "Sequence": "00", + "TechnicalManager": "Unknown", + "TechnicalManagerCode": "9991001" + } + ], + "Thrusters": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000019", + "Sequence": "01", + "ThrusterType": "None", + "ThrusterTypeCode": "NN", + "NumberOfThrusters": "0", + "ThrusterPosition": "Not Applicable", + "ThrusterBHP": "0", + "ThrusterKW": "0", + "TypeOfInstallation": "Not Applicable" + } + ], + "CallSignAndMmsiHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Lrno": "1000019", + "SeqNo": "00", + "EffectiveDate": "197104" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Lrno": "1000019", + "SeqNo": "95", + "EffectiveDate": "196100" + } + ] + }, + "APSStatus": { + "SystemVersion": "1.1.0", + "SystemDate": "2015-08-20T00:00:00", + "JobRunDate": "2025-11-07T02:03:12.9231553+00:00", + "CompletedOK": true, + "ErrorLevel": "None", + "ErrorMessage": "", + "RemedialAction": "", + "Guid": "0a2a113f-e436-4439-aee6-6bd6eb3628d8" + } + }, + { + "shipCount": 1, + "APSShipDetail": { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "AuxiliaryEnginesNarrative": "Design: Caterpillar, Engine Builder: Caterpillar 2 x 3306B, 4 Stroke 6Cy. 121 x 152, Mcr: 142 kW Design: Caterpillar, Engine Builder: Caterpillar 2 x 3406C-TA, 4 Stroke 6Cy. 137 x 165, Mcr: 218 kW Design: Hatz, Engine Builder: Hatz 1 x Unknown, 4 Stroke 2Cy. 102 x 105, Mcr: 30 kW", + "AuxiliaryGeneratorsDescriptiveNarrative": "2 x 200kW 400V 50Hz a.c., 2 x 125kW 400V 50Hz a.c.", + "BaleCapacity": "0", + "BollardPull": "0", + "BulbousBow": "N", + "BreadthExtreme": "12.920", + "BreadthMoulded": "12.900", + "CallSign": "ZCCB8", + "CargoCapacitiesNarrative": "", + "CargoGradesSegregations": "0", + "ClassNarrative": "Lloyd's Register (1995-07-24)LR Class: + 100 A1 Survey Type: Special Survey Date: 2015-07 Class Notation: Yacht LR Machinery Class: LR Maltese Cross LMC LR Hull Notes: 4BH to main dk, 3 to lower dk", + "ClassificationSociety": "Lloyd's Register 1995-07-24", + "ClassificationSocietyCode": "LR", + "CleanBallastCapacity": "0", + "ClearHeightOfROROLanes": "0.00", + "CompensatedGrossTonnageCGT": "0", + "ContractDate": "19930700", + "ConstructionDescriptiveNarrative": "Statcode5:X11A2YP; Hull Type:Single Hull; Hull Material:Steel; Hull Connections:Welded; Decks:1 dk plus 3 superstructure dks", + "CoreShipInd": "1", + "CountryOfBuild": "Netherlands", + "CountryOfBuildCode": "NTH", + "DateOfBuild": "199507", + "Deadweight": "484", + "DeliveryDate": "19951000", + "Depth": "6.200", + "DischargeDiameterOfCargoManifold": "0.00", + "Displacement": "0", + "DOCCompany": " Unknown", + "DocumentOfComplianceDOCCompanyCode": "9991001", + "Draught": "4.000", + "FlagCode": "CAY", + "FlagEffectiveDate": "199312", + "FlagName": "Cayman Islands", + "FormulaDWT": "2418.82", + "FuelType1Capacity": "0.00", + "FuelType1Code": "YY", + "FuelType1First": "Yes, But Type Not Known", + "FuelType2Capacity": "0.00", + "FuelType2Code": "NN", + "FuelType2Second": "Not Applicable", + "GasCapacity": "0", + "GrainCapacity": "0", + "GrossTonnage": "1980", + "GroupBeneficialOwner": " Unknown", + "GroupBeneficialOwnerCompanyCode": "9991001", + "HeatingCoilsInCargoTanks": "Unknown", + "HullMaterial": "Steel (Unspecified)", + "HullMaterialCode": "ST", + "HullShapeCode": "N1", + "HullType": "Single Hull", + "HullTypeCode": "SH", + "InmarsatNumberSatCommID": "1756670", + "InsulatedCapacity": "0", + "KeelLaidDate": "19931108", + "KeelToMastHeight": "0.000", + "LanesDoorsRampsNarrative": "", + "LastUpdateDate": "2024-10-23T21:42:55.38", + "LaunchDate": "19940430", + "LegalOverall": "0", + "ShipOverallComplianceStatus": "0", + "ShipDarkActivityIndicator": "0", + "ShipFlagDisputed": "0", + "ShipFlagSanctionedCountry": "0", + "ShipHistoricalFlagSanctionedCountry": "0", + "ShipOwnerHistoricalOFACSanctionedCountry": "0", + "ShipOwnerFATFJurisdiction": "0", + "ShipOwnerOFACSanctionedCountry": "0", + "ShipOwnerBESSanctionList": "0", + "ShipOwnerEUSanctionList": "0", + "ShipOwnerOFACSanctionList": "0", + "ShipOwnerUNSanctionList": "0", + "ShipSanctionedCountryPortCallLast12m": "0", + "ShipSanctionedCountryPortCallLast3m": "0", + "ShipSanctionedCountryPortCallLast6m": "0", + "ShipEUSanctionList": "0", + "ShipOFACNonSDNSanctionList": "0", + "ShipOFACSanctionList": "0", + "ShipUNSanctionList": "0", + "ShipOFACSSIList": "0", + "ShipOwnerCanadianSanctionList": "0", + "ShipOwnerAustralianSanctionList": "0", + "ShipUSTreasuryOFACAdvisoryList": "0", + "ShipSwissSanctionList": "0", + "ShipOwnerSwissSanctionList": "0", + "ShipSTSPartnerNonComplianceLast12m": "0", + "ShipSecurityLegalDisputeEventLast12m": "0", + "ShipDetailsNoLongerMaintained": "0", + "ShipBESSanctionList": "0", + "ShipOwnerParentCompanyNonCompliance": "0", + "ShipOwnerUAESanctionList": "0", + "LengthBetweenPerpendicularsLBP": "68.500", + "LengthOfROROLanes": "0", + "LengthOverallLOA": "78.000", + "LengthRegistered": "0.000", + "LightDisplacementTonnage": "0", + "LiquidCapacity": "0", + "IHSLRorIMOShipNo": "1000021", + "MainEngineBore": "170", + "MainEngineBuilder": "Caterpillar Inc - USA", + "MainEngineBuilderCode": "USA606572", + "MainEngineDesigner": "Caterpillar", + "MainEngineDesignerCode": "CAT", + "MainEngineDesignerGroup": "Caterpillar", + "MainEngineDesignerGroupCode": "CAT", + "MainEngineModel": "3516TA", + "MainEngineNumberOfCylinders": "16", + "MainEngineRPM": "1828", + "MainEngineStrokeType": "4", + "MainEngineType": "Oil", + "NetTonnage": "588", + "NewconstructionEntryDate": "199307", + "NumberOfAllEngines": "7", + "NumberOfAuxiliaryEngines": "5", + "NumberOfCabins": "0", + "NumberOfDecks": "1", + "NumberOfMainEngines": "2", + "NumberOfPropulsionUnits": "2", + "NumberOfROROLanes": "0", + "NumberOfThrusters": "1", + "OfficialNumber": "726658", + "Operator": "Montkaj Co", + "OperatorCompanyCode": "6012336", + "OperatorCountryOfDomicileCode": "CAY", + "OperatorCountryOfDomicileName": "Cayman Islands", + "OperatorCountryOfRegistration": "Cayman Islands", + "PanamaCanalNetTonnagePCNT": "0", + "PandIClub": "Steamship Mutual", + "PandIClubCode": "6206831", + "PassengerCapacity": "0", + "PassengersBerthed": "0", + "PortOfRegistryCode": "9072", + "PortOfRegistry": "George Town", + "PortOfRegistryFullCode": "CAY9072", + "PowerBHPIHPSHPMax": "5052", + "PowerBHPIHPSHPService": "0", + "PowerKWMax": "3716", + "PowerKWService": "0", + "PrimeMoverDescriptiveNarrative": "2 oil engines with clutches, flexible couplings & single reduction geared to screw shafts driving 2 CP propellers at 313 rpm Total Power: Mcr 3,716kW (5,052hp)Max. Speed: 18.00kts, Service Speed: 15.00kts", + "PrimeMoverDescriptiveOverviewNarrative": "Design: Caterpillar (Group: Caterpillar), Engine Builder: Caterpillar Inc - USA 2 x 3516TA, 4 Stroke, Single Acting, Vee 16 Cy. 170 x 190, Mcr: 1,858 kW (2,526 hp) at 1,828 rpm", + "PropellerType": "Controllable Pitch", + "PropulsionType": "Oil Engine(s), Geared Drive", + "PropulsionTypeCode": "DG", + "ReeferPoints": "0", + "RegisteredOwner": "Montkaj Co", + "RegisteredOwnerCode": "6012336", + "RegisteredOwnerCountryOfDomicile": "Cayman Islands", + "RegisteredOwnerCountryOfDomicileCode": "CAY", + "RegisteredOwnerCountryOfRegistration": "Cayman Islands", + "SafetyManagementCertificateDateIssued": "20130716", + "SafetyManagementCertificateDOCCompany": "Unknown", + "SafetyManagementCertificateSource": "LRF", + "SegregatedBallastCapacity": "0", + "ShipManager": "Montkaj Co", + "ShipManagerCompanyCode": "6012336", + "ShipManagerCountryOfDomicileName": "Cayman Islands", + "ShipManagerCountryOfDomicileCode": "CAY", + "ShipManagerCountryOfRegistration": "Cayman Islands", + "ShipName": "MONTKAJ", + "ShipStatus": "In Service/Commission", + "ShipStatusCode": "S", + "ShipStatusEffectiveDate": "19951000", + "Shipbuilder": "Slob Scheepswerf Papendre", + "ShipbuilderCompanyCode": "NTH263072", + "ShipbuilderFullStyle": "Scheepswerf Slob B.V. - Papendrecht", + "ShipbuilderSubContractor": "AMELS HOLLAND BV", + "ShipbuilderSubContractorCode": "NTH001062", + "ShipbuilderSubContractorShipyardYardHullNo": "429", + "ShiptypeLevel2": "Non Merchant", + "ShiptypeLevel3": "Non Merchant", + "ShiptypeLevel4": "Yacht", + "ShiptypeLevel5": "Yacht", + "ShiptypeLevel5HullType": "Ship Shape Including Multi-Hulls", + "ShiptypeLevel5SubGroup": "Yacht", + "ShiptypeLevel5SubType": "Yacht, Private", + "SpeedMax": "18.00", + "SpeedService": "15.00", + "StatCode5": "X11A2YP", + "SuezCanalNetTonnageSCNT": "0", + "TechnicalManager": " Unknown", + "TechnicalManagerCode": "9991001", + "TEU": "0", + "TEUCapacity14THomogenous": "0", + "ThrustersDescriptiveNarrative": "1 Thwart. FP thruster (f)", + "TonnageEffectiveDate": "199312", + "TonnageSystem69Convention": "I", + "TonnesPerCentimetreImmersionTPCI": "0.000", + "TotalHorsepowerOfAuxiliaryGenerators": "872", + "TotalHorsepowerOfMainEngines": "5052", + "TotalKilowattsOfMainEngines": "3716", + "TotalPowerOfAllEngines": "4466", + "TotalPowerOfAuxiliaryEngines": "750", + "YardNumber": "398", + "YearOfBuild": "1995", + "MainEngineTypeCode": "01", + "CargoOtherType": "Not Applicable", + "CargoOtherCapacity": "", + "NuclearPowerIndicator": "N", + "AuxPropulsionIndicator": "N", + "MainEngineReEngineIndicator": "N", + "MainEngineTypeOfInstallation": "O", + "MainEngineTypeOfInstallationDecode": "ORIGINAL", + "MainEngineStrokeCycle": "SINGLE-ACTING", + "AdditionalInformation": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "TweenDeckFixed": "N", + "TweenDeckPortable": "N", + "DrillBargeInd": "N", + "LRNO": "1000021", + "ProductionVesselInd": "N", + "SatComID": "1756670", + "SatComAnsBack": "MONT" + } + ], + "AuxEngine": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Bore": "102", + "EngineDesigner": "Hatz", + "EngineBuilder": "Hatz", + "EngineSequence": "1", + "LRNO": "1000021", + "MaxPower": "30", + "NumberOfCylinders": "2", + "Stroke": "105", + "StrokeType": "4" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Bore": "137", + "EngineDesigner": "Caterpillar", + "EngineModel": "3406C-TA", + "EngineBuilder": "Caterpillar", + "EngineSequence": "2", + "LRNO": "1000021", + "MaxPower": "218", + "NumberOfCylinders": "6", + "Stroke": "165", + "StrokeType": "4" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Bore": "137", + "EngineDesigner": "Caterpillar", + "EngineModel": "3406C-TA", + "EngineBuilder": "Caterpillar", + "EngineSequence": "3", + "LRNO": "1000021", + "MaxPower": "218", + "NumberOfCylinders": "6", + "Stroke": "165", + "StrokeType": "4" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Bore": "121", + "EngineDesigner": "Caterpillar", + "EngineModel": "3306B", + "EngineBuilder": "Caterpillar", + "EngineSequence": "4", + "LRNO": "1000021", + "MaxPower": "142", + "NumberOfCylinders": "6", + "Stroke": "152", + "StrokeType": "4" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Bore": "121", + "EngineDesigner": "Caterpillar", + "EngineModel": "3306B", + "EngineBuilder": "Caterpillar", + "EngineSequence": "5", + "LRNO": "1000021", + "MaxPower": "142", + "NumberOfCylinders": "6", + "Stroke": "152", + "StrokeType": "4" + } + ], + "AuxGenerator": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "ACDC": "AC", + "Frequency": "50", + "KWEach": "125.00", + "LRNO": "1000021", + "MainEngineDriven": "N", + "Number": "2", + "SEQ": "02", + "Voltage1": "400", + "Voltage2": "0" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "ACDC": "AC", + "Frequency": "50", + "KWEach": "200.00", + "LRNO": "1000021", + "MainEngineDriven": "N", + "Number": "2", + "SEQ": "01", + "Voltage1": "400", + "Voltage2": "0" + } + ], + "BuilderAddress": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "BuilderCode": "NTH263072", + "CountryCode": "NTH", + "CountryName": "Netherlands", + "Facsimile": "+31 78 6411199", + "FullAddress": "Scheepvaartweg 11, 3356 LL Papendrecht. Postbus 1146, 3350 CC Papendrecht ", + "ShipBuilder": "Slob Scheepswerf Papendre", + "ShipBuilderFullStyle": "Scheepswerf Slob B.V. - Papendrecht", + "Telephone": "+31 78 6150266", + "Town": "Papendrecht", + "TownCode": "0998", + "BuilderStatus": "Current" + } + ], + "Capacities": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000021", + "Bale": "0", + "Horsepower": "5052", + "BollardPull": "0", + "GasCapacity": "0", + "GrainCapacity": "0", + "LiquidCapacity": "0", + "NumberOfPassengers": "0", + "NumberRefrigeratedContainers": "0", + "NumberOfTEU": "0" + } + ], + "CargoPump": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CubicMetersCapacity": "0", + "CubicTonsCapacity": "0", + "LRNO": "1000021", + "NumberOfPumps": "0", + "Sequence": "1" + } + ], + "ClassCurrent": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Class": "Not Applicable", + "ClassCode": "NN", + "ClassIndicator": "Not Applicable", + "EffectiveDate": "19931200", + "LRNO": "1000021" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Class": "Lloyd's Register", + "ClassCode": "LR", + "ClassIndicator": "Classed", + "EffectiveDate": "19950724", + "LRNO": "1000021" + } + ], + "ClassHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Class": "Not Applicable", + "ClassCode": "NN", + "ClassIndicator": "Not Applicable", + "CurrentIndicator": "Current", + "EffectiveDate": "19931200", + "LRNO": "1000021", + "Sequence": "00" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Class": "Lloyd's Register", + "ClassCode": "LR", + "ClassIndicator": "Classed", + "CurrentIndicator": "Current", + "EffectiveDate": "19950724", + "LRNO": "1000021", + "Sequence": "00" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Class": "Lloyd's Register", + "ClassCode": "LR", + "ClassIndicator": "Class contemplated", + "CurrentIndicator": "Historical", + "EffectiveDate": "19931108", + "LRNO": "1000021", + "Sequence": "95" + } + ], + "CompanyComplianceDetails": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "OwCode": "6012336", + "ShortCompanyName": "Montkaj Co", + "CompanyOnOFACSanctionList": "0", + "CompanyOnUNSanctionList": "0", + "CompanyOnEUSanctionList": "0", + "CompanyOnBESSanctionList": "0", + "CompanyInOFACSanctionedCountry": "0", + "CompanyInFATFJurisdiction": "0", + "CompanyOverallComplianceStatus": "0", + "CompanyOnAustralianSanctionList": "0", + "CompanyOnCanadianSanctionList": "0", + "CompanyOnSwissSanctionList": "0", + "CompanyOnOFACSSIList": "0", + "CompanyOnOFACNonSDNSanctionList": "0", + "CompanyOnUAESanctionList": "0" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "OwCode": "9991001", + "ShortCompanyName": "Unknown", + "CompanyOnOFACSanctionList": "0", + "CompanyOnUNSanctionList": "0", + "CompanyOnEUSanctionList": "0", + "CompanyOnBESSanctionList": "0", + "CompanyInOFACSanctionedCountry": "0", + "CompanyInFATFJurisdiction": "0", + "CompanyOverallComplianceStatus": "0", + "CompanyOnAustralianSanctionList": "0", + "CompanyOnCanadianSanctionList": "0", + "CompanyOnSwissSanctionList": "0", + "CompanyOnOFACSSIList": "0", + "CompanyOnOFACNonSDNSanctionList": "0", + "CompanyOnUAESanctionList": "0" + } + ], + "CompanyDetailsComplexWithCodesAndParent": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "Active", + "CountryName": "Cayman Islands", + "FoundedDate": "2017", + "FullAddress": "PO Box 309, George Town, Grand Cayman, KY1-1104, Cayman Islands.", + "FullName": "The Montkaj Co", + "LastChangeDate": "20241009", + "LocationCode": "CAY", + "NationalityofRegistration": "Cayman Islands", + "NationalityofRegistrationCode": "CAY", + "OWCODE": "6012336", + "ParentCompany": "6012336", + "POBox": "309,", + "PostPostcode": "KY1-1104,", + "ShortCompanyName": "MONTKAJ CO", + "TownName": "George Town" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "Active", + "CountryName": "Unknown", + "FoundedDate": "2018", + "FullAddress": "Unknown", + "FullName": "Unknown", + "LastChangeDate": "20251106", + "LocationCode": "UNK", + "NationalityofRegistration": "Unknown", + "NationalityofRegistrationCode": "UNK", + "OWCODE": "9991001", + "ParentCompany": "9991001", + "ShortCompanyName": "UNKNOWN" + } + ], + "CompanyFleetCounts": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "DOCCount": "0", + "FleetSize": "1", + "GroupOwnerCount": "0", + "InServiceCount": "1", + "OperatorCount": "1", + "OWCODE": "6012336", + "RegisteredOwnerCount": "1", + "ShipManagerCount": "1", + "ShortCompanyName": "Montkaj Co", + "TechnicalManagerCount": "0" + } + ], + "CompanyOrderBookCounts": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "DOCOrderbookCount": "0", + "GroupOwnerOrderbookCount": "0", + "OperatorOrderbookCount": "0", + "OWCODE": "6012336", + "RegisteredOrderbookOwnerCount": "0", + "ShipManagerOrderbookCount": "0" + } + ], + "CompanyVesselRelationships": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "DOCCode": "9991001", + "DOCCompany": " Unknown", + "GroupBeneficialOwner": " Unknown", + "GroupBeneficialOwnerCode": "9991001", + "LRNO": "1000021", + "Operator": "Montkaj Co", + "OperatorCode": "6012336", + "RegisteredOwner": "Montkaj Co", + "RegisteredOwnerCode": "6012336", + "ShipManager": "Montkaj Co", + "ShipManagerCode": "6012336", + "TechnicalManager": " Unknown", + "TechnicalManagerCode": "9991001" + } + ], + "EngineBuilder": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "EngineBuilderLargestCode": "USA606572", + "EngineBuilderShortName": "CATERPILLAR INC", + "EngineBuilderFullName": "Caterpillar Inc -USA ", + "FullAddress": "100, NE Adams Street, Peoria IL 61629-0001 ", + "TownName": "Peoria, IL", + "CountryName": "United States of America", + "Telephone": "+1 309 675 1000", + "Facsimile": "+1 309 675 4332", + "Website": "www.caterpillar.com", + "CountryCode": "USA", + "TownCode": "9069" + } + ], + "FlagHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "EffectiveDate": "199312", + "Flag": "Cayman Islands (British)", + "FlagCode": "CAY", + "LRNO": "1000021", + "Sequence": "00" + } + ], + "GrossTonnageHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "EffectiveDate": "199312", + "GT": "1980", + "LRNO": "1000021", + "Sequence": "00" + } + ], + "GroupBeneficialOwnerHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "19951123", + "GroupBeneficialOwner": "Unknown", + "GroupBeneficialOwnerCode": "9991001", + "LRNO": "1000021", + "Sequence": "00" + } + ], + "LiftingGear": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "GearType": "Unknown", + "LRNO": "1000021", + "MaxSWLOfGear": "0.00", + "NumberOfGears": "0", + "Sequence": "01" + } + ], + "MainEngine": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "BHPOfMainOilEngines": "5052", + "Bore": "170", + "CylinderArrangementCode": "V", + "CylinderArrangementDecode": "VEE", + "EngineBuilder": "Caterpillar Inc - USA", + "EngineBuilderCode": "CAT", + "EngineDesigner": "Caterpillar", + "EngineMakerCode": "USA606572", + "EngineModel": "3516TA", + "EngineType": "Oil", + "LRNO": "1000021", + "NumberOfCylinders": "16", + "Position": "PORT", + "PowerBHP": "2526", + "PowerKW": "1858", + "RPM": "1828", + "Stroke": "190", + "StrokeType": "4" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "BHPOfMainOilEngines": "5052", + "Bore": "170", + "CylinderArrangementCode": "V", + "CylinderArrangementDecode": "VEE", + "EngineBuilder": "Caterpillar Inc - USA", + "EngineBuilderCode": "CAT", + "EngineDesigner": "Caterpillar", + "EngineMakerCode": "USA606572", + "EngineModel": "3516TA", + "EngineType": "Oil", + "LRNO": "1000021", + "NumberOfCylinders": "16", + "Position": "STARBOARD", + "PowerBHP": "2526", + "PowerKW": "1858", + "RPM": "1828", + "Stroke": "190", + "StrokeType": "4" + } + ], + "NameHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Effective_Date": "199507", + "LRNO": "1000021", + "Sequence": "00", + "VesselName": "MONTKAJ" + } + ], + "OperatorHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20170899", + "LRNO": "1000021", + "Operator": "Montkaj Co", + "OperatorCode": "6012336", + "Sequence": "00" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20080109", + "LRNO": "1000021", + "Operator": "Al Bilad Establishment", + "OperatorCode": "5355442", + "Sequence": "94" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20000701", + "LRNO": "1000021", + "Operator": "Sete Yacht Management SA", + "OperatorCode": "3026819", + "Sequence": "95" + } + ], + "OwnerHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20170899", + "LRNO": "1000021", + "Owner": "Montkaj Co", + "OwnerCode": "6012336", + "Sequence": "00" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20080109", + "LRNO": "1000021", + "Owner": "Al Bilad Establishment", + "OwnerCode": "5355442", + "Sequence": "93" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20000701", + "LRNO": "1000021", + "Owner": "Mont Shipping Ltd", + "OwnerCode": "3032888", + "Sequence": "94" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "19951123", + "LRNO": "1000021", + "Owner": "Guevar Investments Corp", + "OwnerCode": "3019251", + "Sequence": "95" + } + ], + "PandIHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000021", + "Sequence": "00", + "PandIClubCode": "6206831", + "PandIClubDecode": "Steamship Mutual", + "EffectiveDate": "20220225", + "Source": "P&I_SSM_20220225" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000021", + "Sequence": "90", + "PandIClubCode": "9991001", + "PandIClubDecode": "Unknown", + "EffectiveDate": "20210311", + "Source": "P&I_SSM_20220225" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000021", + "Sequence": "91", + "PandIClubCode": "6206831", + "PandIClubDecode": "Steamship Mutual", + "EffectiveDate": "20140702", + "Source": "P&I_GO_LIVE_20211110" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000021", + "Sequence": "92", + "PandIClubCode": "6206831", + "PandIClubDecode": "Steamship Mutual", + "EffectiveDate": "20130114", + "Source": "P&I_GO_LIVE_20211110" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000021", + "Sequence": "93", + "PandIClubCode": "6206831", + "PandIClubDecode": "Steamship Mutual", + "EffectiveDate": "20120503", + "Source": "P&I_GO_LIVE_20211110" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000021", + "Sequence": "94", + "PandIClubCode": "6219108", + "PandIClubDecode": "Shipowners' Club", + "EffectiveDate": "20070401", + "Source": "P&I_GO_LIVE_20211110" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000021", + "Sequence": "95", + "PandIClubCode": "9991001", + "PandIClubDecode": "Unknown", + "EffectiveDate": "20060220", + "Source": "P&I_GO_LIVE_20211110" + } + ], + "Propellers": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000021", + "NozzleType": "Unknown", + "PropellerPosition": "Port", + "PropellerType": "Controllable Pitch", + "PropellerTypeCode": "CP", + "RPMMaximum": "313", + "RPMService": "0", + "Sequence": "01" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000021", + "NozzleType": "Unknown", + "PropellerPosition": "Starboard", + "PropellerType": "Controllable Pitch", + "PropellerTypeCode": "CP", + "RPMMaximum": "313", + "RPMService": "0", + "Sequence": "02" + } + ], + "SafetyManagementCertificateHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000021", + "SafetyManagementCertificateDateIssued": "20130716", + "SafetyManagementCertificateDOCCompany": "Unknown", + "SafetyManagementCertificateSource": "LRF", + "SafetyManagementCertificateCompanyCode": "9991001", + "Sequence": "00" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000021", + "SafetyManagementCertificateAuditor": "LR", + "SafetyManagementCertificateConventionOrVol": "C", + "SafetyManagementCertificateDateExpires": "20150211", + "SafetyManagementCertificateDateIssued": "20100212", + "SafetyManagementCertificateDOCCompany": "Knight Caledonian Ltd", + "SafetyManagementCertificateFlag": "United Kingdom", + "SafetyManagementCertificateIssuer": "Lloyd's Register", + "SafetyManagementCertificateShipName": "", + "SafetyManagementCertificateSource": "LR\\102011", + "SafetyManagementCertificateCompanyCode": "0003243", + "Sequence": "94" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000021", + "SafetyManagementCertificateAuditor": "", + "SafetyManagementCertificateConventionOrVol": "", + "SafetyManagementCertificateDateIssued": "19971101", + "SafetyManagementCertificateDOCCompany": "Unknown", + "SafetyManagementCertificateFlag": "", + "SafetyManagementCertificateIssuer": "", + "SafetyManagementCertificateOtherDescription": "", + "SafetyManagementCertificateShipName": "", + "SafetyManagementCertificateShipType": "", + "SafetyManagementCertificateSource": "LRF", + "SafetyManagementCertificateCompanyCode": "9991001", + "Sequence": "95" + } + ], + "ShipBuilderDetail": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "BuilderCode": "NTH001062", + "BuilderStatus": "Closed", + "CountryCode": "NTH", + "CountryName": "Netherlands", + "Facsimile": "+31 515 232719", + "FullAddress": "Stranwei 6, 8754 HA Makkum. Postbus 1, 8754 ZN Makkum ", + "Shipbuilder": "Amels Holland BV", + "ShipbuilderFullStyle": "Amels Holland BV - Makkum", + "Telephone": "+31 515 232525", + "Telex": "46183", + "TownCode": "0220" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "BuilderCode": "NTH263072", + "BuilderStatus": "Current", + "CountryCode": "NTH", + "CountryName": "Netherlands", + "Facsimile": "+31 78 6411199", + "FullAddress": "Scheepvaartweg 11, 3356 LL Papendrecht. Postbus 1146, 3350 CC Papendrecht ", + "Shipbuilder": "Slob Scheepswerf Papendre", + "ShipbuilderFullStyle": "Scheepswerf Slob B.V. - Papendrecht", + "Telephone": "+31 78 6150266", + "TownCode": "0998" + } + ], + "ShipBuilderAndSubContractor": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "BuilderCode": "NTH263072", + "LRNO": "1000021", + "Section": "Sub-Contractor (Hull)", + "SequenceNumber": "01" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "BuilderCode": "NTH001062", + "LRNO": "1000021", + "Section": "Main Contractor", + "SequenceNumber": "02" + } + ], + "ShipBuilderHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "BuilderCode": "NTH263072", + "BuilderHistory": "(Formerly Scheepsbouwwerf Slob B.V., SLOB previously N.V. Scheepsbouwwerf en Lasbedrijf v/h J.C. Slob.) SLO", + "BuilderType": "Shipbuilder" + } + ], + "ShipManagerHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20170899", + "LRNO": "1000021", + "Sequence": "00", + "ShipManager": "Montkaj Co", + "ShipManagerCode": "6012336" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20080109", + "LRNO": "1000021", + "Sequence": "94", + "ShipManager": "Al Bilad Establishment", + "ShipManagerCode": "5355442" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20000701", + "LRNO": "1000021", + "Sequence": "95", + "ShipManager": "Sete Yacht Management SA", + "ShipManagerCode": "3026819" + } + ], + "ShipTypeHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "EffDate": "199312", + "LRNO": "1000021", + "Sequence": "00", + "Shiptype": "Yacht", + "ShiptypeCode": "X11A2YP" + } + ], + "SpecialFeature": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000021", + "Sequence": "001", + "SpecialFeature": "7 Bulkheads", + "SpecialFeatureCode": "0507" + } + ], + "StatusHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000021", + "Sequence": "00", + "Status": "IN SERVICE/COMMISSION", + "StatusCode": "S", + "StatusDate": "19951000" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000021", + "Sequence": "94", + "Status": "KEEL LAID", + "StatusCode": "E", + "StatusDate": "19931108" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000021", + "Sequence": "93", + "Status": "LAUNCHED", + "StatusCode": "F", + "StatusDate": "19940430" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000021", + "Sequence": "95", + "Status": "ON ORDER/NOT COMMENCED", + "StatusCode": "O", + "StatusDate": "19930700" + } + ], + "SurveyDates": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "ClassSociety": "Lloyd's Register", + "ClassSocietyCode": "LR", + "ContinuousMachinerySurvey": "2005-07-31", + "DockingSurvey": "2024-08-09", + "LRNO": "1000021", + "SpecialSurvey": "2025-07-24", + "TailShaftSurvey": "2024-12-01" + } + ], + "TechnicalManagerHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20130716", + "LRNO": "1000021", + "Sequence": "00", + "TechnicalManager": "Unknown", + "TechnicalManagerCode": "9991001" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20100212", + "LRNO": "1000021", + "Sequence": "95", + "TechnicalManager": "Knight Caledonian Ltd", + "TechnicalManagerCode": "0003243" + } + ], + "Thrusters": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000021", + "Sequence": "01", + "ThrusterType": "Thwart. FP thruster", + "ThrusterTypeCode": "FP", + "NumberOfThrusters": "1", + "ThrusterPosition": "Forward", + "ThrusterBHP": "0", + "ThrusterKW": "0", + "TypeOfInstallation": "Not Applicable" + } + ], + "TurboCharger": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Builder": "ABBTURBOSWZ", + "CountryOfBuild": "SWZ", + "Design": "BROWNBOVERI", + "LRNO": "1000021", + "Main_AuxEng": "MN", + "SequenceNumber": "1" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Builder": "ABBTURBOSWZ", + "CountryOfBuild": "SWZ", + "Design": "BROWNBOVERI", + "LRNO": "1000021", + "Main_AuxEng": "MN", + "SequenceNumber": "2" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Builder": "ABBTURBOSWZ", + "CountryOfBuild": "SWZ", + "Design": "BROWNBOVERI", + "LRNO": "1000021", + "Main_AuxEng": "MN", + "SequenceNumber": "3" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Builder": "ABBTURBOSWZ", + "CountryOfBuild": "SWZ", + "Design": "BROWNBOVERI", + "LRNO": "1000021", + "Main_AuxEng": "MN", + "SequenceNumber": "4" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Builder": "CATERPILLARUSA", + "CountryOfBuild": "USA", + "Design": "CATERPILLAR", + "LRNO": "1000021", + "Main_AuxEng": "AUX", + "SequenceNumber": "5" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Builder": "CATERPILLARUSA", + "CountryOfBuild": "USA", + "Design": "CATERPILLAR", + "LRNO": "1000021", + "Main_AuxEng": "AUX", + "SequenceNumber": "6" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000021", + "Main_AuxEng": "AUX", + "SequenceNumber": "7" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000021", + "Main_AuxEng": "AUX", + "SequenceNumber": "8" + } + ], + "CallSignAndMmsiHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Lrno": "1000021", + "SeqNo": "00", + "CallSign": "ZCCB8", + "EffectiveDate": "199312" + } + ], + "SurveyDatesHistoryUnique": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000021", + "ClassSocietyCode": "LR", + "SurveyDate": "2019-12-02", + "SurveyType": "TailshaftSurvey", + "ClassSociety": "Lloyd's Register" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000021", + "ClassSocietyCode": "LR", + "SurveyDate": "2020-07-25", + "SurveyType": "SpecialSurvey", + "ClassSociety": "Lloyd's Register" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000021", + "ClassSocietyCode": "LR", + "SurveyDate": "2021-08-10", + "SurveyType": "DockingSurvey", + "ClassSociety": "Lloyd's Register" + } + ] + }, + "APSStatus": { + "SystemVersion": "1.1.0", + "SystemDate": "2015-08-20T00:00:00", + "JobRunDate": "2025-11-07T02:03:16.9577773+00:00", + "CompletedOK": true, + "ErrorLevel": "None", + "ErrorMessage": "", + "RemedialAction": "", + "Guid": "0238ec39-c5f2-4fc4-93c8-5444b1761eaa" + } + }, + { + "shipCount": 1, + "APSShipDetail": { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "AuxiliaryEnginesNarrative": "Design: John Deere, Engine Builder: John Deere 2 x 4039DFM, 4 Stroke 4Cy. 106 x 110, Mcr: 45 kW", + "AuxiliaryGeneratorsDescriptiveNarrative": "2 x 45kW 380V 50Hz a.c.", + "BaleCapacity": "0", + "BollardPull": "0", + "BreadthExtreme": "7.500", + "BreadthMoulded": "7.200", + "CallSign": "MVWV7", + "CargoCapacitiesNarrative": "", + "CargoGradesSegregations": "0", + "ClassNarrative": "Lloyd's Register (1995-04-21)LR Class: + 100 A1 Survey Type: Special Survey Date: 2015-04 Class Notation: Yacht LR Machinery Class: LR Maltese Cross LMC LR Hull Notes: 3BH WBnil", + "ClassificationSociety": "Lloyd's Register 1995-04-21", + "ClassificationSocietyCode": "LR", + "CleanBallastCapacity": "0", + "ClearHeightOfROROLanes": "0.00", + "CompensatedGrossTonnageCGT": "0", + "ContractDate": "19930421", + "ConstructionDescriptiveNarrative": "Statcode5:X11A2YP; Hull Type:Single Hull; Hull Material:Steel; Hull Connections:Welded; Decks:1 dk", + "CoreShipInd": "1", + "CountryOfBuild": "Netherlands", + "CountryOfBuildCode": "NTH", + "DateOfBuild": "199504", + "Deadweight": "0", + "DeliveryDate": "19950512", + "Depth": "3.630", + "DischargeDiameterOfCargoManifold": "0.00", + "Displacement": "0", + "DOCCompany": " Unknown", + "DocumentOfComplianceDOCCompanyCode": "9991001", + "Draught": "0.000", + "FlagCode": "GBI", + "FlagEffectiveDate": "199612", + "FlagName": "United Kingdom", + "FormulaDWT": "312.71", + "FuelType1Capacity": "0.00", + "FuelType1Code": "YY", + "FuelType1First": "Yes, But Type Not Known", + "FuelType2Capacity": "0.00", + "FuelType2Code": "NN", + "FuelType2Second": "Not Applicable", + "GasCapacity": "0", + "GrainCapacity": "0", + "GrossTonnage": "178", + "GroupBeneficialOwner": "Manx Commercial Yacht", + "GroupBeneficialOwnerCompanyCode": "3020695", + "GroupBeneficialOwnerCountryOfControl": "United Kingdom", + "GroupBeneficialOwnerCountryOfDomicile": "United Kingdom", + "GroupBeneficialOwnerCountryOfDomicileCode": "GBI", + "GroupBeneficialOwnerCountryOfRegistration": "Isle Of Man", + "HeatingCoilsInCargoTanks": "Unknown", + "HullMaterial": "Steel (Unspecified)", + "HullMaterialCode": "ST", + "HullShapeCode": "N1", + "HullType": "Single Hull", + "HullTypeCode": "SH", + "InsulatedCapacity": "0", + "KeelLaidDate": "19931200", + "KeelToMastHeight": "0.000", + "LanesDoorsRampsNarrative": "", + "LastUpdateDate": "2024-10-23T08:44:27.47", + "LaunchDate": "19940600", + "LegalOverall": "0", + "ShipOverallComplianceStatus": "0", + "ShipDarkActivityIndicator": "0", + "ShipFlagDisputed": "0", + "ShipFlagSanctionedCountry": "0", + "ShipHistoricalFlagSanctionedCountry": "0", + "ShipOwnerHistoricalOFACSanctionedCountry": "0", + "ShipOwnerFATFJurisdiction": "0", + "ShipOwnerOFACSanctionedCountry": "0", + "ShipOwnerBESSanctionList": "0", + "ShipOwnerEUSanctionList": "0", + "ShipOwnerOFACSanctionList": "0", + "ShipOwnerUNSanctionList": "0", + "ShipOwnerParentFATFJurisdiction": "0", + "ShipOwnerParentOFACSanctionedCountry": "0", + "ShipSanctionedCountryPortCallLast12m": "0", + "ShipSanctionedCountryPortCallLast3m": "0", + "ShipSanctionedCountryPortCallLast6m": "0", + "ShipEUSanctionList": "0", + "ShipOFACNonSDNSanctionList": "0", + "ShipOFACSanctionList": "0", + "ShipUNSanctionList": "0", + "ShipOFACSSIList": "0", + "ShipOwnerCanadianSanctionList": "0", + "ShipOwnerAustralianSanctionList": "0", + "ShipUSTreasuryOFACAdvisoryList": "0", + "ShipSwissSanctionList": "0", + "ShipOwnerSwissSanctionList": "0", + "ShipSTSPartnerNonComplianceLast12m": "0", + "ShipSecurityLegalDisputeEventLast12m": "0", + "ShipDetailsNoLongerMaintained": "0", + "ShipBESSanctionList": "0", + "ShipOwnerParentCompanyNonCompliance": "0", + "ShipOwnerUAESanctionList": "0", + "LengthBetweenPerpendicularsLBP": "27.100", + "LengthOfROROLanes": "0", + "LengthOverallLOA": "31.500", + "LengthRegistered": "0.000", + "LightDisplacementTonnage": "0", + "LiquidCapacity": "0", + "IHSLRorIMOShipNo": "1000033", + "MainEngineBore": "137", + "MainEngineBuilder": "Caterpillar Inc - USA", + "MainEngineBuilderCode": "USA606572", + "MainEngineDesigner": "Caterpillar", + "MainEngineDesignerCode": "CAT", + "MainEngineDesignerGroup": "Caterpillar", + "MainEngineDesignerGroupCode": "CAT", + "MainEngineModel": "3408TA", + "MainEngineNumberOfCylinders": "8", + "MainEngineRPM": "1800", + "MainEngineStrokeType": "4", + "MainEngineType": "Oil", + "MaritimeMobileServiceIdentityMMSINumber": "234028000", + "NetTonnage": "53", + "NewconstructionEntryDate": "199304", + "NumberOfAllEngines": "4", + "NumberOfAuxiliaryEngines": "2", + "NumberOfCabins": "0", + "NumberOfDecks": "1", + "NumberOfMainEngines": "2", + "NumberOfPropulsionUnits": "2", + "NumberOfROROLanes": "0", + "NumberOfThrusters": "1", + "OfficialNumber": "728711", + "Operator": "Hill Robinson Yacht Mgmt-FRA", + "OperatorCompanyCode": "5039982", + "OperatorCountryOfControl": "France", + "OperatorCountryOfDomicileCode": "FRA", + "OperatorCountryOfDomicileName": "France", + "OperatorCountryOfRegistration": "France", + "PanamaCanalNetTonnagePCNT": "0", + "PandIClub": "Unknown", + "PandIClubCode": "9991001", + "PassengerCapacity": "0", + "PassengersBerthed": "0", + "PortOfRegistryCode": "0819", + "PortOfRegistry": "Southampton", + "PortOfRegistryFullCode": "GBI0819", + "PowerBHPIHPSHPMax": "1034", + "PowerBHPIHPSHPService": "0", + "PowerKWMax": "760", + "PowerKWService": "0", + "PrimeMoverDescriptiveNarrative": " 2 oil engines reverse reduction geared to screw shafts driving 2 FP propellers Total Power: Mcr 760kW (1,034hp)", + "PrimeMoverDescriptiveOverviewNarrative": "Design: Caterpillar (Group: Caterpillar), Engine Builder: Caterpillar Inc - USA 2 x 3408TA, 4 Stroke, Single Acting, Vee 8 Cy. 137 x 152, Mcr: 380 kW (517 hp) at 1,800 rpm", + "PropellerType": "Fixed Pitch", + "PropulsionType": "Oil Engine(s), Geared Drive", + "PropulsionTypeCode": "DG", + "ReeferPoints": "0", + "RegisteredOwner": "Jolly Maritime Sailing Ltd", + "RegisteredOwnerCode": "3019278", + "RegisteredOwnerCountryOfControl": "United Kingdom", + "RegisteredOwnerCountryOfDomicile": "Jersey", + "RegisteredOwnerCountryOfDomicileCode": "JER", + "RegisteredOwnerCountryOfRegistration": "Jersey", + "SafetyManagementCertificateAuditor": "", + "SafetyManagementCertificateConventionOrVol": "", + "SafetyManagementCertificateDateIssued": "19971101", + "SafetyManagementCertificateDOCCompany": "Unknown", + "SafetyManagementCertificateFlag": "", + "SafetyManagementCertificateIssuer": "", + "SafetyManagementCertificateOtherDescription": "", + "SafetyManagementCertificateShipName": "", + "SafetyManagementCertificateShipType": "", + "SafetyManagementCertificateSource": "LRF", + "SegregatedBallastCapacity": "0", + "ShipManager": "Hill Robinson Yacht Mgmt-FRA", + "ShipManagerCompanyCode": "5039982", + "ShipManagerCountryOfControl": "France", + "ShipManagerCountryOfDomicileName": "France", + "ShipManagerCountryOfDomicileCode": "FRA", + "ShipManagerCountryOfRegistration": "France", + "ShipName": "ASTRALIUM", + "ShipStatus": "In Service/Commission", + "ShipStatusCode": "S", + "ShipStatusEffectiveDate": "19950512", + "Shipbuilder": "Werff & Visser Irnsum", + "ShipbuilderCompanyCode": "NTH451070", + "ShipbuilderFullStyle": "Scheepswerf van der Werff en Visser - Irnsum", + "ShipbuilderSubContractor": "LOWLAND", + "ShipbuilderSubContractorCode": "NTHA70052", + "ShiptypeLevel2": "Non Merchant", + "ShiptypeLevel3": "Non Merchant", + "ShiptypeLevel4": "Yacht", + "ShiptypeLevel5": "Yacht", + "ShiptypeLevel5HullType": "Ship Shape Including Multi-Hulls", + "ShiptypeLevel5SubGroup": "Yacht", + "ShiptypeLevel5SubType": "Yacht, Private", + "SpeedMax": "0.00", + "SpeedService": "0.00", + "StatCode5": "X11A2YP", + "SuezCanalNetTonnageSCNT": "0", + "TechnicalManager": "Hill Robinson Yacht Mgmt-FRA", + "TechnicalManagerCode": "5039982", + "TechnicalManagerCountryOfControl": "France", + "TechnicalManagerCountryOfDomicile": "France", + "TechnicalManagerCountryOfDomicileCode": "FRA", + "TechnicalManagerCountryOfRegistration": "France", + "TEU": "0", + "TEUCapacity14THomogenous": "0", + "ThrustersDescriptiveNarrative": "1 Thwart. FP thruster (f)", + "TonnageEffectiveDate": "199504", + "TonnageSystem69Convention": "I", + "TonnesPerCentimetreImmersionTPCI": "0.000", + "TotalHorsepowerOfAuxiliaryGenerators": "120", + "TotalHorsepowerOfMainEngines": "1034", + "TotalKilowattsOfMainEngines": "760", + "TotalPowerOfAllEngines": "850", + "TotalPowerOfAuxiliaryEngines": "90", + "YardNumber": "433", + "YearOfBuild": "1995", + "MainEngineTypeCode": "01", + "CargoOtherType": "Not Applicable", + "CargoOtherCapacity": "", + "NuclearPowerIndicator": "N", + "AuxPropulsionIndicator": "N", + "MainEngineReEngineIndicator": "N", + "MainEngineTypeOfInstallation": "O", + "MainEngineTypeOfInstallationDecode": "ORIGINAL", + "MainEngineStrokeCycle": "SINGLE-ACTING", + "AdditionalInformation": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "TweenDeckFixed": "N", + "TweenDeckPortable": "N", + "DrillBargeInd": "N", + "LRNO": "1000033", + "ProductionVesselInd": "N" + } + ], + "AuxEngine": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Bore": "106", + "EngineDesigner": "John Deere", + "EngineModel": "4039DFM", + "EngineBuilder": "John Deere", + "EngineSequence": "1", + "LRNO": "1000033", + "MaxPower": "45", + "NumberOfCylinders": "4", + "Stroke": "110", + "StrokeType": "4" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Bore": "106", + "EngineDesigner": "John Deere", + "EngineModel": "4039DFM", + "EngineBuilder": "John Deere", + "EngineSequence": "2", + "LRNO": "1000033", + "MaxPower": "45", + "NumberOfCylinders": "4", + "Stroke": "110", + "StrokeType": "4" + } + ], + "AuxGenerator": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "ACDC": "AC", + "Frequency": "50", + "KWEach": "45.00", + "LRNO": "1000033", + "MainEngineDriven": "N", + "Number": "2", + "SEQ": "01", + "Voltage1": "380", + "Voltage2": "0" + } + ], + "BuilderAddress": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "BuilderCode": "NTH451070", + "CountryCode": "NTH", + "CountryName": "Netherlands", + "ShipBuilder": "Werff & Visser Irnsum", + "ShipBuilderFullStyle": "Scheepswerf van der Werff en Visser - Irnsum", + "Town": "Irnsum", + "TownCode": "0234", + "BuilderStatus": "Closed" + } + ], + "Capacities": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000033", + "Bale": "0", + "Horsepower": "1034", + "BollardPull": "0", + "GasCapacity": "0", + "GrainCapacity": "0", + "LiquidCapacity": "0", + "NumberOfPassengers": "0", + "NumberRefrigeratedContainers": "0", + "NumberOfTEU": "0" + } + ], + "CargoPump": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CubicMetersCapacity": "0", + "CubicTonsCapacity": "0", + "LRNO": "1000033", + "NumberOfPumps": "0", + "Sequence": "1" + } + ], + "ClassCurrent": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Class": "Not Applicable", + "ClassCode": "NN", + "ClassIndicator": "Not Applicable", + "EffectiveDate": "19950300", + "LRNO": "1000033" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Class": "Lloyd's Register", + "ClassCode": "LR", + "ClassIndicator": "Classed", + "EffectiveDate": "19950421", + "LRNO": "1000033" + } + ], + "ClassHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Class": "Not Applicable", + "ClassCode": "NN", + "ClassIndicator": "Not Applicable", + "CurrentIndicator": "Current", + "EffectiveDate": "19950300", + "LRNO": "1000033", + "Sequence": "00" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Class": "Lloyd's Register", + "ClassCode": "LR", + "ClassIndicator": "Classed", + "CurrentIndicator": "Current", + "EffectiveDate": "19950421", + "LRNO": "1000033", + "Sequence": "00" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Class": "Lloyd's Register", + "ClassCode": "LR", + "ClassIndicator": "Class contemplated", + "CurrentIndicator": "Historical", + "EffectiveDate": "19931231", + "LRNO": "1000033", + "Sequence": "95" + } + ], + "CompanyComplianceDetails": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "OwCode": "3019278", + "ShortCompanyName": "Jolly Maritime Sailing Ltd", + "CompanyOnOFACSanctionList": "0", + "CompanyOnUNSanctionList": "0", + "CompanyOnEUSanctionList": "0", + "CompanyOnBESSanctionList": "0", + "CompanyInOFACSanctionedCountry": "0", + "CompanyInFATFJurisdiction": "0", + "ParentCompanyComplianceRisk": "0", + "CompanyOverallComplianceStatus": "0", + "CompanyOnAustralianSanctionList": "0", + "CompanyOnCanadianSanctionList": "0", + "CompanyOnSwissSanctionList": "0", + "CompanyOnOFACSSIList": "0", + "CompanyOnOFACNonSDNSanctionList": "0", + "CompanyOnUAESanctionList": "0" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "OwCode": "3020695", + "ShortCompanyName": "Manx Commercial Yacht", + "CompanyOnOFACSanctionList": "0", + "CompanyOnUNSanctionList": "0", + "CompanyOnEUSanctionList": "0", + "CompanyOnBESSanctionList": "0", + "CompanyInOFACSanctionedCountry": "0", + "CompanyInFATFJurisdiction": "0", + "CompanyOverallComplianceStatus": "0", + "CompanyOnAustralianSanctionList": "0", + "CompanyOnCanadianSanctionList": "0", + "CompanyOnSwissSanctionList": "0", + "CompanyOnOFACSSIList": "0", + "CompanyOnOFACNonSDNSanctionList": "0", + "CompanyOnUAESanctionList": "0" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "OwCode": "5039982", + "ShortCompanyName": "Hill Robinson Yacht Mgmt-FRA", + "CompanyOnOFACSanctionList": "0", + "CompanyOnUNSanctionList": "0", + "CompanyOnEUSanctionList": "0", + "CompanyOnBESSanctionList": "0", + "CompanyInOFACSanctionedCountry": "0", + "CompanyInFATFJurisdiction": "0", + "CompanyOverallComplianceStatus": "0", + "CompanyOnAustralianSanctionList": "0", + "CompanyOnCanadianSanctionList": "0", + "CompanyOnSwissSanctionList": "0", + "CompanyOnOFACSSIList": "0", + "CompanyOnOFACNonSDNSanctionList": "0", + "CompanyOnUAESanctionList": "0" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "OwCode": "9991001", + "ShortCompanyName": "Unknown", + "CompanyOnOFACSanctionList": "0", + "CompanyOnUNSanctionList": "0", + "CompanyOnEUSanctionList": "0", + "CompanyOnBESSanctionList": "0", + "CompanyInOFACSanctionedCountry": "0", + "CompanyInFATFJurisdiction": "0", + "CompanyOverallComplianceStatus": "0", + "CompanyOnAustralianSanctionList": "0", + "CompanyOnCanadianSanctionList": "0", + "CompanyOnSwissSanctionList": "0", + "CompanyOnOFACSSIList": "0", + "CompanyOnOFACNonSDNSanctionList": "0", + "CompanyOnUAESanctionList": "0" + } + ], + "CompanyDetailsComplexWithCodesAndParent": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CareOfCode": "5039982", + "CompanyStatus": "Active", + "CountryName": "France", + "Emailaddress": "info@hillrobinson.com", + "FoundedDate": "1995", + "FullAddress": "C/O: Yacht Management Consultants Sarl (Hill Robinson Yacht Management Consultants) Residence du Port Vauban, 17, avenue du 11 Novembre, 06600, Antibes, France.", + "FullName": "Jolly Maritime Sailing Ltd", + "LastChangeDate": "20251008", + "LocationCode": "FRA", + "NationalityofControl": "United Kingdom", + "NationalityofControlCode": "GBI", + "NationalityofRegistration": "Jersey", + "NationalityofRegistrationCode": "JER", + "OWCODE": "3019278", + "ParentCompany": "3020695", + "PrePostcode": "06600", + "RoomFloorBuilding1": "Residence du Port Vauban,", + "ShortCompanyName": "JOLLY MARITIME SAILING LTD", + "Street": "avenue du 11 Novembre", + "StreetNumber": "17,", + "Telephone": "+33 4 92 90 59 59", + "TownName": "Antibes", + "Website": "https://www.hillrobinson.com" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "Active", + "CountryName": "United Kingdom", + "Emailaddress": "max@manxcom.com", + "FoundedDate": "1995", + "FullAddress": "47, Newtown Road, Warsash, Southampton, SO31 9FY, United Kingdom.", + "FullName": "Manx Commercial Yacht Management Ltd", + "LastChangeDate": "20240902", + "LocationCode": "GBI", + "NationalityofControl": "United Kingdom", + "NationalityofControlCode": "GBI", + "NationalityofRegistration": "Isle of Man", + "NationalityofRegistrationCode": "IOM", + "OWCODE": "3020695", + "ParentCompany": "3020695", + "PostPostcode": "SO31 9FY,", + "ShortCompanyName": "MANX COMMERCIAL YACHT", + "Street": "Newtown Road, Warsash", + "StreetNumber": "47,", + "Telephone": "+44 1489 570057", + "TownName": "Southampton" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "Active", + "CountryName": "France", + "Emailaddress": "info@hillrobinson.com", + "FoundedDate": "2001", + "FullAddress": "Residence du Port Vauban, 17, avenue du 11 Novembre, 06600, Antibes, France.", + "FullName": "Yacht Management Consultants Sarl (Hill Robinson Yacht Management Consultants)", + "LastChangeDate": "20251103", + "LocationCode": "FRA", + "NationalityofControl": "France", + "NationalityofControlCode": "FRA", + "NationalityofRegistration": "France", + "NationalityofRegistrationCode": "FRA", + "OWCODE": "5039982", + "ParentCompany": "5039982", + "PrePostcode": "06600", + "RoomFloorBuilding1": "Residence du Port Vauban,", + "ShortCompanyName": "HILL ROBINSON YACHT MGMT-FRA", + "Street": "avenue du 11 Novembre", + "StreetNumber": "17,", + "Telephone": "+33 4 92 90 59 59", + "TownName": "Antibes", + "Website": "https://www.hillrobinson.com" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "Active", + "CountryName": "Unknown", + "FoundedDate": "2018", + "FullAddress": "Unknown", + "FullName": "Unknown", + "LastChangeDate": "20251106", + "LocationCode": "UNK", + "NationalityofRegistration": "Unknown", + "NationalityofRegistrationCode": "UNK", + "OWCODE": "9991001", + "ParentCompany": "9991001", + "ShortCompanyName": "UNKNOWN" + } + ], + "CompanyFleetCounts": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "DOCCount": "0", + "FleetSize": "2", + "GroupOwnerCount": "0", + "InServiceCount": "2", + "OperatorCount": "0", + "OWCODE": "3019278", + "RegisteredOwnerCount": "2", + "ShipManagerCount": "0", + "ShortCompanyName": "Jolly Maritime Sailing Ltd", + "TechnicalManagerCount": "0" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "DOCCount": "0", + "FleetSize": "2", + "GroupOwnerCount": "2", + "InServiceCount": "2", + "OperatorCount": "1", + "OWCODE": "3020695", + "RegisteredOwnerCount": "0", + "ShipManagerCount": "0", + "ShortCompanyName": "Manx Commercial Yacht", + "TechnicalManagerCount": "0" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "DOCCount": "8", + "FleetSize": "25", + "GroupOwnerCount": "1", + "InServiceCount": "25", + "OperatorCount": "21", + "OWCODE": "5039982", + "RegisteredOwnerCount": "0", + "ShipManagerCount": "21", + "ShortCompanyName": "Hill Robinson Yacht Mgmt-FRA", + "TechnicalManagerCount": "18" + } + ], + "CompanyOrderBookCounts": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "DOCOrderbookCount": "0", + "GroupOwnerOrderbookCount": "0", + "OperatorOrderbookCount": "0", + "OWCODE": "3019278", + "RegisteredOrderbookOwnerCount": "0", + "ShipManagerOrderbookCount": "0" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "DOCOrderbookCount": "0", + "GroupOwnerOrderbookCount": "0", + "OperatorOrderbookCount": "0", + "OWCODE": "3020695", + "RegisteredOrderbookOwnerCount": "0", + "ShipManagerOrderbookCount": "0" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "DOCOrderbookCount": "0", + "GroupOwnerOrderbookCount": "0", + "OperatorOrderbookCount": "0", + "OWCODE": "5039982", + "RegisteredOrderbookOwnerCount": "0", + "ShipManagerOrderbookCount": "0" + } + ], + "CompanyVesselRelationships": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "DOCCode": "9991001", + "DOCCompany": " Unknown", + "GroupBeneficialOwner": "Manx Commercial Yacht", + "GroupBeneficialOwnerCode": "3020695", + "LRNO": "1000033", + "Operator": "Hill Robinson Yacht Mgmt-FRA", + "OperatorCode": "5039982", + "RegisteredOwner": "Jolly Maritime Sailing Ltd", + "RegisteredOwnerCode": "3019278", + "ShipManager": "Hill Robinson Yacht Mgmt-FRA", + "ShipManagerCode": "5039982", + "TechnicalManager": "Hill Robinson Yacht Mgmt-FRA", + "TechnicalManagerCode": "5039982" + } + ], + "EngineBuilder": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "EngineBuilderLargestCode": "USA606572", + "EngineBuilderShortName": "CATERPILLAR INC", + "EngineBuilderFullName": "Caterpillar Inc -USA ", + "FullAddress": "100, NE Adams Street, Peoria IL 61629-0001 ", + "TownName": "Peoria, IL", + "CountryName": "United States of America", + "Telephone": "+1 309 675 1000", + "Facsimile": "+1 309 675 4332", + "Website": "www.caterpillar.com", + "CountryCode": "USA", + "TownCode": "9069" + } + ], + "FlagHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "EffectiveDate": "199612", + "Flag": "United Kingdom ", + "FlagCode": "GBI", + "LRNO": "1000033", + "Sequence": "00" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "EffectiveDate": "199504", + "Flag": "Luxembourg ", + "FlagCode": "LUX", + "LRNO": "1000033", + "Sequence": "95" + } + ], + "GrossTonnageHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "EffectiveDate": "199504", + "GT": "178", + "LRNO": "1000033", + "Sequence": "00" + } + ], + "GroupBeneficialOwnerHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "19960612", + "GroupBeneficialOwner": "Manx Commercial Yacht", + "GroupBeneficialOwnerCode": "3020695", + "LRNO": "1000033", + "Sequence": "00" + } + ], + "LiftingGear": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "GearType": "Unknown", + "LRNO": "1000033", + "MaxSWLOfGear": "0.00", + "NumberOfGears": "0", + "Sequence": "01" + } + ], + "MainEngine": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "BHPOfMainOilEngines": "1034", + "Bore": "137", + "CylinderArrangementCode": "V", + "CylinderArrangementDecode": "VEE", + "EngineBuilder": "Caterpillar Inc - USA", + "EngineBuilderCode": "CAT", + "EngineDesigner": "Caterpillar", + "EngineMakerCode": "USA606572", + "EngineModel": "3408TA", + "EngineType": "Oil", + "LRNO": "1000033", + "NumberOfCylinders": "8", + "Position": "PORT", + "PowerBHP": "517", + "PowerKW": "380", + "RPM": "1800", + "Stroke": "152", + "StrokeType": "4" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "BHPOfMainOilEngines": "1034", + "Bore": "137", + "CylinderArrangementCode": "V", + "CylinderArrangementDecode": "VEE", + "EngineBuilder": "Caterpillar Inc - USA", + "EngineBuilderCode": "CAT", + "EngineDesigner": "Caterpillar", + "EngineMakerCode": "USA606572", + "EngineModel": "3408TA", + "EngineType": "Oil", + "LRNO": "1000033", + "NumberOfCylinders": "8", + "Position": "STARBOARD", + "PowerBHP": "517", + "PowerKW": "380", + "RPM": "1800", + "Stroke": "152", + "StrokeType": "4" + } + ], + "NameHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Effective_Date": "199504", + "LRNO": "1000033", + "Sequence": "00", + "VesselName": "ASTRALIUM" + } + ], + "OperatorHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20090720", + "LRNO": "1000033", + "Operator": "Hill Robinson Yacht Mgmt-FRA", + "OperatorCode": "5039982", + "Sequence": "00" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20080129", + "LRNO": "1000033", + "Operator": "Yachting Partners Intl Ltd", + "OperatorCode": "1593175", + "Sequence": "93" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20020000", + "LRNO": "1000033", + "Operator": "Manx Commercial Yacht", + "OperatorCode": "3020695", + "Sequence": "94" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "19960600", + "LRNO": "1000033", + "Operator": "Maxwell International", + "OperatorCode": "3019264", + "Sequence": "95" + } + ], + "OwnerHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "19960612", + "LRNO": "1000033", + "Owner": "Jolly Maritime Sailing Ltd", + "OwnerCode": "3019278", + "Sequence": "00" + } + ], + "PandIHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000033", + "Sequence": "00", + "PandIClubCode": "9991001", + "PandIClubDecode": "Unknown", + "EffectiveDate": "20060220" + } + ], + "Propellers": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000033", + "NozzleType": "Unknown", + "PropellerPosition": "Port", + "PropellerType": "Fixed Pitch", + "PropellerTypeCode": "FP", + "RPMMaximum": "0", + "RPMService": "0", + "Sequence": "01" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000033", + "NozzleType": "Unknown", + "PropellerPosition": "Starboard", + "PropellerType": "Fixed Pitch", + "PropellerTypeCode": "FP", + "RPMMaximum": "0", + "RPMService": "0", + "Sequence": "02" + } + ], + "SafetyManagementCertificateHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000033", + "SafetyManagementCertificateAuditor": "", + "SafetyManagementCertificateConventionOrVol": "", + "SafetyManagementCertificateDateIssued": "19971101", + "SafetyManagementCertificateDOCCompany": "Unknown", + "SafetyManagementCertificateFlag": "", + "SafetyManagementCertificateIssuer": "", + "SafetyManagementCertificateOtherDescription": "", + "SafetyManagementCertificateShipName": "", + "SafetyManagementCertificateShipType": "", + "SafetyManagementCertificateSource": "LRF", + "SafetyManagementCertificateCompanyCode": "9991001", + "Sequence": "00" + } + ], + "ShipBuilderDetail": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "BuilderCode": "NTH451070", + "BuilderStatus": "Closed", + "CountryCode": "NTH", + "CountryName": "Netherlands", + "Shipbuilder": "Werff & Visser Irnsum", + "ShipbuilderFullStyle": "Scheepswerf van der Werff en Visser - Irnsum", + "TownCode": "0234" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "BuilderCode": "NTHA70052", + "BuilderStatus": "Current", + "CountryCode": "NTH", + "CountryName": "Netherlands", + "Facsimile": "+31 71 5413897", + "FullAddress": "Hoge Rijndijk 211, 2382 AL Zoeterwoude ", + "Shipbuilder": "Lowland", + "ShipbuilderFullStyle": "Lowland Yachts B.V. - Zoeterwoude", + "Telephone": "+31 71 5410503", + "TownCode": "0588" + } + ], + "ShipBuilderAndSubContractor": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "BuilderCode": "NTH451070", + "LRNO": "1000033", + "Section": "Sub-Contractor (Hull)", + "SequenceNumber": "01" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "BuilderCode": "NTHA70052", + "LRNO": "1000033", + "Section": "Main Contractor", + "SequenceNumber": "02" + } + ], + "ShipManagerHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20090720", + "LRNO": "1000033", + "Sequence": "00", + "ShipManager": "Hill Robinson Yacht Mgmt-FRA", + "ShipManagerCode": "5039982" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20080129", + "LRNO": "1000033", + "Sequence": "93", + "ShipManager": "Yachting Partners Intl Ltd", + "ShipManagerCode": "1593175" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20020000", + "LRNO": "1000033", + "Sequence": "94", + "ShipManager": "Manx Commercial Yacht", + "ShipManagerCode": "3020695" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "19960600", + "LRNO": "1000033", + "Sequence": "95", + "ShipManager": "Maxwell International", + "ShipManagerCode": "3019264" + } + ], + "ShipTypeHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "EffDate": "199504", + "LRNO": "1000033", + "Sequence": "00", + "Shiptype": "Yacht", + "ShiptypeCode": "X11A2YP" + } + ], + "SpecialFeature": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000033", + "Sequence": "001", + "SpecialFeature": "3 Bulkheads", + "SpecialFeatureCode": "0503" + } + ], + "StatusHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000033", + "Sequence": "00", + "Status": "IN SERVICE/COMMISSION", + "StatusCode": "S", + "StatusDate": "19950512" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000033", + "Sequence": "94", + "Status": "KEEL LAID", + "StatusCode": "E", + "StatusDate": "19931200" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000033", + "Sequence": "93", + "Status": "LAUNCHED", + "StatusCode": "F", + "StatusDate": "19940600" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000033", + "Sequence": "95", + "Status": "ON ORDER/NOT COMMENCED", + "StatusCode": "O", + "StatusDate": "19930421" + } + ], + "SurveyDates": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "ClassSociety": "Lloyd's Register", + "ClassSocietyCode": "LR", + "DockingSurvey": "2027-03-24", + "LRNO": "1000033", + "SpecialSurvey": "2030-04-20", + "TailShaftSurvey": "2027-11-30" + } + ], + "TechnicalManagerHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20090720", + "LRNO": "1000033", + "Sequence": "00", + "TechnicalManager": "Hill Robinson Yacht Mgmt-FRA", + "TechnicalManagerCode": "5039982" + } + ], + "Thrusters": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000033", + "Sequence": "01", + "ThrusterType": "Thwart. FP thruster", + "ThrusterTypeCode": "FP", + "NumberOfThrusters": "1", + "ThrusterPosition": "Forward", + "ThrusterBHP": "0", + "ThrusterKW": "0", + "TypeOfInstallation": "Not Applicable" + } + ], + "TurboCharger": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Builder": "CATERPILLARUSA", + "CountryOfBuild": "USA", + "Design": "CATERPILLAR", + "LRNO": "1000033", + "Main_AuxEng": "MN", + "SequenceNumber": "1" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Builder": "CATERPILLARUSA", + "CountryOfBuild": "USA", + "Design": "CATERPILLAR", + "LRNO": "1000033", + "Main_AuxEng": "MN", + "SequenceNumber": "2" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Builder": "CATERPILLARUSA", + "CountryOfBuild": "USA", + "Design": "CATERPILLAR", + "LRNO": "1000033", + "Main_AuxEng": "MN", + "SequenceNumber": "3" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Builder": "CATERPILLARUSA", + "CountryOfBuild": "USA", + "Design": "CATERPILLAR", + "LRNO": "1000033", + "Main_AuxEng": "MN", + "SequenceNumber": "4" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000033", + "Main_AuxEng": "AUX", + "SequenceNumber": "5" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000033", + "Main_AuxEng": "AUX", + "SequenceNumber": "6" + } + ], + "CallSignAndMmsiHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Lrno": "1000033", + "SeqNo": "00", + "CallSign": "MVWV7", + "Mmsi": "234028000", + "EffectiveDate": "199612" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Lrno": "1000033", + "SeqNo": "95", + "EffectiveDate": "199504" + } + ], + "SurveyDatesHistoryUnique": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000033", + "ClassSocietyCode": "LR", + "SurveyDate": "2022-12-01", + "SurveyType": "TailshaftSurvey", + "ClassSociety": "Lloyd's Register" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000033", + "ClassSocietyCode": "LR", + "SurveyDate": "2024-03-25", + "SurveyType": "DockingSurvey", + "ClassSociety": "Lloyd's Register" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000033", + "ClassSocietyCode": "LR", + "SurveyDate": "2025-04-21", + "SurveyType": "SpecialSurvey", + "ClassSociety": "Lloyd's Register" + } + ] + }, + "APSStatus": { + "SystemVersion": "1.1.0", + "SystemDate": "2015-08-20T00:00:00", + "JobRunDate": "2025-11-07T02:03:20.5107134+00:00", + "CompletedOK": true, + "ErrorLevel": "None", + "ErrorMessage": "", + "RemedialAction": "", + "Guid": "6d87ff9e-3f7b-4d82-bae5-16436740a062" + } + }, + { + "shipCount": 1, + "APSShipDetail": { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "AuxiliaryEnginesNarrative": "Design: Mwm, Engine Builder: Deutz Mwm 2 x TD226B-6CD, 4 Stroke 6Cy. 105 x 120, Mcr: 75 kW", + "AuxiliaryGeneratorsDescriptiveNarrative": "2 x 70kW 380V 50Hz a.c.", + "BaleCapacity": "0", + "BollardPull": "0", + "BulbousBow": "N", + "BreadthExtreme": "7.480", + "BreadthMoulded": "7.480", + "CallSign": "SX5322", + "CargoCapacitiesNarrative": "", + "CargoGradesSegregations": "0", + "ClassNarrative": "Registro Italiano Navale (Contemplated) (2022-07-00)", + "ClassificationSociety": "Registro Italiano Navale (Contemplated) 2022-07", + "ClassificationSocietyCode": "RI", + "CleanBallastCapacity": "0", + "ClearHeightOfROROLanes": "0.00", + "CompensatedGrossTonnageCGT": "0", + "ContractDate": "19930429", + "ConstructionDescriptiveNarrative": "Statcode5:X11A2YP; Hull Type:Single Hull; Hull Material:Steel; Hull Connections:Welded; Decks:1 dk", + "CoreShipInd": "1", + "CountryOfBuild": "Italy", + "CountryOfBuildCode": "ITL", + "DateOfBuild": "199504", + "Deadweight": "0", + "DeliveryDate": "19950429", + "Depth": "4.250", + "DischargeDiameterOfCargoManifold": "0.00", + "Displacement": "0", + "DOCCompany": " Unknown", + "DocumentOfComplianceDOCCompanyCode": "9991001", + "Draught": "2.000", + "FlagCode": "GRC", + "FlagEffectiveDate": "199702", + "FlagName": "Greece", + "FormulaDWT": "456.71", + "FuelType1Code": "YY", + "FuelType1First": "Yes, But Type Not Known", + "FuelType2Capacity": "0.00", + "FuelType2Code": "NN", + "FuelType2Second": "Not Applicable", + "GasCapacity": "0", + "GrainCapacity": "0", + "GrossTonnage": "264", + "GroupBeneficialOwner": " Unknown", + "GroupBeneficialOwnerCompanyCode": "9991001", + "HeatingCoilsInCargoTanks": "Unknown", + "HullMaterial": "Steel (Unspecified)", + "HullMaterialCode": "ST", + "HullShapeCode": "N1", + "HullType": "Single Hull", + "HullTypeCode": "SH", + "InmarsatNumberSatCommID": "623948810", + "InsulatedCapacity": "0", + "KeelLaidDate": "19940419", + "KeelToMastHeight": "0.000", + "LanesDoorsRampsNarrative": "", + "LastUpdateDate": "2022-08-05T21:35:04.62", + "LaunchDate": "19950400", + "LegalOverall": "0", + "ShipOverallComplianceStatus": "0", + "ShipDarkActivityIndicator": "0", + "ShipFlagDisputed": "0", + "ShipFlagSanctionedCountry": "0", + "ShipHistoricalFlagSanctionedCountry": "0", + "ShipOwnerHistoricalOFACSanctionedCountry": "0", + "ShipOwnerFATFJurisdiction": "0", + "ShipOwnerOFACSanctionedCountry": "0", + "ShipOwnerBESSanctionList": "0", + "ShipOwnerEUSanctionList": "0", + "ShipOwnerOFACSanctionList": "0", + "ShipOwnerUNSanctionList": "0", + "ShipSanctionedCountryPortCallLast12m": "0", + "ShipSanctionedCountryPortCallLast3m": "0", + "ShipSanctionedCountryPortCallLast6m": "0", + "ShipEUSanctionList": "0", + "ShipOFACNonSDNSanctionList": "0", + "ShipOFACSanctionList": "0", + "ShipUNSanctionList": "0", + "ShipOFACSSIList": "0", + "ShipOwnerCanadianSanctionList": "0", + "ShipOwnerAustralianSanctionList": "0", + "ShipUSTreasuryOFACAdvisoryList": "0", + "ShipSwissSanctionList": "0", + "ShipOwnerSwissSanctionList": "0", + "ShipSTSPartnerNonComplianceLast12m": "0", + "ShipSecurityLegalDisputeEventLast12m": "0", + "ShipDetailsNoLongerMaintained": "0", + "ShipBESSanctionList": "0", + "ShipOwnerParentCompanyNonCompliance": "0", + "ShipOwnerUAESanctionList": "0", + "LengthBetweenPerpendicularsLBP": "32.540", + "LengthOfROROLanes": "0", + "LengthOverallLOA": "38.500", + "LengthRegistered": "0.000", + "LightDisplacementTonnage": "0", + "LiquidCapacity": "0", + "IHSLRorIMOShipNo": "1000045", + "MainEngineBore": "165", + "MainEngineBuilder": "MTU Friedrichshafen GmbH - Friedrichshafen", + "MainEngineBuilderCode": "GEU530552", + "MainEngineDesigner": "M.T.U.", + "MainEngineDesignerCode": "MTU", + "MainEngineDesignerGroup": "Rolls Royce", + "MainEngineDesignerGroupCode": "ROL", + "MainEngineModel": "12V396TE94", + "MainEngineNumberOfCylinders": "12", + "MainEngineRPM": "2100", + "MainEngineStrokeType": "4", + "MainEngineType": "Oil", + "MaritimeMobileServiceIdentityMMSINumber": "239488000", + "NetTonnage": "79", + "NewconstructionEntryDate": "199304", + "NumberOfAllEngines": "4", + "NumberOfAuxiliaryEngines": "2", + "NumberOfCabins": "0", + "NumberOfDecks": "1", + "NumberOfMainEngines": "2", + "NumberOfPropulsionUnits": "2", + "NumberOfROROLanes": "0", + "NumberOfThrusters": "1", + "OfficialNumber": "10455", + "Operator": "Kastro Agency Ltd", + "OperatorCompanyCode": "5019446", + "OperatorCountryOfDomicileCode": "GRC", + "OperatorCountryOfDomicileName": "Greece", + "OperatorCountryOfRegistration": "Greece", + "PanamaCanalNetTonnagePCNT": "0", + "PandIClub": "Unknown", + "PandIClubCode": "9991001", + "PassengerCapacity": "0", + "PassengersBerthed": "0", + "PortOfRegistryCode": "0825", + "PortOfRegistry": "Piraeus", + "PortOfRegistryFullCode": "GRC0825", + "PowerBHPIHPSHPMax": "4568", + "PowerBHPIHPSHPService": "0", + "PowerKWMax": "3360", + "PowerKWService": "0", + "PrimeMoverDescriptiveNarrative": "2 oil engines with clutches, flexible couplings & single reduction reverse geared to screw shafts driving 2 FP propellers at 562 rpm Total Power: Mcr 3,360kW (4,568hp)Max. Speed: 19.00kts, Service Speed: 15.00kts", + "PrimeMoverDescriptiveOverviewNarrative": "Design: M.T.U. (Group: Rolls Royce), Engine Builder: MTU Friedrichshafen GmbH - Friedrichshafen 2 x 12V396TE94, 4 Stroke, Single Acting, Vee 12 Cy. 165 x 185, Mcr: 1,680 kW (2,284 hp) at 2,100 rpm", + "PropellerType": "Fixed Pitch", + "PropulsionType": "Oil Engine(s), Geared Drive", + "PropulsionTypeCode": "DG", + "ReeferPoints": "0", + "RegisteredOwner": "Kastro Agency Ltd", + "RegisteredOwnerCode": "5019446", + "RegisteredOwnerCountryOfDomicile": "Greece", + "RegisteredOwnerCountryOfDomicileCode": "GRC", + "RegisteredOwnerCountryOfRegistration": "Greece", + "SafetyManagementCertificateAuditor": "", + "SafetyManagementCertificateConventionOrVol": "", + "SafetyManagementCertificateDateIssued": "19971101", + "SafetyManagementCertificateDOCCompany": "Unknown", + "SafetyManagementCertificateFlag": "", + "SafetyManagementCertificateIssuer": "", + "SafetyManagementCertificateOtherDescription": "", + "SafetyManagementCertificateShipName": "", + "SafetyManagementCertificateShipType": "", + "SafetyManagementCertificateSource": "LRF", + "SegregatedBallastCapacity": "0", + "ShipManager": "Kastro Agency Ltd", + "ShipManagerCompanyCode": "5019446", + "ShipManagerCountryOfDomicileName": "Greece", + "ShipManagerCountryOfDomicileCode": "GRC", + "ShipManagerCountryOfRegistration": "Greece", + "ShipName": "OKTANA", + "ShipStatus": "In Service/Commission", + "ShipStatusCode": "S", + "ShipStatusEffectiveDate": "19950429", + "Shipbuilder": "Codecasa Tre", + "ShipbuilderCompanyCode": "ITL281055", + "ShipbuilderFullStyle": "Codecasa Tre SpA - Viareggio", + "ShiptypeLevel2": "Non Merchant", + "ShiptypeLevel3": "Non Merchant", + "ShiptypeLevel4": "Yacht", + "ShiptypeLevel5": "Yacht", + "ShiptypeLevel5HullType": "Ship Shape Including Multi-Hulls", + "ShiptypeLevel5SubGroup": "Yacht", + "ShiptypeLevel5SubType": "Yacht, Private", + "SpeedMax": "19.00", + "SpeedService": "15.00", + "StatCode5": "X11A2YP", + "SuezCanalNetTonnageSCNT": "0", + "TechnicalManager": " Unknown", + "TechnicalManagerCode": "9991001", + "TEU": "0", + "TEUCapacity14THomogenous": "0", + "ThrustersDescriptiveNarrative": "1 Thwart. FP thruster (f)", + "TonnageEffectiveDate": "199400", + "TonnageSystem69Convention": "I", + "TonnesPerCentimetreImmersionTPCI": "0.000", + "TotalHorsepowerOfAuxiliaryGenerators": "188", + "TotalHorsepowerOfMainEngines": "4568", + "TotalKilowattsOfMainEngines": "3360", + "TotalPowerOfAllEngines": "3510", + "TotalPowerOfAuxiliaryEngines": "150", + "YardNumber": "104", + "YearOfBuild": "1995", + "MainEngineTypeCode": "01", + "CargoOtherType": "Not Applicable", + "CargoOtherCapacity": "", + "NuclearPowerIndicator": "N", + "AuxPropulsionIndicator": "N", + "MainEngineReEngineIndicator": "N", + "MainEngineTypeOfInstallation": "O", + "MainEngineTypeOfInstallationDecode": "ORIGINAL", + "MainEngineStrokeCycle": "SINGLE-ACTING", + "AdditionalInformation": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "TweenDeckFixed": "N", + "TweenDeckPortable": "N", + "DrillBargeInd": "N", + "LRNO": "1000045", + "ProductionVesselInd": "N", + "SatComID": "623948810" + } + ], + "AuxEngine": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Bore": "105", + "EngineDesigner": "Mwm", + "EngineModel": "TD226B-6CD", + "EngineBuilder": "Deutz Mwm", + "EngineSequence": "1", + "LRNO": "1000045", + "MaxPower": "75", + "NumberOfCylinders": "6", + "Stroke": "120", + "StrokeType": "4" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Bore": "105", + "EngineDesigner": "Mwm", + "EngineModel": "TD226B-6CD", + "EngineBuilder": "Deutz Mwm", + "EngineSequence": "2", + "LRNO": "1000045", + "MaxPower": "75", + "NumberOfCylinders": "6", + "Stroke": "120", + "StrokeType": "4" + } + ], + "AuxGenerator": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "ACDC": "AC", + "Frequency": "50", + "KWEach": "70.00", + "LRNO": "1000045", + "MainEngineDriven": "N", + "Number": "2", + "SEQ": "01", + "Voltage1": "380", + "Voltage2": "0" + } + ], + "BuilderAddress": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "BuilderCode": "ITL281055", + "CountryCode": "ITL", + "CountryName": "Italy", + "EmailAddress": "info@codecasayachts.com", + "Facsimile": "+39 0584 383531", + "FullAddress": "Via Amendola, 55049 Viareggio LU ", + "ShipBuilder": "Codecasa Tre", + "ShipBuilderFullStyle": "Codecasa Tre SpA - Viareggio", + "Telephone": "+39 0584 383221", + "Town": "Viareggio", + "TownCode": "0499", + "Website": "www.codecasayachts.com", + "BuilderStatus": "Current" + } + ], + "Capacities": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000045", + "Bale": "0", + "Horsepower": "4568", + "BollardPull": "0", + "GasCapacity": "0", + "GrainCapacity": "0", + "LiquidCapacity": "0", + "NumberOfPassengers": "0", + "NumberRefrigeratedContainers": "0", + "NumberOfTEU": "0" + } + ], + "CargoPump": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CubicMetersCapacity": "0", + "CubicTonsCapacity": "0", + "LRNO": "1000045", + "NumberOfPumps": "0", + "Sequence": "1" + } + ], + "ClassCurrent": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Class": "Not Applicable", + "ClassCode": "NN", + "ClassIndicator": "Not Applicable", + "EffectiveDate": "19940000", + "LRNO": "1000045" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Class": "Registro Italiano Navale", + "ClassCode": "RI", + "ClassIndicator": "Class contemplated", + "EffectiveDate": "20220700", + "LRNO": "1000045" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Class": "Lloyd's Register", + "ClassCode": "LR", + "ClassIndicator": "Disclassed", + "EffectiveDate": "20220705", + "LRNO": "1000045" + } + ], + "ClassHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Class": "Not Applicable", + "ClassCode": "NN", + "ClassIndicator": "Not Applicable", + "CurrentIndicator": "Current", + "EffectiveDate": "19940000", + "LRNO": "1000045", + "Sequence": "00" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Class": "Registro Italiano Navale", + "ClassCode": "RI", + "ClassIndicator": "Class contemplated", + "CurrentIndicator": "Current", + "EffectiveDate": "20220700", + "LRNO": "1000045", + "Sequence": "00" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Class": "Lloyd's Register", + "ClassCode": "LR", + "ClassIndicator": "Disclassed", + "CurrentIndicator": "Current", + "EffectiveDate": "20220705", + "LRNO": "1000045", + "Sequence": "00" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Class": "Lloyd's Register", + "ClassCode": "LR", + "ClassIndicator": "Classed", + "CurrentIndicator": "Historical", + "EffectiveDate": "19950429", + "LRNO": "1000045", + "Sequence": "94" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Class": "Lloyd's Register", + "ClassCode": "LR", + "ClassIndicator": "Class contemplated", + "CurrentIndicator": "Historical", + "EffectiveDate": "19940419", + "LRNO": "1000045", + "Sequence": "95" + } + ], + "CompanyComplianceDetails": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "OwCode": "5019446", + "ShortCompanyName": "Kastro Agency Ltd", + "CompanyOnOFACSanctionList": "0", + "CompanyOnUNSanctionList": "0", + "CompanyOnEUSanctionList": "0", + "CompanyOnBESSanctionList": "0", + "CompanyInOFACSanctionedCountry": "0", + "CompanyInFATFJurisdiction": "0", + "CompanyOverallComplianceStatus": "0", + "CompanyOnAustralianSanctionList": "0", + "CompanyOnCanadianSanctionList": "0", + "CompanyOnSwissSanctionList": "0", + "CompanyOnOFACSSIList": "0", + "CompanyOnOFACNonSDNSanctionList": "0", + "CompanyOnUAESanctionList": "0" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "OwCode": "9991001", + "ShortCompanyName": "Unknown", + "CompanyOnOFACSanctionList": "0", + "CompanyOnUNSanctionList": "0", + "CompanyOnEUSanctionList": "0", + "CompanyOnBESSanctionList": "0", + "CompanyInOFACSanctionedCountry": "0", + "CompanyInFATFJurisdiction": "0", + "CompanyOverallComplianceStatus": "0", + "CompanyOnAustralianSanctionList": "0", + "CompanyOnCanadianSanctionList": "0", + "CompanyOnSwissSanctionList": "0", + "CompanyOnOFACSSIList": "0", + "CompanyOnOFACNonSDNSanctionList": "0", + "CompanyOnUAESanctionList": "0" + } + ], + "CompanyDetailsComplexWithCodesAndParent": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "Active", + "CountryName": "Greece", + "FoundedDate": "1987", + "FullAddress": "85, Akti Miaouli, 185 38, Piraeus, Greece.", + "FullName": "Kastro Agency Ltd", + "LastChangeDate": "20240911", + "LocationCode": "GRC", + "NationalityofRegistration": "Greece", + "NationalityofRegistrationCode": "GRC", + "OWCODE": "5019446", + "ParentCompany": "5019446", + "PrePostcode": "185 38", + "ShortCompanyName": "KASTRO AGENCY LTD", + "Street": "Akti Miaouli", + "StreetNumber": "85,", + "Telephone": "+30 210 429 0100", + "TownName": "Piraeus" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "Active", + "CountryName": "Unknown", + "FoundedDate": "2018", + "FullAddress": "Unknown", + "FullName": "Unknown", + "LastChangeDate": "20251106", + "LocationCode": "UNK", + "NationalityofRegistration": "Unknown", + "NationalityofRegistrationCode": "UNK", + "OWCODE": "9991001", + "ParentCompany": "9991001", + "ShortCompanyName": "UNKNOWN" + } + ], + "CompanyFleetCounts": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "DOCCount": "0", + "FleetSize": "2", + "GroupOwnerCount": "0", + "InServiceCount": "2", + "OperatorCount": "2", + "OWCODE": "5019446", + "RegisteredOwnerCount": "2", + "ShipManagerCount": "2", + "ShortCompanyName": "Kastro Agency Ltd", + "TechnicalManagerCount": "0" + } + ], + "CompanyOrderBookCounts": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "DOCOrderbookCount": "0", + "GroupOwnerOrderbookCount": "0", + "OperatorOrderbookCount": "0", + "OWCODE": "5019446", + "RegisteredOrderbookOwnerCount": "0", + "ShipManagerOrderbookCount": "0" + } + ], + "CompanyVesselRelationships": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "DOCCode": "9991001", + "DOCCompany": " Unknown", + "GroupBeneficialOwner": " Unknown", + "GroupBeneficialOwnerCode": "9991001", + "LRNO": "1000045", + "Operator": "Kastro Agency Ltd", + "OperatorCode": "5019446", + "RegisteredOwner": "Kastro Agency Ltd", + "RegisteredOwnerCode": "5019446", + "ShipManager": "Kastro Agency Ltd", + "ShipManagerCode": "5019446", + "TechnicalManager": " Unknown", + "TechnicalManagerCode": "9991001" + } + ], + "EngineBuilder": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "EngineBuilderLargestCode": "GEU530552", + "EngineBuilderShortName": "MTU FRIEDRICHSHAFEN GMBH - GEU", + "EngineBuilderFullName": "MTU Friedrichshafen GmbH -Friedrichshafen ", + "FullAddress": "Postfach 2040, 88040 Friedrichshafen. Olgastrasse 75, 88045 Friedrichshafen ", + "TownName": "Friedrichshafen", + "CountryName": "Germany", + "Telephone": "+49 7541 900", + "Facsimile": "+49 7541 905000", + "Telex": "07342800mtu", + "CountryCode": "GEU", + "TownCode": "8132" + } + ], + "FlagHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "EffectiveDate": "199702", + "Flag": "Greece ", + "FlagCode": "GRC", + "LRNO": "1000045", + "Sequence": "00" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "EffectiveDate": "199400", + "Flag": "Bermuda (British)", + "FlagCode": "BER", + "LRNO": "1000045", + "Sequence": "95" + } + ], + "GrossTonnageHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "EffectiveDate": "199400", + "GT": "264", + "LRNO": "1000045", + "Sequence": "00" + } + ], + "GroupBeneficialOwnerHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "19951123", + "GroupBeneficialOwner": "Unknown", + "GroupBeneficialOwnerCode": "9991001", + "LRNO": "1000045", + "Sequence": "00" + } + ], + "LiftingGear": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "GearType": "Unknown", + "LRNO": "1000045", + "MaxSWLOfGear": "0.00", + "NumberOfGears": "0", + "Sequence": "01" + } + ], + "MainEngine": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "BHPOfMainOilEngines": "4568", + "Bore": "165", + "CylinderArrangementCode": "V", + "CylinderArrangementDecode": "VEE", + "EngineBuilder": "MTU Friedrichshafen GmbH - Friedrichshafen", + "EngineBuilderCode": "MTU", + "EngineDesigner": "M.T.U.", + "EngineMakerCode": "GEU530552", + "EngineModel": "12V396TE94", + "EngineType": "Oil", + "LRNO": "1000045", + "NumberOfCylinders": "12", + "Position": "PORT", + "PowerBHP": "2284", + "PowerKW": "1680", + "RPM": "2100", + "Stroke": "185", + "StrokeType": "4" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "BHPOfMainOilEngines": "4568", + "Bore": "165", + "CylinderArrangementCode": "V", + "CylinderArrangementDecode": "VEE", + "EngineBuilder": "MTU Friedrichshafen GmbH - Friedrichshafen", + "EngineBuilderCode": "MTU", + "EngineDesigner": "M.T.U.", + "EngineMakerCode": "GEU530552", + "EngineModel": "12V396TE94", + "EngineType": "Oil", + "LRNO": "1000045", + "NumberOfCylinders": "12", + "Position": "STARBOARD", + "PowerBHP": "2284", + "PowerKW": "1680", + "RPM": "2100", + "Stroke": "185", + "StrokeType": "4" + } + ], + "NameHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Effective_Date": "199505", + "LRNO": "1000045", + "Sequence": "00", + "VesselName": "OKTANA" + } + ], + "OperatorHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20030200", + "LRNO": "1000045", + "Operator": "Kastro Agency Ltd", + "OperatorCode": "5019446", + "Sequence": "00" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "19951100", + "LRNO": "1000045", + "Operator": "Amazon Marine Ltd", + "OperatorCode": "3020121", + "Sequence": "95" + } + ], + "OwnerHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20030214", + "LRNO": "1000045", + "Owner": "Kastro Agency Ltd", + "OwnerCode": "5019446", + "Sequence": "00" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "19951123", + "LRNO": "1000045", + "Owner": "Amazon Marine Ltd", + "OwnerCode": "3020121", + "Sequence": "95" + } + ], + "PandIHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000045", + "Sequence": "00", + "PandIClubCode": "9991001", + "PandIClubDecode": "Unknown", + "EffectiveDate": "20060220" + } + ], + "Propellers": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000045", + "NozzleType": "Unknown", + "PropellerPosition": "Port", + "PropellerType": "Fixed Pitch", + "PropellerTypeCode": "FP", + "RPMMaximum": "562", + "RPMService": "0", + "Sequence": "01" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000045", + "NozzleType": "Unknown", + "PropellerPosition": "Starboard", + "PropellerType": "Fixed Pitch", + "PropellerTypeCode": "FP", + "RPMMaximum": "562", + "RPMService": "0", + "Sequence": "02" + } + ], + "SafetyManagementCertificateHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000045", + "SafetyManagementCertificateAuditor": "", + "SafetyManagementCertificateConventionOrVol": "", + "SafetyManagementCertificateDateIssued": "19971101", + "SafetyManagementCertificateDOCCompany": "Unknown", + "SafetyManagementCertificateFlag": "", + "SafetyManagementCertificateIssuer": "", + "SafetyManagementCertificateOtherDescription": "", + "SafetyManagementCertificateShipName": "", + "SafetyManagementCertificateShipType": "", + "SafetyManagementCertificateSource": "LRF", + "SafetyManagementCertificateCompanyCode": "9991001", + "Sequence": "00" + } + ], + "ShipBuilderDetail": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "BuilderCode": "ITL281055", + "BuilderStatus": "Current", + "CountryCode": "ITL", + "CountryName": "Italy", + "EmailAddress": "info@codecasayachts.com", + "Facsimile": "+39 0584 383531", + "FullAddress": "Via Amendola, 55049 Viareggio LU ", + "Shipbuilder": "Codecasa Tre", + "ShipbuilderFullStyle": "Codecasa Tre SpA - Viareggio", + "Telephone": "+39 0584 383221", + "TownCode": "0499", + "Website": "www.codecasayachts.com" + } + ], + "ShipBuilderAndSubContractor": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "BuilderCode": "ITL281055", + "LRNO": "1000045", + "Section": "Whole Ship", + "SequenceNumber": "01" + } + ], + "ShipManagerHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20030200", + "LRNO": "1000045", + "Sequence": "00", + "ShipManager": "Kastro Agency Ltd", + "ShipManagerCode": "5019446" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "19951100", + "LRNO": "1000045", + "Sequence": "95", + "ShipManager": "Amazon Marine Ltd", + "ShipManagerCode": "3020121" + } + ], + "ShipTypeHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "EffDate": "199400", + "LRNO": "1000045", + "Sequence": "00", + "Shiptype": "Yacht", + "ShiptypeCode": "X11A2YP" + } + ], + "SpecialFeature": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000045", + "Sequence": "001", + "SpecialFeature": "4 Bulkheads", + "SpecialFeatureCode": "0504" + } + ], + "StatusHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000045", + "Sequence": "00", + "Status": "IN SERVICE/COMMISSION", + "StatusCode": "S", + "StatusDate": "19950429" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000045", + "Sequence": "94", + "Status": "KEEL LAID", + "StatusCode": "E", + "StatusDate": "19940419" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000045", + "Sequence": "93", + "Status": "LAUNCHED", + "StatusCode": "F", + "StatusDate": "19950400" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000045", + "Sequence": "95", + "Status": "ON ORDER/NOT COMMENCED", + "StatusCode": "O", + "StatusDate": "19930429" + } + ], + "SurveyDates": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "ClassSociety": "Lloyd's Register", + "ClassSocietyCode": "LR", + "DockingSurvey": "Not recorded", + "LRNO": "1000045", + "SpecialSurvey": "Not recorded", + "TailShaftSurvey": "Not recorded" + } + ], + "TechnicalManagerHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "19971101", + "LRNO": "1000045", + "Sequence": "00", + "TechnicalManager": "Unknown", + "TechnicalManagerCode": "9991001" + } + ], + "Thrusters": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000045", + "Sequence": "01", + "ThrusterType": "Thwart. FP thruster", + "ThrusterTypeCode": "FP", + "NumberOfThrusters": "1", + "ThrusterPosition": "Forward", + "ThrusterBHP": "0", + "ThrusterKW": "0", + "TypeOfInstallation": "Not Applicable" + } + ], + "CallSignAndMmsiHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Lrno": "1000045", + "SeqNo": "00", + "CallSign": "SX5322", + "Mmsi": "239488000", + "EffectiveDate": "199702" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Lrno": "1000045", + "SeqNo": "95", + "EffectiveDate": "199400" + } + ] + }, + "APSStatus": { + "SystemVersion": "1.1.0", + "SystemDate": "2015-08-20T00:00:00", + "JobRunDate": "2025-11-07T02:03:24.4675587+00:00", + "CompletedOK": true, + "ErrorLevel": "None", + "ErrorMessage": "", + "RemedialAction": "", + "Guid": "b0bf1776-aeac-440b-a191-087d2ce3ff5c" + } + }, + { + "shipCount": 1, + "APSShipDetail": { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "BaleCapacity": "0", + "BollardPull": "0", + "BreadthExtreme": "10.800", + "BreadthMoulded": "10.500", + "CallSign": "MRPW2", + "CargoCapacitiesNarrative": "", + "CargoGradesSegregations": "0", + "ClassNarrative": "Lloyd's Register (1993-06-30)LR Class: + 100 A1 Survey Type: Special Survey Date: 2018-06 Class Notation: Yacht LR Machinery Class: LR Maltese Cross LMC", + "ClassificationSociety": "Lloyd's Register 1993-06-30", + "ClassificationSocietyCode": "LR", + "CleanBallastCapacity": "0", + "ClearHeightOfROROLanes": "0.00", + "CompensatedGrossTonnageCGT": "0", + "ContractDate": "19910600", + "ConstructionDescriptiveNarrative": "Statcode5:X11A2YP; Hull Type:Single Hull; Hull Material:Steel; Hull Connections:Welded; Decks:1 dk", + "CoreShipInd": "1", + "CountryOfBuild": "Netherlands", + "CountryOfBuildCode": "NTH", + "DateOfBuild": "199306", + "Deadweight": "0", + "DeliveryDate": "19930600", + "Depth": "5.400", + "DischargeDiameterOfCargoManifold": "0.00", + "Displacement": "0", + "DOCCompany": " Unknown", + "DocumentOfComplianceDOCCompanyCode": "9991001", + "Draught": "3.200", + "FlagCode": "IOM", + "FlagEffectiveDate": "199306", + "FlagName": "Isle Of Man", + "FormulaDWT": "1339.27", + "FuelType1Code": "YY", + "FuelType1First": "Yes, But Type Not Known", + "FuelType2Capacity": "0.00", + "FuelType2Code": "NN", + "FuelType2Second": "Not Applicable", + "GasCapacity": "0", + "GrainCapacity": "0", + "GrossTonnage": "970", + "GroupBeneficialOwner": " Unknown", + "GroupBeneficialOwnerCompanyCode": "9991001", + "HeatingCoilsInCargoTanks": "Unknown", + "HullMaterial": "Steel (Unspecified)", + "HullMaterialCode": "ST", + "HullShapeCode": "N1", + "HullType": "Single Hull", + "HullTypeCode": "SH", + "InmarsatNumberSatCommID": "1447511", + "InsulatedCapacity": "0", + "KeelToMastHeight": "0.000", + "LanesDoorsRampsNarrative": "", + "LastUpdateDate": "2025-04-08T21:43:06.607", + "LegalOverall": "1", + "ShipOverallComplianceStatus": "1", + "ShipDarkActivityIndicator": "0", + "ShipFlagDisputed": "0", + "ShipFlagSanctionedCountry": "0", + "ShipHistoricalFlagSanctionedCountry": "0", + "ShipOwnerHistoricalOFACSanctionedCountry": "0", + "ShipOwnerFATFJurisdiction": "1", + "ShipOwnerOFACSanctionedCountry": "0", + "ShipOwnerBESSanctionList": "0", + "ShipOwnerEUSanctionList": "0", + "ShipOwnerOFACSanctionList": "0", + "ShipOwnerUNSanctionList": "0", + "ShipSanctionedCountryPortCallLast12m": "0", + "ShipSanctionedCountryPortCallLast3m": "0", + "ShipSanctionedCountryPortCallLast6m": "0", + "ShipEUSanctionList": "0", + "ShipOFACNonSDNSanctionList": "0", + "ShipOFACSanctionList": "0", + "ShipUNSanctionList": "0", + "ShipOFACSSIList": "0", + "ShipOwnerCanadianSanctionList": "0", + "ShipOwnerAustralianSanctionList": "0", + "ShipUSTreasuryOFACAdvisoryList": "0", + "ShipSwissSanctionList": "0", + "ShipOwnerSwissSanctionList": "0", + "ShipSTSPartnerNonComplianceLast12m": "0", + "ShipSecurityLegalDisputeEventLast12m": "0", + "ShipDetailsNoLongerMaintained": "0", + "ShipBESSanctionList": "0", + "ShipOwnerParentCompanyNonCompliance": "0", + "ShipOwnerUAESanctionList": "0", + "LengthBetweenPerpendicularsLBP": "53.500", + "LengthOfROROLanes": "0", + "LengthOverallLOA": "60.000", + "LengthRegistered": "0.000", + "LightDisplacementTonnage": "0", + "LiquidCapacity": "0", + "IHSLRorIMOShipNo": "1000069", + "MainEngineBore": "170", + "MainEngineBuilder": "Caterpillar Inc - USA", + "MainEngineBuilderCode": "USA606572", + "MainEngineDesigner": "Caterpillar", + "MainEngineDesignerCode": "CAT", + "MainEngineDesignerGroup": "Caterpillar", + "MainEngineDesignerGroupCode": "CAT", + "MainEngineModel": "3516TA", + "MainEngineNumberOfCylinders": "16", + "MainEngineRPM": "1300", + "MainEngineStrokeType": "4", + "MainEngineType": "Oil", + "MaritimeMobileServiceIdentityMMSINumber": "233219000", + "NetTonnage": "291", + "NewconstructionEntryDate": "199106", + "NumberOfAllEngines": "2", + "NumberOfCabins": "0", + "NumberOfDecks": "1", + "NumberOfMainEngines": "2", + "NumberOfPropulsionUnits": "2", + "NumberOfROROLanes": "0", + "OfficialNumber": "723617", + "Operator": "YCO SAM", + "OperatorCompanyCode": "5073856", + "OperatorCountryOfControl": "United Kingdom", + "OperatorCountryOfDomicileCode": "MON", + "OperatorCountryOfDomicileName": "Monaco", + "OperatorCountryOfRegistration": "Monaco", + "PanamaCanalNetTonnagePCNT": "0", + "PandIClub": "Unknown", + "PandIClubCode": "9991001", + "PassengerCapacity": "0", + "PassengersBerthed": "0", + "PortOfRegistryCode": "3575", + "PortOfRegistry": "Douglas ", + "PortOfRegistryFullCode": "IOM3575", + "PowerBHPIHPSHPMax": "3266", + "PowerBHPIHPSHPService": "0", + "PowerKWMax": "2402", + "PowerKWService": "0", + "PrimeMoverDescriptiveNarrative": "2 oil engines driving 2 FP propellers Total Power: Mcr 2,402kW (3,266hp)Max. Speed: 18.00kts, Service Speed: 16.00kts", + "PrimeMoverDescriptiveOverviewNarrative": "Design: Caterpillar (Group: Caterpillar), Engine Builder: Caterpillar Inc - USA 2 x 3516TA, 4 Stroke, Single Acting, Vee 16 Cy. 170 x 190, Mcr: 1,201 kW (1,633 hp) at 1,300 rpm", + "PropellerType": "Fixed Pitch", + "PropulsionType": "Oil Engine(s), Geared Drive", + "PropulsionTypeCode": "DG", + "ReeferPoints": "0", + "RegisteredOwner": "Kizbel Bermuda Ltd", + "RegisteredOwnerCode": "0175341", + "RegisteredOwnerCountryOfDomicile": "Bermuda", + "RegisteredOwnerCountryOfDomicileCode": "BER", + "RegisteredOwnerCountryOfRegistration": "Bermuda", + "SafetyManagementCertificateAuditor": "", + "SafetyManagementCertificateConventionOrVol": "", + "SafetyManagementCertificateDateIssued": "19971101", + "SafetyManagementCertificateDOCCompany": "Unknown", + "SafetyManagementCertificateFlag": "", + "SafetyManagementCertificateIssuer": "", + "SafetyManagementCertificateOtherDescription": "", + "SafetyManagementCertificateShipName": "", + "SafetyManagementCertificateShipType": "", + "SafetyManagementCertificateSource": "LRF", + "SegregatedBallastCapacity": "0", + "ShipManager": "YCO SAM", + "ShipManagerCompanyCode": "5073856", + "ShipManagerCountryOfControl": "United Kingdom", + "ShipManagerCountryOfDomicileName": "Monaco", + "ShipManagerCountryOfDomicileCode": "MON", + "ShipManagerCountryOfRegistration": "Monaco", + "ShipName": "LADY BEATRICE", + "ShipStatus": "In Service/Commission", + "ShipStatusCode": "S", + "ShipStatusEffectiveDate": "19930600", + "Shipbuilder": "Lent Jacht/Scheepswerf", + "ShipbuilderCompanyCode": "NTH408051", + "ShipbuilderFullStyle": "Jacht- en Scheepswerf C. van Lent & Zonen B.V. - Kaag", + "ShiptypeLevel2": "Non Merchant", + "ShiptypeLevel3": "Non Merchant", + "ShiptypeLevel4": "Yacht", + "ShiptypeLevel5": "Yacht", + "ShiptypeLevel5HullType": "Ship Shape Including Multi-Hulls", + "ShiptypeLevel5SubGroup": "Yacht", + "ShiptypeLevel5SubType": "Yacht, Private", + "SpeedMax": "18.00", + "SpeedService": "16.00", + "StatCode5": "X11A2YP", + "SuezCanalNetTonnageSCNT": "0", + "TechnicalManager": "YCO SAM", + "TechnicalManagerCode": "5073856", + "TechnicalManagerCountryOfControl": "United Kingdom", + "TechnicalManagerCountryOfDomicile": "Monaco", + "TechnicalManagerCountryOfDomicileCode": "MON", + "TechnicalManagerCountryOfRegistration": "Monaco", + "TEU": "0", + "TEUCapacity14THomogenous": "0", + "TonnageEffectiveDate": "199306", + "TonnageSystem69Convention": "I", + "TonnesPerCentimetreImmersionTPCI": "0.000", + "TotalHorsepowerOfMainEngines": "3266", + "TotalKilowattsOfMainEngines": "2402", + "TotalPowerOfAllEngines": "2402", + "YardNumber": "770", + "YearOfBuild": "1993", + "MainEngineTypeCode": "01", + "CargoOtherType": "Not Applicable", + "CargoOtherCapacity": "", + "NuclearPowerIndicator": "N", + "AuxPropulsionIndicator": "N", + "MainEngineReEngineIndicator": "N", + "MainEngineTypeOfInstallation": "O", + "MainEngineTypeOfInstallationDecode": "ORIGINAL", + "MainEngineStrokeCycle": "SINGLE-ACTING", + "AdditionalInformation": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "TweenDeckFixed": "N", + "TweenDeckPortable": "N", + "DrillBargeInd": "N", + "LRNO": "1000069", + "ProductionVesselInd": "N", + "SatComID": "1447511" + } + ], + "BuilderAddress": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "BuilderCode": "NTH408051", + "CountryCode": "NTH", + "CountryName": "Netherlands", + "ShipBuilder": "Lent Jacht/Scheepswerf", + "ShipBuilderFullStyle": "Jacht- en Scheepswerf C. van Lent & Zonen B.V. - Kaag", + "Town": "Kaag", + "TownCode": "8015", + "BuilderStatus": "Closed" + } + ], + "Capacities": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000069", + "Bale": "0", + "Horsepower": "3266", + "BollardPull": "0", + "GasCapacity": "0", + "GrainCapacity": "0", + "LiquidCapacity": "0", + "NumberOfPassengers": "0", + "NumberRefrigeratedContainers": "0", + "NumberOfTEU": "0" + } + ], + "CargoPump": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CubicMetersCapacity": "0", + "CubicTonsCapacity": "0", + "LRNO": "1000069", + "NumberOfPumps": "0", + "Sequence": "1" + } + ], + "ClassCurrent": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Class": "Not Applicable", + "ClassCode": "NN", + "ClassIndicator": "Not Applicable", + "EffectiveDate": "19930600", + "LRNO": "1000069" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Class": "Lloyd's Register", + "ClassCode": "LR", + "ClassIndicator": "Classed", + "EffectiveDate": "19930630", + "LRNO": "1000069" + } + ], + "ClassHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Class": "Not Applicable", + "ClassCode": "NN", + "ClassIndicator": "Not Applicable", + "CurrentIndicator": "Current", + "EffectiveDate": "19930600", + "LRNO": "1000069", + "Sequence": "00" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Class": "Lloyd's Register", + "ClassCode": "LR", + "ClassIndicator": "Classed", + "CurrentIndicator": "Current", + "EffectiveDate": "19930630", + "LRNO": "1000069", + "Sequence": "00" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Class": "Lloyd's Register", + "ClassCode": "LR", + "ClassIndicator": "Class contemplated", + "CurrentIndicator": "Historical", + "EffectiveDate": "19930630", + "LRNO": "1000069", + "Sequence": "95" + } + ], + "CompanyComplianceDetails": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "OwCode": "0175341", + "ShortCompanyName": "Kizbel Bermuda Ltd", + "CompanyOnOFACSanctionList": "0", + "CompanyOnUNSanctionList": "0", + "CompanyOnEUSanctionList": "0", + "CompanyOnBESSanctionList": "0", + "CompanyInOFACSanctionedCountry": "0", + "CompanyInFATFJurisdiction": "0", + "CompanyOverallComplianceStatus": "0", + "CompanyOnAustralianSanctionList": "0", + "CompanyOnCanadianSanctionList": "0", + "CompanyOnSwissSanctionList": "0", + "CompanyOnOFACSSIList": "0", + "CompanyOnOFACNonSDNSanctionList": "0", + "CompanyOnUAESanctionList": "0" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "OwCode": "5073856", + "ShortCompanyName": "YCO SAM", + "CompanyOnOFACSanctionList": "0", + "CompanyOnUNSanctionList": "0", + "CompanyOnEUSanctionList": "0", + "CompanyOnBESSanctionList": "0", + "CompanyInOFACSanctionedCountry": "0", + "CompanyInFATFJurisdiction": "1", + "CompanyOverallComplianceStatus": "1", + "CompanyOnAustralianSanctionList": "0", + "CompanyOnCanadianSanctionList": "0", + "CompanyOnSwissSanctionList": "0", + "CompanyOnOFACSSIList": "0", + "CompanyOnOFACNonSDNSanctionList": "0", + "CompanyOnUAESanctionList": "0" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "OwCode": "9991001", + "ShortCompanyName": "Unknown", + "CompanyOnOFACSanctionList": "0", + "CompanyOnUNSanctionList": "0", + "CompanyOnEUSanctionList": "0", + "CompanyOnBESSanctionList": "0", + "CompanyInOFACSanctionedCountry": "0", + "CompanyInFATFJurisdiction": "0", + "CompanyOverallComplianceStatus": "0", + "CompanyOnAustralianSanctionList": "0", + "CompanyOnCanadianSanctionList": "0", + "CompanyOnSwissSanctionList": "0", + "CompanyOnOFACSSIList": "0", + "CompanyOnOFACNonSDNSanctionList": "0", + "CompanyOnUAESanctionList": "0" + } + ], + "CompanyDetailsComplexWithCodesAndParent": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CareOfCode": "5073856", + "CompanyStatus": "Active", + "CountryName": "Monaco", + "Emailaddress": "monaco@y.co", + "FoundedDate": "2011", + "FullAddress": "C/O: YCO SAM L'ALBU, 17, avenue Albert II, 98000, Monaco-Ville, Monaco.", + "FullName": "Kizbel (Bermuda) Ltd", + "LastChangeDate": "20250311", + "LocationCode": "MON", + "NationalityofRegistration": "Bermuda", + "NationalityofRegistrationCode": "BER", + "OWCODE": "0175341", + "ParentCompany": "0175341", + "PrePostcode": "98000", + "RoomFloorBuilding1": "L'ALBU,", + "ShortCompanyName": "KIZBEL BERMUDA LTD", + "Street": "avenue Albert II", + "StreetNumber": "17,", + "Telephone": "+377 93 50 12 12", + "TownName": "Monaco-Ville", + "Website": "https://www.y.co" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "Active", + "CountryName": "Monaco", + "Emailaddress": "monaco@y.co", + "FoundedDate": "2004", + "FullAddress": "L'ALBU, 17, avenue Albert II, 98000, Monaco-Ville, Monaco.", + "FullName": "YCO SAM", + "LastChangeDate": "20251006", + "LocationCode": "MON", + "NationalityofControl": "United Kingdom", + "NationalityofControlCode": "GBI", + "NationalityofRegistration": "Monaco", + "NationalityofRegistrationCode": "MON", + "OWCODE": "5073856", + "ParentCompany": "5073856", + "PrePostcode": "98000", + "RoomFloorBuilding1": "L'ALBU,", + "ShortCompanyName": "YCO SAM", + "Street": "avenue Albert II", + "StreetNumber": "17,", + "Telephone": "+377 93 50 12 12", + "TownName": "Monaco-Ville", + "Website": "https://www.y.co" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "Active", + "CountryName": "Unknown", + "FoundedDate": "2018", + "FullAddress": "Unknown", + "FullName": "Unknown", + "LastChangeDate": "20251106", + "LocationCode": "UNK", + "NationalityofRegistration": "Unknown", + "NationalityofRegistrationCode": "UNK", + "OWCODE": "9991001", + "ParentCompany": "9991001", + "ShortCompanyName": "UNKNOWN" + } + ], + "CompanyFleetCounts": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "DOCCount": "0", + "FleetSize": "1", + "GroupOwnerCount": "0", + "InServiceCount": "1", + "OperatorCount": "0", + "OWCODE": "0175341", + "RegisteredOwnerCount": "1", + "ShipManagerCount": "0", + "ShortCompanyName": "Kizbel Bermuda Ltd", + "TechnicalManagerCount": "0" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "DOCCount": "11", + "FleetSize": "54", + "GroupOwnerCount": "4", + "InServiceCount": "54", + "OperatorCount": "52", + "OWCODE": "5073856", + "RegisteredOwnerCount": "0", + "ShipManagerCount": "52", + "ShortCompanyName": "YCO SAM", + "TechnicalManagerCount": "46" + } + ], + "CompanyOrderBookCounts": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "DOCOrderbookCount": "0", + "GroupOwnerOrderbookCount": "0", + "OperatorOrderbookCount": "0", + "OWCODE": "0175341", + "RegisteredOrderbookOwnerCount": "0", + "ShipManagerOrderbookCount": "0" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "DOCOrderbookCount": "0", + "GroupOwnerOrderbookCount": "0", + "OperatorOrderbookCount": "0", + "OWCODE": "5073856", + "RegisteredOrderbookOwnerCount": "0", + "ShipManagerOrderbookCount": "0" + } + ], + "CompanyVesselRelationships": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "DOCCode": "9991001", + "DOCCompany": " Unknown", + "GroupBeneficialOwner": " Unknown", + "GroupBeneficialOwnerCode": "9991001", + "LRNO": "1000069", + "Operator": "YCO SAM", + "OperatorCode": "5073856", + "RegisteredOwner": "Kizbel Bermuda Ltd", + "RegisteredOwnerCode": "0175341", + "ShipManager": "YCO SAM", + "ShipManagerCode": "5073856", + "TechnicalManager": "YCO SAM", + "TechnicalManagerCode": "5073856" + } + ], + "EngineBuilder": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "EngineBuilderLargestCode": "USA606572", + "EngineBuilderShortName": "CATERPILLAR INC", + "EngineBuilderFullName": "Caterpillar Inc -USA ", + "FullAddress": "100, NE Adams Street, Peoria IL 61629-0001 ", + "TownName": "Peoria, IL", + "CountryName": "United States of America", + "Telephone": "+1 309 675 1000", + "Facsimile": "+1 309 675 4332", + "Website": "www.caterpillar.com", + "CountryCode": "USA", + "TownCode": "9069" + } + ], + "FlagHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "EffectiveDate": "199306", + "Flag": "Isle of Man (British)", + "FlagCode": "IOM", + "LRNO": "1000069", + "Sequence": "00" + } + ], + "GrossTonnageHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "EffectiveDate": "199306", + "GT": "970", + "LRNO": "1000069", + "Sequence": "00" + } + ], + "GroupBeneficialOwnerHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "19940601", + "GroupBeneficialOwner": "Unknown", + "GroupBeneficialOwnerCode": "9991001", + "LRNO": "1000069", + "Sequence": "00" + } + ], + "LiftingGear": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "GearType": "Unknown", + "LRNO": "1000069", + "MaxSWLOfGear": "0.00", + "NumberOfGears": "0", + "Sequence": "01" + } + ], + "MainEngine": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "BHPOfMainOilEngines": "3266", + "Bore": "170", + "CylinderArrangementCode": "V", + "CylinderArrangementDecode": "VEE", + "EngineBuilder": "Caterpillar Inc - USA", + "EngineBuilderCode": "CAT", + "EngineDesigner": "Caterpillar", + "EngineMakerCode": "USA606572", + "EngineModel": "3516TA", + "EngineType": "Oil", + "LRNO": "1000069", + "NumberOfCylinders": "16", + "Position": "PORT", + "PowerBHP": "1633", + "PowerKW": "1201", + "RPM": "1300", + "Stroke": "190", + "StrokeType": "4" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "BHPOfMainOilEngines": "3266", + "Bore": "170", + "CylinderArrangementCode": "V", + "CylinderArrangementDecode": "VEE", + "EngineBuilder": "Caterpillar Inc - USA", + "EngineBuilderCode": "CAT", + "EngineDesigner": "Caterpillar", + "EngineMakerCode": "USA606572", + "EngineModel": "3516TA", + "EngineType": "Oil", + "LRNO": "1000069", + "NumberOfCylinders": "16", + "Position": "STARBOARD", + "PowerBHP": "1633", + "PowerKW": "1201", + "RPM": "1300", + "Stroke": "190", + "StrokeType": "4" + } + ], + "NameHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Effective_Date": "199306", + "LRNO": "1000069", + "Sequence": "00", + "VesselName": "LADY BEATRICE" + } + ], + "OperatorHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20250310", + "LRNO": "1000069", + "Operator": "YCO SAM", + "OperatorCode": "5073856", + "Sequence": "00" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20250211", + "LRNO": "1000069", + "Operator": "Kizbel Bermuda Ltd", + "OperatorCode": "0175341", + "Sequence": "94" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "19930600", + "LRNO": "1000069", + "Operator": "Horizon Cruises Ltd", + "OperatorCode": "3010963", + "Sequence": "95" + } + ], + "OwnerHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20250211", + "LRNO": "1000069", + "Owner": "Kizbel Bermuda Ltd", + "OwnerCode": "0175341", + "Sequence": "00" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "19930600", + "LRNO": "1000069", + "Owner": "Horizon Cruises Ltd", + "OwnerCode": "3010963", + "Sequence": "95" + } + ], + "PandIHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000069", + "Sequence": "00", + "PandIClubCode": "9991001", + "PandIClubDecode": "Unknown", + "EffectiveDate": "20250310", + "Source": "P&I Website" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000069", + "Sequence": "95", + "PandIClubCode": "6219108", + "PandIClubDecode": "Shipowners' Club", + "EffectiveDate": "20070401", + "Source": "P&I Website" + } + ], + "Propellers": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000069", + "NozzleType": "Unknown", + "PropellerPosition": "Port", + "PropellerType": "Fixed Pitch", + "PropellerTypeCode": "FP", + "RPMMaximum": "0", + "RPMService": "0", + "Sequence": "01" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000069", + "NozzleType": "Unknown", + "PropellerPosition": "Starboard", + "PropellerType": "Fixed Pitch", + "PropellerTypeCode": "FP", + "RPMMaximum": "0", + "RPMService": "0", + "Sequence": "02" + } + ], + "SafetyManagementCertificateHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000069", + "SafetyManagementCertificateAuditor": "", + "SafetyManagementCertificateConventionOrVol": "", + "SafetyManagementCertificateDateIssued": "19971101", + "SafetyManagementCertificateDOCCompany": "Unknown", + "SafetyManagementCertificateFlag": "", + "SafetyManagementCertificateIssuer": "", + "SafetyManagementCertificateOtherDescription": "", + "SafetyManagementCertificateShipName": "", + "SafetyManagementCertificateShipType": "", + "SafetyManagementCertificateSource": "LRF", + "SafetyManagementCertificateCompanyCode": "9991001", + "Sequence": "00" + } + ], + "ShipBuilderDetail": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "BuilderCode": "NTH408051", + "BuilderStatus": "Closed", + "CountryCode": "NTH", + "CountryName": "Netherlands", + "Shipbuilder": "Lent Jacht/Scheepswerf", + "ShipbuilderFullStyle": "Jacht- en Scheepswerf C. van Lent & Zonen B.V. - Kaag", + "TownCode": "8015" + } + ], + "ShipBuilderAndSubContractor": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "BuilderCode": "NTH408051", + "LRNO": "1000069", + "Section": "Whole Ship", + "SequenceNumber": "01" + } + ], + "ShipBuilderHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "BuilderCode": "NTH408051", + "BuilderHistory": "(Formerly N.V. C. van Lent & Zonen.) LEN", + "BuilderType": "Shipbuilder" + } + ], + "ShipManagerHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20250310", + "LRNO": "1000069", + "Sequence": "00", + "ShipManager": "YCO SAM", + "ShipManagerCode": "5073856" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20250211", + "LRNO": "1000069", + "Sequence": "94", + "ShipManager": "Kizbel Bermuda Ltd", + "ShipManagerCode": "0175341" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "19930600", + "LRNO": "1000069", + "Sequence": "95", + "ShipManager": "Horizon Cruises Ltd", + "ShipManagerCode": "3010963" + } + ], + "ShipTypeHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "EffDate": "199306", + "LRNO": "1000069", + "Sequence": "00", + "Shiptype": "Yacht", + "ShiptypeCode": "X11A2YP" + } + ], + "StatusHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000069", + "Sequence": "00", + "Status": "IN SERVICE/COMMISSION", + "StatusCode": "S", + "StatusDate": "19930600" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000069", + "Sequence": "95", + "Status": "ON ORDER/NOT COMMENCED", + "StatusCode": "O", + "StatusDate": "19910600" + } + ], + "SurveyDates": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "AnnualSurvey": "2006-06-30", + "ClassSociety": "Lloyd's Register", + "ClassSocietyCode": "LR", + "DockingSurvey": "2026-02-27", + "LRNO": "1000069", + "SpecialSurvey": "2028-06-29", + "TailShaftSurvey": "2028-04-30" + } + ], + "TechnicalManagerHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "20250310", + "LRNO": "1000069", + "Sequence": "00", + "TechnicalManager": "YCO SAM", + "TechnicalManagerCode": "5073856" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "CompanyStatus": "", + "EffectiveDate": "19971101", + "LRNO": "1000069", + "Sequence": "95", + "TechnicalManager": "Unknown", + "TechnicalManagerCode": "9991001" + } + ], + "Thrusters": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000069", + "Sequence": "01", + "ThrusterType": "None", + "ThrusterTypeCode": "NN", + "NumberOfThrusters": "0", + "ThrusterPosition": "Not Applicable", + "ThrusterBHP": "0", + "ThrusterKW": "0", + "TypeOfInstallation": "Not Applicable" + } + ], + "CallSignAndMmsiHistory": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "Lrno": "1000069", + "SeqNo": "00", + "CallSign": "MRPW2", + "Mmsi": "233219000", + "EffectiveDate": "199306" + } + ], + "SurveyDatesHistoryUnique": [ + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000069", + "ClassSocietyCode": "LR", + "SurveyDate": "2023-02-28", + "SurveyType": "DockingSurvey", + "ClassSociety": "Lloyd's Register" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000069", + "ClassSocietyCode": "LR", + "SurveyDate": "2023-05-01", + "SurveyType": "TailshaftSurvey", + "ClassSociety": "Lloyd's Register" + }, + { + "DataSetVersion": { + "DataSetVersion": "1.0.0" + }, + "LRNO": "1000069", + "ClassSocietyCode": "LR", + "SurveyDate": "2023-06-30", + "SurveyType": "SpecialSurvey", + "ClassSociety": "Lloyd's Register" + } + ] + }, + "APSStatus": { + "SystemVersion": "1.1.0", + "SystemDate": "2015-08-20T00:00:00", + "JobRunDate": "2025-11-07T02:03:31.4270159+00:00", + "CompletedOK": true, + "ErrorLevel": "None", + "ErrorMessage": "", + "RemedialAction": "", + "Guid": "6a6ceb5a-39bf-4fe3-8e41-0cf6c5d4d7d0" + } + } + ], + "APSStatus": { + "SystemVersion": "1.1.0", + "SystemDate": "2015-08-20T00:00:00", + "JobRunDate": "2025-11-07T02:03:12.9179917+00:00", + "CompletedOK": true, + "ErrorLevel": "None", + "ErrorMessage": "", + "RemedialAction": "", + "Guid": "d3adb053-4579-461b-8183-f2aa6849648e" + } +} \ No newline at end of file diff --git a/src/main/resources/db/migration/V3__Create_Sample_Products_Table.sql b/src/main/resources/db/migration/V3__Create_Sample_Products_Table.sql new file mode 100644 index 0000000..43a5b2f --- /dev/null +++ b/src/main/resources/db/migration/V3__Create_Sample_Products_Table.sql @@ -0,0 +1,114 @@ +-- ======================================== +-- 샘플 제품 테이블 생성 +-- 다양한 데이터 타입 테스트용 +-- ======================================== + +-- 기존 테이블 삭제 (개발 환경에서만) +DROP TABLE IF EXISTS sample_products CASCADE; + +-- 샘플 제품 테이블 생성 +CREATE TABLE sample_products ( + -- 기본 키 (자동 증가) + id BIGSERIAL PRIMARY KEY, + + -- 제품 ID (비즈니스 키, 유니크) + product_id VARCHAR(50) NOT NULL UNIQUE, + + -- 제품명 + product_name VARCHAR(200) NOT NULL, + + -- 카테고리 + category VARCHAR(100), + + -- 가격 (DECIMAL 타입: 정밀한 소수점 계산) + price DECIMAL(10, 2), + + -- 재고 수량 (INTEGER 타입) + stock_quantity INTEGER, + + -- 활성 여부 (BOOLEAN 타입) + is_active BOOLEAN DEFAULT TRUE, + + -- 평점 (DOUBLE PRECISION 타입) + rating DOUBLE PRECISION, + + -- 제조일자 (DATE 타입) + manufacture_date DATE, + + -- 무게 (REAL/FLOAT 타입) + weight REAL, + + -- 판매 횟수 (BIGINT 타입) + sales_count BIGINT DEFAULT 0, + + -- 설명 (TEXT 타입: 긴 텍스트) + description TEXT, + + -- 태그 (JSON 문자열 저장) + tags VARCHAR(500), + + -- 감사 필드 (BaseEntity에서 상속) + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by VARCHAR(100) DEFAULT 'SYSTEM', + updated_by VARCHAR(100) DEFAULT 'SYSTEM' +); + +-- ======================================== +-- 인덱스 생성 (성능 최적화) +-- ======================================== + +-- 제품 ID 인덱스 (이미 UNIQUE로 자동 생성되지만 명시적 표시) +CREATE INDEX IF NOT EXISTS idx_sample_products_product_id + ON sample_products(product_id); + +-- 카테고리 인덱스 (카테고리별 검색 최적화) +CREATE INDEX IF NOT EXISTS idx_sample_products_category + ON sample_products(category); + +-- 활성 여부 인덱스 (활성 제품 필터링 최적화) +CREATE INDEX IF NOT EXISTS idx_sample_products_is_active + ON sample_products(is_active); + +-- 제조일자 인덱스 (날짜 범위 검색 최적화) +CREATE INDEX IF NOT EXISTS idx_sample_products_manufacture_date + ON sample_products(manufacture_date); + +-- 복합 인덱스: 카테고리 + 활성 여부 (자주 함께 검색되는 조건) +CREATE INDEX IF NOT EXISTS idx_sample_products_category_active + ON sample_products(category, is_active); + +-- 생성일시 인덱스 (최신 데이터 조회 최적화) +CREATE INDEX IF NOT EXISTS idx_sample_products_created_at + ON sample_products(created_at DESC); + +-- ======================================== +-- 코멘트 추가 (테이블 및 컬럼 설명) +-- ======================================== + +COMMENT ON TABLE sample_products IS '샘플 제품 테이블 - 다양한 데이터 타입 테스트용'; + +COMMENT ON COLUMN sample_products.id IS '기본 키 (자동 증가)'; +COMMENT ON COLUMN sample_products.product_id IS '제품 ID (비즈니스 키)'; +COMMENT ON COLUMN sample_products.product_name IS '제품명'; +COMMENT ON COLUMN sample_products.category IS '카테고리'; +COMMENT ON COLUMN sample_products.price IS '가격 (DECIMAL 타입, 정밀 소수점)'; +COMMENT ON COLUMN sample_products.stock_quantity IS '재고 수량 (INTEGER)'; +COMMENT ON COLUMN sample_products.is_active IS '활성 여부 (BOOLEAN)'; +COMMENT ON COLUMN sample_products.rating IS '평점 (DOUBLE PRECISION)'; +COMMENT ON COLUMN sample_products.manufacture_date IS '제조일자 (DATE)'; +COMMENT ON COLUMN sample_products.weight IS '무게 kg (REAL/FLOAT)'; +COMMENT ON COLUMN sample_products.sales_count IS '판매 횟수 (BIGINT)'; +COMMENT ON COLUMN sample_products.description IS '설명 (TEXT, 긴 텍스트)'; +COMMENT ON COLUMN sample_products.tags IS '태그 (JSON 문자열)'; +COMMENT ON COLUMN sample_products.created_at IS '생성일시'; +COMMENT ON COLUMN sample_products.updated_at IS '수정일시'; +COMMENT ON COLUMN sample_products.created_by IS '생성자'; +COMMENT ON COLUMN sample_products.updated_by IS '수정자'; + +-- ======================================== +-- 테이블 통계 정보 +-- ======================================== + +-- 테이블 통계 업데이트 (쿼리 최적화를 위한 통계 수집) +ANALYZE sample_products; diff --git a/src/main/resources/db/schema/001_create_job_execution_lock.sql b/src/main/resources/db/schema/001_create_job_execution_lock.sql new file mode 100644 index 0000000..5e297d0 --- /dev/null +++ b/src/main/resources/db/schema/001_create_job_execution_lock.sql @@ -0,0 +1,61 @@ +-- ============================================================ +-- Job Execution Lock 테이블 생성 +-- ============================================================ +-- 목적: Job 동시 실행 방지 (분산 환경 지원) +-- 작성일: 2025-10-17 +-- 버전: 1.0.0 +-- ============================================================ + +-- 테이블 삭제 (재생성 시) +DROP TABLE IF EXISTS job_execution_lock CASCADE; + +-- 테이블 생성 +CREATE TABLE job_execution_lock ( + -- Job 이름 (Primary Key) + job_name VARCHAR(100) PRIMARY KEY, + + -- Lock 상태 (true: 실행 중, false: 대기) + locked BOOLEAN NOT NULL DEFAULT FALSE, + + -- Lock 획득 시간 + locked_at TIMESTAMP, + + -- Lock 소유자 (hostname:pid 형식) + locked_by VARCHAR(255), + + -- 현재 실행 중인 Execution ID + execution_id BIGINT, + + -- 감사 필드 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 인덱스 생성 +CREATE INDEX idx_job_execution_lock_locked ON job_execution_lock(locked); +CREATE INDEX idx_job_execution_lock_locked_at ON job_execution_lock(locked_at); +CREATE INDEX idx_job_execution_lock_execution_id ON job_execution_lock(execution_id); + +-- 테이블 및 컬럼 주석 +COMMENT ON TABLE job_execution_lock IS 'Job 실행 Lock 관리 테이블 (동시 실행 방지)'; +COMMENT ON COLUMN job_execution_lock.job_name IS 'Job 이름 (Primary Key)'; +COMMENT ON COLUMN job_execution_lock.locked IS 'Lock 상태 (true: 실행 중, false: 대기)'; +COMMENT ON COLUMN job_execution_lock.locked_at IS 'Lock 획득 시간'; +COMMENT ON COLUMN job_execution_lock.locked_by IS 'Lock 소유자 (hostname:pid)'; +COMMENT ON COLUMN job_execution_lock.execution_id IS '현재 실행 중인 Execution ID'; +COMMENT ON COLUMN job_execution_lock.created_at IS '생성 시간'; +COMMENT ON COLUMN job_execution_lock.updated_at IS '수정 시간'; + +-- 샘플 데이터 삽입 (선택사항) +-- INSERT INTO job_execution_lock (job_name, locked, locked_at, locked_by, execution_id) +-- VALUES ('sampleProductImportJob', FALSE, NULL, NULL, NULL); +-- INSERT INTO job_execution_lock (job_name, locked, locked_at, locked_by, execution_id) +-- VALUES ('shipDataImportJob', FALSE, NULL, NULL, NULL); +-- INSERT INTO job_execution_lock (job_name, locked, locked_at, locked_by, execution_id) +-- VALUES ('shipDetailImportJob', FALSE, NULL, NULL, NULL); + +-- 권한 부여 (필요 시) +-- GRANT SELECT, INSERT, UPDATE, DELETE ON job_execution_lock TO snp; + +-- 완료 메시지 +SELECT 'job_execution_lock 테이블 생성 완료' AS status; diff --git a/src/main/resources/db/schema/ship_detail.sql b/src/main/resources/db/schema/ship_detail.sql new file mode 100644 index 0000000..8bb9aed --- /dev/null +++ b/src/main/resources/db/schema/ship_detail.sql @@ -0,0 +1,64 @@ +-- 선박 상세 정보 테이블 +CREATE TABLE IF NOT EXISTS ship_detail ( + -- 기본 키 + id BIGSERIAL PRIMARY KEY, + + -- 비즈니스 키 + imo_number VARCHAR(20) UNIQUE NOT NULL, + + -- 선박 기본 정보 + ship_name VARCHAR(200), + ship_type VARCHAR(100), + classification VARCHAR(100), + build_year INTEGER, + shipyard VARCHAR(200), + + -- 소유/운영 정보 + owner VARCHAR(200), + operator VARCHAR(200), + flag VARCHAR(100), + + -- 선박 제원 + gross_tonnage DOUBLE PRECISION, + net_tonnage DOUBLE PRECISION, + deadweight DOUBLE PRECISION, + length_overall DOUBLE PRECISION, + breadth DOUBLE PRECISION, + depth DOUBLE PRECISION, + + -- 기술 정보 + hull_material VARCHAR(100), + engine_type VARCHAR(100), + engine_power DOUBLE PRECISION, + speed DOUBLE PRECISION, + + -- 식별 정보 + mmsi VARCHAR(20), + call_sign VARCHAR(20), + + -- 상태 정보 + status VARCHAR(50), + last_updated VARCHAR(100), + + -- 감사 필드 (BaseEntity) + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by VARCHAR(100) DEFAULT 'SYSTEM', + updated_by VARCHAR(100) DEFAULT 'SYSTEM' +); + +-- 인덱스 +CREATE UNIQUE INDEX IF NOT EXISTS idx_ship_detail_imo ON ship_detail(imo_number); +CREATE INDEX IF NOT EXISTS idx_ship_detail_ship_name ON ship_detail(ship_name); +CREATE INDEX IF NOT EXISTS idx_ship_detail_ship_type ON ship_detail(ship_type); +CREATE INDEX IF NOT EXISTS idx_ship_detail_flag ON ship_detail(flag); +CREATE INDEX IF NOT EXISTS idx_ship_detail_status ON ship_detail(status); + +-- 주석 +COMMENT ON TABLE ship_detail IS '선박 상세 정보'; +COMMENT ON COLUMN ship_detail.imo_number IS 'IMO 번호 (비즈니스 키)'; +COMMENT ON COLUMN ship_detail.ship_name IS '선박명'; +COMMENT ON COLUMN ship_detail.ship_type IS '선박 타입'; +COMMENT ON COLUMN ship_detail.gross_tonnage IS '총톤수'; +COMMENT ON COLUMN ship_detail.deadweight IS '재화중량톤수'; +COMMENT ON COLUMN ship_detail.length_overall IS '전체 길이 (meters)'; diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..e3e9083 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,234 @@ + + + + + + + + + + + + + + + + + ${LOG_PATTERN_SIMPLE} + UTF-8 + + + + + + ${LOG_PATH}/application.log + + ${LOG_PATTERN} + UTF-8 + + + ${LOG_PATH}/archive/application.%d{yyyy-MM-dd}.%i.log.gz + ${MAX_FILE_SIZE} + ${MAX_HISTORY} + ${TOTAL_SIZE_CAP} + + + + + + ${LOG_PATH}/batch.log + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{jobName}] %logger{30} - %msg%n + UTF-8 + + + ${LOG_PATH}/archive/batch.%d{yyyy-MM-dd}.%i.log.gz + ${MAX_FILE_SIZE} + ${MAX_HISTORY} + ${TOTAL_SIZE_CAP} + + + + + + ${LOG_PATH}/api-access.log + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %msg%n + UTF-8 + + + ${LOG_PATH}/archive/api-access.%d{yyyy-MM-dd}.%i.log.gz + ${MAX_FILE_SIZE} + ${MAX_HISTORY} + ${TOTAL_SIZE_CAP} + + + + + + ${LOG_PATH}/metrics.log + + %d{yyyy-MM-dd HH:mm:ss.SSS} %msg%n + UTF-8 + + + ${LOG_PATH}/archive/metrics.%d{yyyy-MM-dd}.%i.log.gz + 50MB + 7 + 1GB + + + + + + ${LOG_PATH}/error.log + + WARN + + + ${LOG_PATTERN} + UTF-8 + + + ${LOG_PATH}/archive/error.%d{yyyy-MM-dd}.%i.log.gz + ${MAX_FILE_SIZE} + 60 + 5GB + + + + + + 512 + 0 + + + + + 256 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/sql/ais_target_ddl.sql b/src/main/resources/sql/ais_target_ddl.sql new file mode 100644 index 0000000..1d76dd8 --- /dev/null +++ b/src/main/resources/sql/ais_target_ddl.sql @@ -0,0 +1,442 @@ +-- ============================================ +-- AIS Target 파티션 테이블 DDL +-- ============================================ +-- 용도: 선박 AIS 위치 정보 저장 (항적 분석용) +-- 수집 주기: 매 분 15초 +-- 예상 데이터량: 약 33,000건/분, 일 20GB (인덱스 포함) +-- 파티셔닝: 일별 파티션 (ais_target_YYMMDD) +-- ============================================ + +-- PostGIS 확장 활성화 (이미 설치되어 있다면 생략) +CREATE EXTENSION IF NOT EXISTS postgis; + +-- ============================================ +-- 1. 부모 테이블 생성 (파티션 테이블) +-- ============================================ +CREATE TABLE IF NOT EXISTS snp_data.ais_target ( + -- ========== PK (복합키) ========== + mmsi BIGINT NOT NULL, + message_timestamp TIMESTAMP WITH TIME ZONE NOT NULL, + + -- ========== 선박 식별 정보 ========== + imo BIGINT, + name VARCHAR(100), + callsign VARCHAR(20), + vessel_type VARCHAR(50), + extra_info VARCHAR(100), + + -- ========== 위치 정보 ========== + lat DOUBLE PRECISION NOT NULL, + lon DOUBLE PRECISION NOT NULL, + geom GEOMETRY(Point, 4326), + + -- ========== 항해 정보 ========== + heading DOUBLE PRECISION, + sog DOUBLE PRECISION, -- Speed over Ground (knots) + cog DOUBLE PRECISION, -- Course over Ground (degrees) + rot INTEGER, -- Rate of Turn (degrees/min) + + -- ========== 선박 제원 ========== + length INTEGER, + width INTEGER, + draught DOUBLE PRECISION, + length_bow INTEGER, + length_stern INTEGER, + width_port INTEGER, + width_starboard INTEGER, + + -- ========== 목적지 정보 ========== + destination VARCHAR(200), + eta TIMESTAMP WITH TIME ZONE, + status VARCHAR(50), + + -- ========== AIS 메시지 정보 ========== + age_minutes DOUBLE PRECISION, + position_accuracy INTEGER, + timestamp_utc INTEGER, + repeat_indicator INTEGER, + raim_flag INTEGER, + radio_status INTEGER, + regional INTEGER, + regional2 INTEGER, + spare INTEGER, + spare2 INTEGER, + ais_version INTEGER, + position_fix_type INTEGER, + dte INTEGER, + band_flag INTEGER, + + -- ========== 타임스탬프 ========== + received_date TIMESTAMP WITH TIME ZONE, + collected_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + tonnes_cargo INTEGER NULL, -- 화물 톤수 + in_sts INTEGER NULL, -- STS 여부 + on_berth BOOLEAN NULL, -- 정박 여부 + dwt INTEGER NULL, -- 재화중량톤수 + anomalous INTEGER NULL, -- 이상 징후 여부 + destination_port_id INTEGER NULL, -- 목적지 항만 ID + destination_tidied VARCHAR(50) NULL, -- 정제된 목적지명 + destination_unlocode VARCHAR(6) NULL, -- 목적지 UNLOCODE + imo_verified VARCHAR(7) NULL, -- IMO 검증 코드 + last_static_update_received TIMESTAMP WITH TIME ZONE NULL, -- 마지막 정적 업데이트 수신 시각 + lpc_code INTEGER NULL, -- LPC 코드 + message_type INTEGER NULL, -- 메시지 유형 + "source" VARCHAR(30) NULL, -- 데이터 소스 + station_id VARCHAR(100) NULL, -- 스테이션 ID + zone_id DOUBLE PRECISION NULL, -- 구역 ID + + -- ========== 제약조건 ========== + CONSTRAINT pk_ais_target PRIMARY KEY (mmsi, message_timestamp) +) PARTITION BY RANGE (message_timestamp); + +-- ============================================ +-- 2. 초기 파티션 생성 (현재 일 + 다음 3일) +-- ============================================ +-- 파티션 네이밍: ais_target_YYMMDD +-- 실제 운영 시 partitionManagerJob에서 자동 생성 + +-- 2024년 12월 4일 파티션 (예시) +CREATE TABLE IF NOT EXISTS snp_data.ais_target_241204 PARTITION OF snp_data.ais_target + FOR VALUES FROM ('2024-12-04 00:00:00+00') TO ('2024-12-05 00:00:00+00'); + +-- 2024년 12월 5일 파티션 +CREATE TABLE IF NOT EXISTS snp_data.ais_target_241205 PARTITION OF snp_data.ais_target + FOR VALUES FROM ('2024-12-05 00:00:00+00') TO ('2024-12-06 00:00:00+00'); + +-- 2024년 12월 6일 파티션 +CREATE TABLE IF NOT EXISTS snp_data.ais_target_241206 PARTITION OF snp_data.ais_target + FOR VALUES FROM ('2024-12-06 00:00:00+00') TO ('2024-12-07 00:00:00+00'); + +-- 2024년 12월 7일 파티션 +CREATE TABLE IF NOT EXISTS snp_data.ais_target_241207 PARTITION OF snp_data.ais_target + FOR VALUES FROM ('2024-12-07 00:00:00+00') TO ('2024-12-08 00:00:00+00'); + +-- ============================================ +-- 3. 인덱스 생성 (각 파티션에 자동 상속) +-- ============================================ + +-- 1. MMSI 인덱스 (특정 선박 조회) +CREATE INDEX IF NOT EXISTS idx_ais_target_mmsi + ON snp_data.ais_target (mmsi); + +-- 2. IMO 인덱스 (IMO가 있는 선박만) +CREATE INDEX IF NOT EXISTS idx_ais_target_imo + ON snp_data.ais_target (imo) + WHERE imo IS NOT NULL AND imo > 0; + +-- 3. 메시지 타임스탬프 인덱스 (시간 범위 조회) +CREATE INDEX IF NOT EXISTS idx_ais_target_message_timestamp + ON snp_data.ais_target (message_timestamp DESC); + +-- 4. MMSI + 타임스탬프 복합 인덱스 (항적 조회 최적화) +CREATE INDEX IF NOT EXISTS idx_ais_target_mmsi_timestamp + ON snp_data.ais_target (mmsi, message_timestamp DESC); + +-- 5. 공간 인덱스 (GIST) - 공간 쿼리 최적화 +CREATE INDEX IF NOT EXISTS idx_ais_target_geom + ON snp_data.ais_target USING GIST (geom); + +-- 6. 수집 시점 인덱스 (배치 모니터링용) +CREATE INDEX IF NOT EXISTS idx_ais_target_collected_at + ON snp_data.ais_target (collected_at DESC); + +-- ============================================ +-- 4. 파티션 자동 생성 함수 (일별) +-- ============================================ + +-- 파티션 존재 여부 확인 함수 +CREATE OR REPLACE FUNCTION snp_data.partition_exists(partition_name TEXT) +RETURNS BOOLEAN AS $$ +BEGIN + RETURN EXISTS ( + SELECT 1 FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'std_snp_data' + AND c.relname = partition_name + AND c.relkind = 'r' + ); +END; +$$ LANGUAGE plpgsql; + +-- 특정 일의 파티션 생성 함수 +CREATE OR REPLACE FUNCTION snp_data.create_ais_target_daily_partition(target_date DATE) +RETURNS TEXT AS $$ +DECLARE + partition_name TEXT; + start_date DATE; + end_date DATE; + create_sql TEXT; +BEGIN + -- 파티션 이름 생성: ais_target_YYMMDD + partition_name := 'ais_target_' || TO_CHAR(target_date, 'YYMMDD'); + + -- 시작/종료 날짜 계산 + start_date := target_date; + end_date := target_date + INTERVAL '1 day'; + + -- 이미 존재하면 스킵 + IF snp_data.partition_exists(partition_name) THEN + RAISE NOTICE 'Partition % already exists, skipping', partition_name; + RETURN partition_name || ' (already exists)'; + END IF; + + -- 파티션 생성 SQL + create_sql := format( + 'CREATE TABLE snp_data.%I PARTITION OF snp_data.ais_target FOR VALUES FROM (%L) TO (%L)', + partition_name, + start_date::TIMESTAMP WITH TIME ZONE, + end_date::TIMESTAMP WITH TIME ZONE + ); + + EXECUTE create_sql; + + RAISE NOTICE 'Created partition: % (% to %)', partition_name, start_date, end_date; + + RETURN partition_name; +END; +$$ LANGUAGE plpgsql; + +-- 다음 N일 파티션 사전 생성 함수 +CREATE OR REPLACE FUNCTION snp_data.create_future_ais_target_daily_partitions(days_ahead INTEGER DEFAULT 3) +RETURNS TABLE (partition_name TEXT, status TEXT) AS $$ +DECLARE + i INTEGER; + target_date DATE; + result TEXT; +BEGIN + FOR i IN 0..days_ahead LOOP + target_date := CURRENT_DATE + i; + result := snp_data.create_ais_target_daily_partition(target_date); + partition_name := 'ais_target_' || TO_CHAR(target_date, 'YYMMDD'); + status := result; + RETURN NEXT; + END LOOP; +END; +$$ LANGUAGE plpgsql; + +-- ============================================ +-- 5. 오래된 파티션 삭제 함수 (일별) +-- ============================================ + +-- 특정 일의 파티션 삭제 함수 +CREATE OR REPLACE FUNCTION snp_data.drop_ais_target_daily_partition(target_date DATE) +RETURNS TEXT AS $$ +DECLARE + partition_name TEXT; + drop_sql TEXT; +BEGIN + partition_name := 'ais_target_' || TO_CHAR(target_date, 'YYMMDD'); + + -- 존재하지 않으면 스킵 + IF NOT snp_data.partition_exists(partition_name) THEN + RAISE NOTICE 'Partition % does not exist, skipping', partition_name; + RETURN partition_name || ' (not found)'; + END IF; + + drop_sql := format('DROP TABLE snp_data.%I', partition_name); + EXECUTE drop_sql; + + RAISE NOTICE 'Dropped partition: %', partition_name; + + RETURN partition_name || ' (dropped)'; +END; +$$ LANGUAGE plpgsql; + +-- N일 이전 파티션 정리 함수 +CREATE OR REPLACE FUNCTION snp_data.cleanup_old_ais_target_daily_partitions(retention_days INTEGER DEFAULT 14) +RETURNS TABLE (partition_name TEXT, status TEXT) AS $$ +DECLARE + rec RECORD; + partition_date DATE; + cutoff_date DATE; +BEGIN + cutoff_date := CURRENT_DATE - retention_days; + + -- ais_target_YYMMDD 패턴의 파티션 조회 + FOR rec IN + SELECT c.relname + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + JOIN pg_inherits i ON i.inhrelid = c.oid + WHERE n.nspname = 'std_snp_data' + AND c.relname LIKE 'ais_target_%' + AND LENGTH(c.relname) = 17 -- ais_target_YYMMDD = 17자 + AND c.relkind = 'r' + ORDER BY c.relname + LOOP + -- 파티션 이름에서 날짜 추출 (ais_target_YYMMDD) + BEGIN + partition_date := TO_DATE(SUBSTRING(rec.relname FROM 'ais_target_(\d{6})'), 'YYMMDD'); + + IF partition_date < cutoff_date THEN + EXECUTE format('DROP TABLE snp_data.%I', rec.relname); + partition_name := rec.relname; + status := 'dropped'; + RETURN NEXT; + RAISE NOTICE 'Dropped old partition: %', rec.relname; + END IF; + EXCEPTION WHEN OTHERS THEN + -- 날짜 파싱 실패 시 스킵 + CONTINUE; + END; + END LOOP; +END; +$$ LANGUAGE plpgsql; + +-- 파티션별 통계 조회 함수 (일별) +CREATE OR REPLACE FUNCTION snp_data.ais_target_daily_partition_stats() +RETURNS TABLE ( + partition_name TEXT, + row_count BIGINT, + size_bytes BIGINT, + size_pretty TEXT +) AS $$ +BEGIN + RETURN QUERY + SELECT + c.relname::TEXT as partition_name, + (pg_stat_get_live_tuples(c.oid))::BIGINT as row_count, + pg_relation_size(c.oid) as size_bytes, + pg_size_pretty(pg_relation_size(c.oid)) as size_pretty + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + JOIN pg_inherits i ON i.inhrelid = c.oid + WHERE n.nspname = 'std_snp_data' + AND c.relname LIKE 'ais_target_%' + AND c.relkind = 'r' + ORDER BY c.relname; +END; +$$ LANGUAGE plpgsql; + +-- ============================================ +-- 6. 코멘트 +-- ============================================ + +COMMENT ON TABLE snp_data.ais_target IS 'AIS 선박 위치 정보 (매 분 15초 수집, 일별 파티션 - ais_target_YYMMDD)'; + +COMMENT ON COLUMN snp_data.ais_target.mmsi IS 'Maritime Mobile Service Identity (복합 PK)'; +COMMENT ON COLUMN snp_data.ais_target.message_timestamp IS 'AIS 메시지 발생 시간 (복합 PK, 파티션 키)'; +COMMENT ON COLUMN snp_data.ais_target.imo IS 'IMO 선박 번호'; +COMMENT ON COLUMN snp_data.ais_target.geom IS 'PostGIS Point geometry (SRID 4326 - WGS84)'; +COMMENT ON COLUMN snp_data.ais_target.sog IS 'Speed over Ground (대지속력, knots)'; +COMMENT ON COLUMN snp_data.ais_target.cog IS 'Course over Ground (대지침로, degrees)'; +COMMENT ON COLUMN snp_data.ais_target.rot IS 'Rate of Turn (선회율, degrees/min)'; +COMMENT ON COLUMN snp_data.ais_target.heading IS '선수 방향 (degrees)'; +COMMENT ON COLUMN snp_data.ais_target.draught IS '흘수 (meters)'; +COMMENT ON COLUMN snp_data.ais_target.collected_at IS '배치 수집 시점'; +COMMENT ON COLUMN snp_data.ais_target.received_date IS 'API 수신 시간'; + +-- ============================================ +-- 7. 유지보수용 함수: 통계 조회 +-- ============================================ + +CREATE OR REPLACE FUNCTION snp_data.ais_target_stats() +RETURNS TABLE ( + total_count BIGINT, + unique_mmsi_count BIGINT, + unique_imo_count BIGINT, + oldest_record TIMESTAMP WITH TIME ZONE, + newest_record TIMESTAMP WITH TIME ZONE, + last_hour_count BIGINT +) AS $$ +BEGIN + RETURN QUERY + SELECT + COUNT(*)::BIGINT as total_count, + COUNT(DISTINCT mmsi)::BIGINT as unique_mmsi_count, + COUNT(DISTINCT imo) FILTER (WHERE imo IS NOT NULL AND imo > 0)::BIGINT as unique_imo_count, + MIN(message_timestamp) as oldest_record, + MAX(message_timestamp) as newest_record, + COUNT(*) FILTER (WHERE message_timestamp > NOW() - INTERVAL '1 hour')::BIGINT as last_hour_count + FROM snp_data.ais_target; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION snp_data.ais_target_stats IS 'AIS Target 테이블 통계 조회'; +COMMENT ON FUNCTION snp_data.create_ais_target_daily_partition IS '특정 일의 AIS Target 파티션 생성'; +COMMENT ON FUNCTION snp_data.create_future_ais_target_daily_partitions IS '향후 N일 파티션 사전 생성'; +COMMENT ON FUNCTION snp_data.drop_ais_target_daily_partition IS '특정 일의 파티션 삭제'; +COMMENT ON FUNCTION snp_data.cleanup_old_ais_target_daily_partitions IS 'N일 이전 파티션 정리'; +COMMENT ON FUNCTION snp_data.ais_target_daily_partition_stats IS '파티션별 통계 조회'; + +-- ============================================ +-- 예시 쿼리 +-- ============================================ + +-- 1. 특정 MMSI의 최신 위치 조회 +-- SELECT * FROM snp_data.ais_target WHERE mmsi = 123456789 ORDER BY message_timestamp DESC LIMIT 1; + +-- 2. 특정 시간 범위의 항적 조회 +-- SELECT * FROM snp_data.ais_target +-- WHERE mmsi = 123456789 +-- AND message_timestamp BETWEEN '2024-12-04 00:00:00+00' AND '2024-12-04 01:00:00+00' +-- ORDER BY message_timestamp; + +-- 3. 특정 구역(원형) 내 선박 조회 +-- SELECT DISTINCT ON (mmsi) * +-- FROM snp_data.ais_target +-- WHERE message_timestamp > NOW() - INTERVAL '1 hour' +-- AND ST_DWithin( +-- geom::geography, +-- ST_SetSRID(ST_MakePoint(129.0, 35.0), 4326)::geography, +-- 50000 -- 50km 반경 +-- ) +-- ORDER BY mmsi, message_timestamp DESC; + +-- 4. 다음 7일 파티션 미리 생성 +-- SELECT * FROM snp_data.create_future_ais_target_daily_partitions(7); + +-- 5. 특정 일 파티션 생성 +-- SELECT snp_data.create_ais_target_daily_partition('2024-12-10'); + +-- 6. 14일 이전 파티션 정리 +-- SELECT * FROM snp_data.cleanup_old_ais_target_daily_partitions(14); + +-- 7. 파티션별 통계 조회 +-- SELECT * FROM snp_data.ais_target_daily_partition_stats(); + +-- 8. 전체 통계 조회 +-- SELECT * FROM snp_data.ais_target_stats(); + +-- ============================================ +-- Job Schedule 등록 +-- ============================================ + +-- 1. aisTargetImportJob: 매 분 15초에 실행 +INSERT INTO public.job_schedule (job_name, cron_expression, description, active, created_at, updated_at, created_by, updated_by) +VALUES ( + 'aisTargetImportJob', + '15 * * * * ?', + 'AIS Target 위치 정보 수집 (S&P Global API) - 매 분 15초 실행', + true, + NOW(), + NOW(), + 'SYSTEM', + 'SYSTEM' +) ON CONFLICT (job_name) DO UPDATE SET + cron_expression = EXCLUDED.cron_expression, + description = EXCLUDED.description, + active = EXCLUDED.active, + updated_at = NOW(); + +-- 2. partitionManagerJob: 매일 00:10에 실행 +-- Daily 파티션: 매일 생성/삭제 (ais_target_YYMMDD) +-- Monthly 파티션: 말일 생성, 1일 삭제 (table_YYYY_MM) +INSERT INTO public.job_schedule (job_name, cron_expression, description, active, created_at, updated_at, created_by, updated_by) +VALUES ( + 'partitionManagerJob', + '0 10 0 * * ?', + '파티션 관리 - 매일 00:10 실행 (Daily: 생성/삭제, Monthly: 말일 생성/1일 삭제)', + true, + NOW(), + NOW(), + 'SYSTEM', + 'SYSTEM' +) ON CONFLICT (job_name) DO UPDATE SET + cron_expression = EXCLUDED.cron_expression, + description = EXCLUDED.description, + active = EXCLUDED.active, + updated_at = NOW(); diff --git a/src/main/resources/sql/old_job_cleanup.sql b/src/main/resources/sql/old_job_cleanup.sql new file mode 100644 index 0000000..227afdc --- /dev/null +++ b/src/main/resources/sql/old_job_cleanup.sql @@ -0,0 +1,67 @@ + -- 오래된 STARTED 상태 Job을 정리하는 SQL 쿼리입니다. +-- std_snp_data 스키마에 batch_ 접두사를 사용하는 예시입니다. 실제 스키마에 맞추어 수정해서 사용하세요. + +-- 참고: 시간 간격 변경이 필요하면 INTERVAL '2 hours' 부분을 수정하세요: +-- 1시간: INTERVAL '1 hour' +-- 30분: INTERVAL '30 minutes' +-- 1일: INTERVAL '1 day' + +-- 2시간 이상 경과한 STARTED 상태 Job Execution 조회 +SELECT + je.job_execution_id, + ji.job_name, + je.status, + je.start_time, + je.end_time, + NOW() - je.start_time AS elapsed_time +FROM snp_data.batch_job_execution je + JOIN snp_data.batch_job_instance ji ON je.job_instance_id = ji.job_instance_id +WHERE je.status = 'STARTED' + AND je.start_time < NOW() - INTERVAL '2 hours' +ORDER BY je.start_time; + + +-- Step Execution을 FAILED로 변경 +UPDATE snp_data.batch_step_execution +SET + status = 'FAILED', + exit_code = 'FAILED', + exit_message = 'Manually cleaned up - stale execution (process restart)', + end_time = NOW(), + last_updated = NOW() +WHERE job_execution_id IN ( + SELECT job_execution_id + FROM snp_data.batch_job_execution + WHERE status = 'STARTED' + AND start_time < NOW() - INTERVAL '2 hours' + ); + + + +-- Job Execution을 FAILED로 변경 +UPDATE snp_data.batch_job_execution +SET + status = 'FAILED', + exit_code = 'FAILED', + exit_message = 'Manually cleaned up - stale execution (process restart)', + end_time = NOW(), + last_updated = NOW() +WHERE status = 'STARTED' + AND start_time < NOW() - INTERVAL '2 hours'; + + + +-- 정리 후 STARTED 상태 확인 +SELECT + je.job_execution_id, + ji.job_name, + je.status, + je.exit_code, + je.start_time, + je.end_time +FROM snp_data.batch_job_execution je + JOIN snp_data.batch_job_instance ji ON je.job_instance_id = ji.job_instance_id +WHERE je.status IN ('STARTED', 'FAILED') +ORDER BY je.start_time DESC + LIMIT 20; + diff --git a/src/main/resources/sql/quartz_tables_postgres.sql b/src/main/resources/sql/quartz_tables_postgres.sql new file mode 100644 index 0000000..8ebe8f8 --- /dev/null +++ b/src/main/resources/sql/quartz_tables_postgres.sql @@ -0,0 +1,187 @@ +-- Quartz Scheduler JDBC Store DDL for PostgreSQL +-- Schema: std_snp_data +-- tablePrefix 설정: std_snp_data.QRTZ_ +-- +-- 사용법: psql -d -f quartz_tables_postgres.sql + +SET search_path TO std_snp_data; + +DROP TABLE IF EXISTS QRTZ_FIRED_TRIGGERS; +DROP TABLE IF EXISTS QRTZ_PAUSED_TRIGGER_GRPS; +DROP TABLE IF EXISTS QRTZ_SCHEDULER_STATE; +DROP TABLE IF EXISTS QRTZ_LOCKS; +DROP TABLE IF EXISTS QRTZ_SIMPLE_TRIGGERS; +DROP TABLE IF EXISTS QRTZ_CRON_TRIGGERS; +DROP TABLE IF EXISTS QRTZ_SIMPROP_TRIGGERS; +DROP TABLE IF EXISTS QRTZ_BLOB_TRIGGERS; +DROP TABLE IF EXISTS QRTZ_TRIGGERS; +DROP TABLE IF EXISTS QRTZ_JOB_DETAILS; +DROP TABLE IF EXISTS QRTZ_CALENDARS; + +CREATE TABLE QRTZ_JOB_DETAILS +( + SCHED_NAME VARCHAR(120) NOT NULL, + JOB_NAME VARCHAR(200) NOT NULL, + JOB_GROUP VARCHAR(200) NOT NULL, + DESCRIPTION VARCHAR(250) NULL, + JOB_CLASS_NAME VARCHAR(250) NOT NULL, + IS_DURABLE BOOL NOT NULL, + IS_NONCONCURRENT BOOL NOT NULL, + IS_UPDATE_DATA BOOL NOT NULL, + REQUESTS_RECOVERY BOOL NOT NULL, + JOB_DATA BYTEA NULL, + PRIMARY KEY (SCHED_NAME, JOB_NAME, JOB_GROUP) +); + +CREATE TABLE QRTZ_TRIGGERS +( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR(200) NOT NULL, + TRIGGER_GROUP VARCHAR(200) NOT NULL, + JOB_NAME VARCHAR(200) NOT NULL, + JOB_GROUP VARCHAR(200) NOT NULL, + DESCRIPTION VARCHAR(250) NULL, + NEXT_FIRE_TIME BIGINT NULL, + PREV_FIRE_TIME BIGINT NULL, + PRIORITY INTEGER NULL, + TRIGGER_STATE VARCHAR(16) NOT NULL, + TRIGGER_TYPE VARCHAR(8) NOT NULL, + START_TIME BIGINT NOT NULL, + END_TIME BIGINT NULL, + CALENDAR_NAME VARCHAR(200) NULL, + MISFIRE_INSTR SMALLINT NULL, + JOB_DATA BYTEA NULL, + PRIMARY KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP), + FOREIGN KEY (SCHED_NAME, JOB_NAME, JOB_GROUP) + REFERENCES QRTZ_JOB_DETAILS (SCHED_NAME, JOB_NAME, JOB_GROUP) +); + +CREATE TABLE QRTZ_SIMPLE_TRIGGERS +( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR(200) NOT NULL, + TRIGGER_GROUP VARCHAR(200) NOT NULL, + REPEAT_COUNT BIGINT NOT NULL, + REPEAT_INTERVAL BIGINT NOT NULL, + TIMES_TRIGGERED BIGINT NOT NULL, + PRIMARY KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP), + FOREIGN KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP) + REFERENCES QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP) +); + +CREATE TABLE QRTZ_CRON_TRIGGERS +( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR(200) NOT NULL, + TRIGGER_GROUP VARCHAR(200) NOT NULL, + CRON_EXPRESSION VARCHAR(120) NOT NULL, + TIME_ZONE_ID VARCHAR(80), + PRIMARY KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP), + FOREIGN KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP) + REFERENCES QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP) +); + +CREATE TABLE QRTZ_SIMPROP_TRIGGERS +( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR(200) NOT NULL, + TRIGGER_GROUP VARCHAR(200) NOT NULL, + STR_PROP_1 VARCHAR(512) NULL, + STR_PROP_2 VARCHAR(512) NULL, + STR_PROP_3 VARCHAR(512) NULL, + INT_PROP_1 INT NULL, + INT_PROP_2 INT NULL, + LONG_PROP_1 BIGINT NULL, + LONG_PROP_2 BIGINT NULL, + DEC_PROP_1 NUMERIC(13, 4) NULL, + DEC_PROP_2 NUMERIC(13, 4) NULL, + BOOL_PROP_1 BOOL NULL, + BOOL_PROP_2 BOOL NULL, + PRIMARY KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP), + FOREIGN KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP) + REFERENCES QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP) +); + +CREATE TABLE QRTZ_BLOB_TRIGGERS +( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR(200) NOT NULL, + TRIGGER_GROUP VARCHAR(200) NOT NULL, + BLOB_DATA BYTEA NULL, + PRIMARY KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP), + FOREIGN KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP) + REFERENCES QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP) +); + +CREATE TABLE QRTZ_CALENDARS +( + SCHED_NAME VARCHAR(120) NOT NULL, + CALENDAR_NAME VARCHAR(200) NOT NULL, + CALENDAR BYTEA NOT NULL, + PRIMARY KEY (SCHED_NAME, CALENDAR_NAME) +); + +CREATE TABLE QRTZ_PAUSED_TRIGGER_GRPS +( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_GROUP VARCHAR(200) NOT NULL, + PRIMARY KEY (SCHED_NAME, TRIGGER_GROUP) +); + +CREATE TABLE QRTZ_FIRED_TRIGGERS +( + SCHED_NAME VARCHAR(120) NOT NULL, + ENTRY_ID VARCHAR(95) NOT NULL, + TRIGGER_NAME VARCHAR(200) NOT NULL, + TRIGGER_GROUP VARCHAR(200) NOT NULL, + INSTANCE_NAME VARCHAR(200) NOT NULL, + FIRED_TIME BIGINT NOT NULL, + SCHED_TIME BIGINT NOT NULL, + PRIORITY INTEGER NOT NULL, + STATE VARCHAR(16) NOT NULL, + JOB_NAME VARCHAR(200) NULL, + JOB_GROUP VARCHAR(200) NULL, + IS_NONCONCURRENT BOOL NULL, + REQUESTS_RECOVERY BOOL NULL, + PRIMARY KEY (SCHED_NAME, ENTRY_ID) +); + +CREATE TABLE QRTZ_SCHEDULER_STATE +( + SCHED_NAME VARCHAR(120) NOT NULL, + INSTANCE_NAME VARCHAR(200) NOT NULL, + LAST_CHECKIN_TIME BIGINT NOT NULL, + CHECKIN_INTERVAL BIGINT NOT NULL, + PRIMARY KEY (SCHED_NAME, INSTANCE_NAME) +); + +CREATE TABLE QRTZ_LOCKS +( + SCHED_NAME VARCHAR(120) NOT NULL, + LOCK_NAME VARCHAR(40) NOT NULL, + PRIMARY KEY (SCHED_NAME, LOCK_NAME) +); + +-- Indexes +CREATE INDEX IDX_QRTZ_J_REQ_RECOVERY ON QRTZ_JOB_DETAILS (SCHED_NAME, REQUESTS_RECOVERY); +CREATE INDEX IDX_QRTZ_J_GRP ON QRTZ_JOB_DETAILS (SCHED_NAME, JOB_GROUP); + +CREATE INDEX IDX_QRTZ_T_J ON QRTZ_TRIGGERS (SCHED_NAME, JOB_NAME, JOB_GROUP); +CREATE INDEX IDX_QRTZ_T_JG ON QRTZ_TRIGGERS (SCHED_NAME, JOB_GROUP); +CREATE INDEX IDX_QRTZ_T_C ON QRTZ_TRIGGERS (SCHED_NAME, CALENDAR_NAME); +CREATE INDEX IDX_QRTZ_T_G ON QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_GROUP); +CREATE INDEX IDX_QRTZ_T_STATE ON QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_STATE); +CREATE INDEX IDX_QRTZ_T_N_STATE ON QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP, TRIGGER_STATE); +CREATE INDEX IDX_QRTZ_T_N_G_STATE ON QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_GROUP, TRIGGER_STATE); +CREATE INDEX IDX_QRTZ_T_NEXT_FIRE_TIME ON QRTZ_TRIGGERS (SCHED_NAME, NEXT_FIRE_TIME); +CREATE INDEX IDX_QRTZ_T_NFT_ST ON QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_STATE, NEXT_FIRE_TIME); +CREATE INDEX IDX_QRTZ_T_NFT_MISFIRE ON QRTZ_TRIGGERS (SCHED_NAME, MISFIRE_INSTR, NEXT_FIRE_TIME); +CREATE INDEX IDX_QRTZ_T_NFT_ST_MISFIRE ON QRTZ_TRIGGERS (SCHED_NAME, MISFIRE_INSTR, NEXT_FIRE_TIME, TRIGGER_STATE); +CREATE INDEX IDX_QRTZ_T_NFT_ST_MISFIRE_GRP ON QRTZ_TRIGGERS (SCHED_NAME, MISFIRE_INSTR, NEXT_FIRE_TIME, TRIGGER_GROUP, TRIGGER_STATE); + +CREATE INDEX IDX_QRTZ_FT_TRIG_INST_NAME ON QRTZ_FIRED_TRIGGERS (SCHED_NAME, INSTANCE_NAME); +CREATE INDEX IDX_QRTZ_FT_INST_JOB_REQ_RCVRY ON QRTZ_FIRED_TRIGGERS (SCHED_NAME, INSTANCE_NAME, REQUESTS_RECOVERY); +CREATE INDEX IDX_QRTZ_FT_J_G ON QRTZ_FIRED_TRIGGERS (SCHED_NAME, JOB_NAME, JOB_GROUP); +CREATE INDEX IDX_QRTZ_FT_JG ON QRTZ_FIRED_TRIGGERS (SCHED_NAME, JOB_GROUP); +CREATE INDEX IDX_QRTZ_FT_T_G ON QRTZ_FIRED_TRIGGERS (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP); +CREATE INDEX IDX_QRTZ_FT_TG ON QRTZ_FIRED_TRIGGERS (SCHED_NAME, TRIGGER_GROUP);