diff --git a/pom.xml b/pom.xml index ca2538e..c7b1736 100644 --- a/pom.xml +++ b/pom.xml @@ -111,6 +111,20 @@ 2.3.0 + + + com.github.ben-manes.caffeine + caffeine + 3.1.8 + + + + + org.locationtech.jts + jts-core + 1.19.0 + + org.springframework.boot diff --git a/src/main/java/com/snp/batch/SnpBatchApplication.java b/src/main/java/com/snp/batch/SnpBatchApplication.java index bf4315f..b59535c 100644 --- a/src/main/java/com/snp/batch/SnpBatchApplication.java +++ b/src/main/java/com/snp/batch/SnpBatchApplication.java @@ -2,10 +2,12 @@ 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 SnpBatchApplication { public static void main(String[] 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/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/processor/BaseProcessor.java b/src/main/java/com/snp/batch/common/batch/processor/BaseProcessor.java index 0add9cc..a9ad40c 100644 --- a/src/main/java/com/snp/batch/common/batch/processor/BaseProcessor.java +++ b/src/main/java/com/snp/batch/common/batch/processor/BaseProcessor.java @@ -55,7 +55,7 @@ public abstract class BaseProcessor implements ItemProcessor { return null; } - log.debug("데이터 처리 중: {}", item); +// 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 index 6923f99..a06d804 100644 --- a/src/main/java/com/snp/batch/common/batch/reader/BaseApiReader.java +++ b/src/main/java/com/snp/batch/common/batch/reader/BaseApiReader.java @@ -1,5 +1,7 @@ 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; @@ -7,8 +9,13 @@ 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; @@ -72,12 +79,180 @@ public abstract class BaseApiReader implements ItemReader { 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; + } /** * 기본 생성자 (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()); + } + } + + /** + * 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 연동용) @@ -87,7 +262,7 @@ public abstract class BaseApiReader implements ItemReader { protected BaseApiReader(WebClient webClient) { this.webClient = webClient; } - + /** /** * Step 실행 전 초기화 및 API 정보 저장 * Spring Batch가 자동으로 StepExecution을 주입하고 이 메서드를 호출함 @@ -98,6 +273,9 @@ public abstract class BaseApiReader implements ItemReader { public void saveApiInfoToContext(StepExecution stepExecution) { this.stepExecution = stepExecution; + // Reader 상태 초기화 (Job 재실행 시 필수) + resetReaderState(); + // API 정보를 StepExecutionContext에 저장 ExecutionContext context = stepExecution.getExecutionContext(); @@ -140,6 +318,48 @@ public abstract class BaseApiReader implements ItemReader { 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 호출 통계 업데이트 */ @@ -209,21 +429,42 @@ public abstract class BaseApiReader implements ItemReader { } // currentBatch가 비어있으면 다음 배치 로드 - if (currentBatch == null || !currentBatch.hasNext()) { + /*if (currentBatch == null || !currentBatch.hasNext()) { List nextBatch = fetchNextBatch(); // 더 이상 데이터가 없으면 종료 - if (nextBatch == null || nextBatch.isEmpty()) { +// 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(); } diff --git a/src/main/java/com/snp/batch/common/util/JsonChangeDetector.java b/src/main/java/com/snp/batch/common/util/JsonChangeDetector.java index 96231f1..41eaf65 100644 --- a/src/main/java/com/snp/batch/common/util/JsonChangeDetector.java +++ b/src/main/java/com/snp/batch/common/util/JsonChangeDetector.java @@ -13,14 +13,44 @@ public class JsonChangeDetector { // 해시 비교에서 제외할 필드 목록 (DataSetVersion 등) // 이 목록은 모든 JSON 계층에 걸쳐 적용됩니다. private static final java.util.Set EXCLUDE_KEYS = - java.util.Set.of("DataSetVersion", "APSStatus", "LastUpdateDate", "LastUpdateDateTime"); + java.util.Set.of("DataSetVersion", "APSStatus", "LastUpdateDateTime"); - private static final Map LIST_SORT_KEYS = Map.of( - // List 필드명 // 정렬 기준 키 - "OwnerHistory" ,"Sequence", // OwnerHistory는 Sequence를 기준으로 정렬 - "SurveyDatesHistoryUnique" , "SurveyDate" // SurveyDatesHistoryUnique는 SurveyDate를 기준으로 정렬 - // 추가적인 List/Array 필드가 있다면 여기에 추가 - ); + // ========================================================================= + // ✅ LIST_SORT_KEYS: 정적 초기화 블록을 사용한 Map 정의 + // ========================================================================= + private static final Map LIST_SORT_KEYS; + static { + // TreeMap을 사용하여 키를 알파벳 순으로 정렬할 수도 있지만, 여기서는 HashMap을 사용하고 final로 만듭니다. + Map map = new HashMap<>(); + // List 필드명 // 정렬 기준 복합 키 (JSON 필드명, 쉼표로 구분) + map.put("OwnerHistory", "OwnerCode,EffectiveDate,Sequence"); + map.put("CrewList", "LRNO,Shipname,Nationality"); + map.put("StowageCommodity", "Sequence,CommodityCode,StowageCode"); + map.put("GroupBeneficialOwnerHistory", "EffectiveDate,GroupBeneficialOwnerCode,Sequence"); + map.put("ShipManagerHistory", "EffectiveDate,ShipManagerCode,Sequence"); + map.put("OperatorHistory", "EffectiveDate,OperatorCode,Sequence"); + map.put("TechnicalManagerHistory", "EffectiveDate,Sequence,TechnicalManagerCode"); + map.put("BareBoatCharterHistory", "Sequence,EffectiveDate,BBChartererCode"); + map.put("NameHistory", "Sequence,EffectiveDate"); + map.put("FlagHistory", "FlagCode,EffectiveDate,Sequence"); + map.put("PandIHistory", "PandIClubCode,EffectiveDate"); + map.put("CallSignAndMmsiHistory", "EffectiveDate,SeqNo"); + map.put("IceClass", "IceClassCode"); + map.put("SafetyManagementCertificateHistory", "Sequence"); + map.put("ClassHistory", "ClassCode,EffectiveDate,Sequence"); + map.put("SurveyDatesHistory", "ClassSocietyCode"); + map.put("SurveyDatesHistoryUnique", "ClassSocietyCode,SurveyDate,SurveyType"); + map.put("SisterShipLinks", "LinkedLRNO"); + map.put("StatusHistory", "Sequence,StatusCode,StatusDate"); + map.put("SpecialFeature", "Sequence,SpecialFeatureCode"); + map.put("Thrusters", "Sequence"); + map.put("DarkActivityConfirmed", "Lrno,Mmsi,Dark_Time,Dark_Status"); + map.put("CompanyComplianceDetails", "OwCode"); + map.put("CompanyVesselRelationships", "LRNO"); + map.put("CompanyDetailsComplexWithCodesAndParent", "OWCODE,LastChangeDate"); + + LIST_SORT_KEYS = Collections.unmodifiableMap(map); + } // ========================================================================= // 1. JSON 문자열을 정렬 및 필터링된 Map으로 변환하는 핵심 로직 @@ -90,14 +120,16 @@ public class JsonChangeDetector { } } - // 2. 🔑 List 필드명에 따른 순서 정렬 로직 (추가된 핵심 로직) + // 2. 🔑 List 필드명에 따른 복합 순서 정렬 로직 (수정된 핵심 로직) String listFieldName = entry.getKey(); - String sortKey = LIST_SORT_KEYS.get(listFieldName); + String sortKeysString = LIST_SORT_KEYS.get(listFieldName); // 쉼표로 구분된 복합 키 문자열 + + if (sortKeysString != null && !filteredList.isEmpty() && filteredList.get(0) instanceof Map) { + // 복합 키 문자열을 개별 키 배열로 분리 + final String[] sortKeys = sortKeysString.split(","); - if (sortKey != null && !filteredList.isEmpty() && filteredList.get(0) instanceof Map) { // Map 요소를 가진 리스트인 경우에만 정렬 실행 try { - // 정렬 기준 키를 사용하여 Comparator를 생성 Collections.sort(filteredList, new Comparator() { @Override @SuppressWarnings("unchecked") @@ -105,22 +137,45 @@ public class JsonChangeDetector { Map map1 = (Map) o1; Map map2 = (Map) o2; - // 정렬 기준 키(sortKey)의 값을 가져와 비교 - Object key1 = map1.get(sortKey); - Object key2 = map2.get(sortKey); + // 복합 키(sortKeys)를 순서대로 순회하며 비교 + for (String rawSortKey : sortKeys) { + // 키의 공백 제거 + String sortKey = rawSortKey.trim(); - if (key1 == null || key2 == null) { - // 키 값이 null인 경우, Map의 전체 문자열로 비교 (안전장치) - return map1.toString().compareTo(map2.toString()); + Object key1 = map1.get(sortKey); + Object key2 = map2.get(sortKey); + + // null 값 처리 로직 + if (key1 == null && key2 == null) { + continue; // 두 값이 동일하므로 다음 키로 이동 + } + if (key1 == null) { + // key1이 null이고 key2는 null이 아니면, key2가 더 크다고 (뒤 순서) 간주하고 1 반환 + return 1; + } + if (key2 == null) { + // key2가 null이고 key1은 null이 아니면, key1이 더 크다고 (뒤 순서) 간주하고 -1 반환 + return -1; + } + + // 값을 문자열로 변환하여 비교 (String, Number, Date 타입 모두 처리 가능) + int comparisonResult = key1.toString().compareTo(key2.toString()); + + // 현재 키에서 순서가 결정되면 즉시 반환 + if (comparisonResult != 0) { + return comparisonResult; + } + // comparisonResult == 0 이면 다음 키로 이동하여 비교를 계속함 } - // String 타입으로 변환하여 비교 (Date, Number 타입도 대부분 String으로 처리 가능) - return key1.toString().compareTo(key2.toString()); + // 모든 키를 비교해도 동일한 경우 + // 이 경우 두 Map은 해시값 측면에서 동일한 것으로 간주되어야 합니다. + return 0; } }); } catch (Exception e) { System.err.println("List sort failed for key " + listFieldName + ": " + e.getMessage()); - // 정렬 실패 시 원래 순서 유지 + // 정렬 실패 시 원래 순서 유지 (filteredList 상태 유지) } } sortedMap.put(key, filteredList); @@ -132,7 +187,6 @@ public class JsonChangeDetector { return sortedMap; } - // ========================================================================= // 2. 해시 생성 로직 // ========================================================================= 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..62ba92b --- /dev/null +++ b/src/main/java/com/snp/batch/global/config/AsyncConfig.java @@ -0,0 +1,24 @@ +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 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; + } +} \ 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 index 3f5260a..d05a2f9 100644 --- a/src/main/java/com/snp/batch/global/config/MaritimeApiWebClientConfig.java +++ b/src/main/java/com/snp/batch/global/config/MaritimeApiWebClientConfig.java @@ -29,9 +29,13 @@ public class MaritimeApiWebClientConfig { @Value("${app.batch.ship-api.url}") private String maritimeApiUrl; - @Value("https://aisapi.maritime.spglobal.com") + @Value("${app.batch.ais-api.url}") private String maritimeAisApiUrl; + @Value("${app.batch.webservice-api.url}") + private String maritimeServiceApiUrl; + + @Value("${app.batch.ship-api.username}") private String maritimeApiUsername; @@ -60,7 +64,7 @@ public class MaritimeApiWebClientConfig { .defaultHeaders(headers -> headers.setBasicAuth(maritimeApiUsername, maritimeApiPassword)) .codecs(configurer -> configurer .defaultCodecs() - .maxInMemorySize(20 * 1024 * 1024)) // 20MB 버퍼 + .maxInMemorySize(100 * 1024 * 1024)) // 30MB 버퍼 .build(); } @@ -76,7 +80,23 @@ public class MaritimeApiWebClientConfig { .defaultHeaders(headers -> headers.setBasicAuth(maritimeApiUsername, maritimeApiPassword)) .codecs(configurer -> configurer .defaultCodecs() - .maxInMemorySize(20 * 1024 * 1024)) // 20MB 버퍼 + .maxInMemorySize(50 * 1024 * 1024)) // 50MB 버퍼 (AIS GetTargets 응답 ~20MB+) + .build(); + } + + @Bean(name = "maritimeServiceApiWebClient") + public WebClient maritimeServiceApiWebClient(){ + log.info("========================================"); + log.info("Maritime AIS API WebClient 생성"); + log.info("Base URL: {}", maritimeServiceApiUrl); + log.info("========================================"); + + return WebClient.builder() + .baseUrl(maritimeServiceApiUrl) + .defaultHeaders(headers -> headers.setBasicAuth(maritimeApiUsername, maritimeApiPassword)) + .codecs(configurer -> configurer + .defaultCodecs() + .maxInMemorySize(100 * 1024 * 1024)) // 100MB 버퍼 .build(); } } diff --git a/src/main/java/com/snp/batch/global/config/SwaggerConfig.java b/src/main/java/com/snp/batch/global/config/SwaggerConfig.java index c90a229..f03338d 100644 --- a/src/main/java/com/snp/batch/global/config/SwaggerConfig.java +++ b/src/main/java/com/snp/batch/global/config/SwaggerConfig.java @@ -30,20 +30,29 @@ public class SwaggerConfig { @Value("${server.port:8081}") private int serverPort; + @Value("${server.servlet.context-path:}") + private String contextPath; + @Bean public OpenAPI openAPI() { return new OpenAPI() .info(apiInfo()) .servers(List.of( new Server() - .url("http://localhost:" + serverPort) + .url("http://localhost:" + serverPort + contextPath) .description("로컬 개발 서버"), new Server() - .url("http://211.208.115.83:" + serverPort) + .url("http://10.26.252.39:" + serverPort + contextPath) + .description("로컬 개발 서버"), + new Server() + .url("http://211.208.115.83:" + serverPort + contextPath) .description("중계 서버"), new Server() - .url("https://api.snp-batch.com") - .description("운영 서버 (예시)") + .url("http://10.187.58.58:" + serverPort + contextPath) + .description("운영 서버"), + new Server() + .url("https://mda.kcg.go.kr" + contextPath) + .description("운영 서버 프록시") )); } @@ -79,4 +88,4 @@ public class SwaggerConfig { .name("Apache 2.0") .url("https://www.apache.org/licenses/LICENSE-2.0")); } -} +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/global/controller/BatchController.java b/src/main/java/com/snp/batch/global/controller/BatchController.java index db66315..13c798e 100644 --- a/src/main/java/com/snp/batch/global/controller/BatchController.java +++ b/src/main/java/com/snp/batch/global/controller/BatchController.java @@ -1,20 +1,26 @@ package com.snp.batch.global.controller; import com.snp.batch.global.dto.JobExecutionDto; +import com.snp.batch.global.dto.JobLaunchRequest; import com.snp.batch.global.dto.ScheduleRequest; import com.snp.batch.global.dto.ScheduleResponse; import com.snp.batch.service.BatchService; 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 java.util.HashMap; import java.util.List; import java.util.Map; @@ -56,6 +62,39 @@ public class BatchController { } } + @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 = "조회 성공") @@ -178,7 +217,7 @@ public class BatchController { } } - @PutMapping("/schedules/{jobName}") + @PostMapping("/schedules/{jobName}/update") public ResponseEntity> updateSchedule( @PathVariable String jobName, @RequestBody Map request) { @@ -206,7 +245,7 @@ public class BatchController { @ApiResponse(responseCode = "200", description = "삭제 성공"), @ApiResponse(responseCode = "500", description = "삭제 실패") }) - @DeleteMapping("/schedules/{jobName}") + @PostMapping("/schedules/{jobName}/delete") public ResponseEntity> deleteSchedule( @Parameter(description = "배치 작업 이름", required = true) @PathVariable String jobName) { @@ -226,7 +265,7 @@ public class BatchController { } } - @PatchMapping("/schedules/{jobName}/toggle") + @PostMapping("/schedules/{jobName}/toggle") public ResponseEntity> toggleSchedule( @PathVariable String jobName, @RequestBody Map request) { 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/model/BatchApiLog.java b/src/main/java/com/snp/batch/global/model/BatchApiLog.java new file mode 100644 index 0000000..41ae691 --- /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 = "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/BatchLastExecution.java b/src/main/java/com/snp/batch/global/model/BatchLastExecution.java new file mode 100644 index 0000000..d75b49b --- /dev/null +++ b/src/main/java/com/snp/batch/global/model/BatchLastExecution.java @@ -0,0 +1,46 @@ +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 = "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/partition/PartitionConfig.java b/src/main/java/com/snp/batch/global/partition/PartitionConfig.java new file mode 100644 index 0000000..33fc195 --- /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: snp_data + * table-name: ais_target + * partition-column: message_timestamp + * periods-ahead: 3 + * monthly-tables: + * - schema: 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 = "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..6ec53ff --- /dev/null +++ b/src/main/java/com/snp/batch/global/repository/BatchApiLogRepository.java @@ -0,0 +1,10 @@ +package com.snp.batch.global.repository; + +import com.snp.batch.global.model.BatchApiLog; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface BatchApiLogRepository extends JpaRepository { + +} \ No newline at end of file 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/TimelineRepository.java b/src/main/java/com/snp/batch/global/repository/TimelineRepository.java index 2c28809..7b7b7f5 100644 --- a/src/main/java/com/snp/batch/global/repository/TimelineRepository.java +++ b/src/main/java/com/snp/batch/global/repository/TimelineRepository.java @@ -1,6 +1,6 @@ package com.snp.batch.global.repository; -import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; @@ -13,10 +13,25 @@ import java.util.Map; * Step Context 등 불필요한 데이터를 조회하지 않고 필요한 정보만 가져옴 */ @Repository -@RequiredArgsConstructor 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"; + } /** * 특정 Job의 특정 범위 내 실행 이력 조회 (경량) @@ -27,19 +42,19 @@ public class TimelineRepository { LocalDateTime startTime, LocalDateTime endTime) { - String sql = """ + 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 BATCH_JOB_EXECUTION je - INNER JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID + 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); } @@ -51,19 +66,19 @@ public class TimelineRepository { LocalDateTime startTime, LocalDateTime endTime) { - String sql = """ + 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 BATCH_JOB_EXECUTION je - INNER JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID + 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); } @@ -72,17 +87,17 @@ public class TimelineRepository { * 현재 실행 중인 Job 조회 (STARTED, STARTING 상태) */ public List> findRunningExecutions() { - String sql = """ + 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 BATCH_JOB_EXECUTION je - INNER JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID + 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); } @@ -91,18 +106,18 @@ public class TimelineRepository { * 최근 실행 이력 조회 (상위 N개) */ public List> findRecentExecutions(int limit) { - String sql = """ + 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 BATCH_JOB_EXECUTION je - INNER JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID + 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); } diff --git a/src/main/java/com/snp/batch/jobs/aistarget/batch/config/AisTargetImportJobConfig.java b/src/main/java/com/snp/batch/jobs/aistarget/batch/config/AisTargetImportJobConfig.java new file mode 100644 index 0000000..d5f7ccf --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/aistarget/batch/config/AisTargetImportJobConfig.java @@ -0,0 +1,139 @@ +package com.snp.batch.jobs.aistarget.batch.config; + +import com.snp.batch.common.batch.config.BaseJobConfig; +import com.snp.batch.jobs.aistarget.batch.dto.AisTargetDto; +import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity; +import com.snp.batch.jobs.aistarget.batch.processor.AisTargetDataProcessor; +import com.snp.batch.jobs.aistarget.batch.reader.AisTargetDataReader; +import com.snp.batch.jobs.aistarget.batch.writer.AisTargetDataWriter; +import com.snp.batch.jobs.aistarget.classifier.Core20CacheManager; +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.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.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 java.time.OffsetDateTime; + +/** + * AIS Target Import Job Config + * + * 스케줄: 매 분 15초 (0 15 * * * * ?) + * API: POST /AisSvc.svc/AIS/GetTargets + * 파라미터: {"sinceSeconds": "60"} + * + * 동작: + * - 최근 60초 동안의 전체 선박 위치 정보 수집 + * - 약 33,000건/분 처리 + * - UPSERT 방식으로 DB 저장 + */ +@Slf4j +@Configuration +public class AisTargetImportJobConfig extends BaseJobConfig { + + private final AisTargetDataProcessor aisTargetDataProcessor; + private final AisTargetDataWriter aisTargetDataWriter; + private final WebClient maritimeAisApiWebClient; + private final Core20CacheManager core20CacheManager; + + @Value("${app.batch.ais-target.since-seconds:60}") + private int sinceSeconds; + + @Value("${app.batch.ais-target.chunk-size:5000}") + private int chunkSize; + + public AisTargetImportJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + AisTargetDataProcessor aisTargetDataProcessor, + AisTargetDataWriter aisTargetDataWriter, + @Qualifier("maritimeAisApiWebClient") WebClient maritimeAisApiWebClient, + Core20CacheManager core20CacheManager) { + super(jobRepository, transactionManager); + this.aisTargetDataProcessor = aisTargetDataProcessor; + this.aisTargetDataWriter = aisTargetDataWriter; + this.maritimeAisApiWebClient = maritimeAisApiWebClient; + this.core20CacheManager = core20CacheManager; + } + + @Override + protected String getJobName() { + return "aisTargetImportJob"; + } + + @Override + protected String getStepName() { + return "aisTargetImportStep"; + } + + @Override + protected ItemReader createReader() { + return new AisTargetDataReader(maritimeAisApiWebClient, sinceSeconds); + } + + @Override + protected ItemProcessor createProcessor() { + return aisTargetDataProcessor; + } + + @Override + protected ItemWriter createWriter() { + return aisTargetDataWriter; + } + + @Override + protected int getChunkSize() { + return chunkSize; + } + + @Override + protected void configureJob(JobBuilder jobBuilder) { + jobBuilder.listener(new JobExecutionListener() { + @Override + public void beforeJob(JobExecution jobExecution) { + // 배치 수집 시점 설정 + OffsetDateTime collectedAt = OffsetDateTime.now(); + aisTargetDataProcessor.setCollectedAt(collectedAt); + log.info("[{}] Job 시작 - 수집 시점: {}", getJobName(), collectedAt); + + // Core20 캐시 관리 + // 1. 캐시가 비어있으면 즉시 로딩 (첫 실행 또는 재시작 시) + // 2. 지정된 시간대(기본 04:00)이면 일일 갱신 수행 + if (!core20CacheManager.isLoaded()) { + log.info("[{}] Core20 캐시 초기 로딩 시작", getJobName()); + core20CacheManager.refresh(); + } else if (core20CacheManager.shouldRefresh()) { + log.info("[{}] Core20 캐시 일일 갱신 시작 (스케줄: {}시)", + getJobName(), core20CacheManager.getLastRefreshTime()); + core20CacheManager.refresh(); + } + } + + @Override + public void afterJob(JobExecution jobExecution) { + log.info("[{}] Job 완료 - 상태: {}, 처리 건수: {}, Core20 캐시 크기: {}", + getJobName(), + jobExecution.getStatus(), + jobExecution.getStepExecutions().stream() + .mapToLong(se -> se.getWriteCount()) + .sum(), + core20CacheManager.size()); + } + }); + } + + @Bean(name = "aisTargetImportJob") + public Job aisTargetImportJob() { + return job(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/aistarget/batch/dto/AisTargetApiResponse.java b/src/main/java/com/snp/batch/jobs/aistarget/batch/dto/AisTargetApiResponse.java new file mode 100644 index 0000000..2c19f30 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/aistarget/batch/dto/AisTargetApiResponse.java @@ -0,0 +1,27 @@ +package com.snp.batch.jobs.aistarget.batch.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * AIS GetTargets API 응답 래퍼 + * + * API 응답 구조: + * { + * "targetArr": [...] + * } + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AisTargetApiResponse { + + @JsonProperty("targetEnhancedArr") + private List targetArr; +} diff --git a/src/main/java/com/snp/batch/jobs/aistarget/batch/dto/AisTargetDto.java b/src/main/java/com/snp/batch/jobs/aistarget/batch/dto/AisTargetDto.java new file mode 100644 index 0000000..6ecca2b --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/aistarget/batch/dto/AisTargetDto.java @@ -0,0 +1,167 @@ +package com.snp.batch.jobs.aistarget.batch.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * AIS Target API 응답 DTO + * + * API: POST /AisSvc.svc/AIS/GetTargets + * Request: {"sinceSeconds": "60"} + * Response: {"targetArr": [...]} + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AisTargetDto { + + @JsonProperty("MMSI") + private Long mmsi; + + @JsonProperty("IMO") + private Long imo; + + @JsonProperty("AgeMinutes") + private Double ageMinutes; + + @JsonProperty("Lat") + private Double lat; + + @JsonProperty("Lon") + private Double lon; + + @JsonProperty("Heading") + private Double heading; + + @JsonProperty("SoG") + private Double sog; // Speed over Ground + + @JsonProperty("CoG") + private Double cog; // Course over Ground (if available) + + @JsonProperty("Width") + private Integer width; + + @JsonProperty("Length") + private Integer length; + + @JsonProperty("Draught") + private Double draught; + + @JsonProperty("Name") + private String name; + + @JsonProperty("Callsign") + private String callsign; + + @JsonProperty("Destination") + private String destination; + + @JsonProperty("ETA") + private String eta; + + @JsonProperty("Status") + private String status; + + @JsonProperty("VesselType") + private String vesselType; + + @JsonProperty("ExtraInfo") + private String extraInfo; + + @JsonProperty("PositionAccuracy") + private Integer positionAccuracy; + + @JsonProperty("RoT") + private Integer rot; // Rate of Turn + + @JsonProperty("TimestampUTC") + private Integer timestampUtc; + + @JsonProperty("RepeatIndicator") + private Integer repeatIndicator; + + @JsonProperty("RAIMFlag") + private Integer raimFlag; + + @JsonProperty("RadioStatus") + private Integer radioStatus; + + @JsonProperty("Regional") + private Integer regional; + + @JsonProperty("Regional2") + private Integer regional2; + + @JsonProperty("Spare") + private Integer spare; + + @JsonProperty("Spare2") + private Integer spare2; + + @JsonProperty("AISVersion") + private Integer aisVersion; + + @JsonProperty("PositionFixType") + private Integer positionFixType; + + @JsonProperty("DTE") + private Integer dte; + + @JsonProperty("BandFlag") + private Integer bandFlag; + + @JsonProperty("ReceivedDate") + private String receivedDate; + + @JsonProperty("MessageTimestamp") + private String messageTimestamp; + + @JsonProperty("LengthBow") + private Integer lengthBow; + + @JsonProperty("LengthStern") + private Integer lengthStern; + + @JsonProperty("WidthPort") + private Integer widthPort; + + @JsonProperty("WidthStarboard") + private Integer widthStarboard; + + // TargetEnhanced 컬럼 추가 + @JsonProperty("TonnesCargo") + private Integer tonnesCargo; + @JsonProperty("InSTS") + private Integer inSTS; + @JsonProperty("OnBerth") + private Boolean onBerth; + @JsonProperty("DWT") + private Integer dwt; + @JsonProperty("Anomalous") + private String anomalous; + @JsonProperty("DestinationPortID") + private Integer destinationPortID; + @JsonProperty("DestinationTidied") + private String destinationTidied; + @JsonProperty("DestinationUNLOCODE") + private String destinationUNLOCODE; + @JsonProperty("ImoVerified") + private String imoVerified; + @JsonProperty("LastStaticUpdateReceived") + private String lastStaticUpdateReceived; + @JsonProperty("LPCCode") + private Integer lpcCode; + @JsonProperty("MessageType") + private Integer messageType; + @JsonProperty("Source") + private String source; + @JsonProperty("StationId") + private String stationId; + @JsonProperty("ZoneId") + private Double zoneId; +} diff --git a/src/main/java/com/snp/batch/jobs/aistarget/batch/entity/AisTargetEntity.java b/src/main/java/com/snp/batch/jobs/aistarget/batch/entity/AisTargetEntity.java new file mode 100644 index 0000000..1a83e4c --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/aistarget/batch/entity/AisTargetEntity.java @@ -0,0 +1,121 @@ +package com.snp.batch.jobs.aistarget.batch.entity; + +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.OffsetDateTime; + +/** + * AIS Target Entity + * + * 테이블: snp_data.ais_target + * PK: mmsi + message_timestamp (복합키) + * + * 용도: + * - 선박 위치 이력 저장 (항적 분석용) + * - 특정 시점/구역 선박 조회 + * - LineString 항적 생성 기반 데이터 + */ +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class AisTargetEntity extends BaseEntity { + + // ========== PK (복합키) ========== + private Long mmsi; + private OffsetDateTime messageTimestamp; + + // ========== 선박 식별 정보 ========== + private Long imo; + private String name; + private String callsign; + private String vesselType; + private String extraInfo; + + // ========== 위치 정보 ========== + private Double lat; + private Double lon; + // geom은 DB에서 ST_SetSRID(ST_MakePoint(lon, lat), 4326)로 생성 + + // ========== 항해 정보 ========== + private Double heading; + private Double sog; // Speed over Ground + private Double cog; // Course over Ground + private Integer rot; // Rate of Turn + + // ========== 선박 제원 ========== + private Integer length; + private Integer width; + private Double draught; + private Integer lengthBow; + private Integer lengthStern; + private Integer widthPort; + private Integer widthStarboard; + + // ========== 목적지 정보 ========== + private String destination; + private OffsetDateTime eta; + private String status; + + // ========== AIS 메시지 정보 ========== + private Double ageMinutes; + private Integer positionAccuracy; + private Integer timestampUtc; + private Integer repeatIndicator; + private Integer raimFlag; + private Integer radioStatus; + private Integer regional; + private Integer regional2; + private Integer spare; + private Integer spare2; + private Integer aisVersion; + private Integer positionFixType; + private Integer dte; + private Integer bandFlag; + + // ========== 타임스탬프 ========== + private OffsetDateTime receivedDate; + private OffsetDateTime collectedAt; // 배치 수집 시점 + + // ========== ClassType 분류 정보 ========== + /** + * 선박 클래스 타입 + * - "A": Core20에 등록된 선박 (Class A AIS 장착 의무 선박) + * - "B": Core20 미등록 선박 (Class B AIS 또는 미등록) + * - null: 미분류 (캐시 저장 전) + */ + private String classType; + + /** + * Core20 테이블의 MMSI 값 + * - Class A인 경우에만 값이 있을 수 있음 + * - Class A이지만 Core20에 MMSI가 없는 경우 null + * - Class B인 경우 항상 null + */ + private String core20Mmsi; + + // TargetEnhanced 컬럼 추가 + private Integer tonnesCargo; + private Integer inSTS; + private Boolean onBerth; + private Integer dwt; + private String anomalous; + private Integer destinationPortID; + private String destinationTidied; + private String destinationUNLOCODE; + private String imoVerified; + private OffsetDateTime lastStaticUpdateReceived; + private Integer lpcCode; + private Integer messageType; + private String source; + private String stationId; + private Double zoneId; + +} diff --git a/src/main/java/com/snp/batch/jobs/aistarget/batch/processor/AisTargetDataProcessor.java b/src/main/java/com/snp/batch/jobs/aistarget/batch/processor/AisTargetDataProcessor.java new file mode 100644 index 0000000..4c8913d --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/aistarget/batch/processor/AisTargetDataProcessor.java @@ -0,0 +1,137 @@ +package com.snp.batch.jobs.aistarget.batch.processor; + +import com.snp.batch.common.batch.processor.BaseProcessor; +import com.snp.batch.jobs.aistarget.batch.dto.AisTargetDto; +import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + +/** + * AIS Target 데이터 Processor + * + * DTO → Entity 변환 + * - 타임스탬프 파싱 + * - 필터링 (유효한 위치 정보만) + */ +@Slf4j +@Component +public class AisTargetDataProcessor extends BaseProcessor { + + private static final DateTimeFormatter ISO_FORMATTER = DateTimeFormatter.ISO_DATE_TIME; + + // 배치 수집 시점 (모든 레코드에 동일하게 적용) + private OffsetDateTime collectedAt; + + public void setCollectedAt(OffsetDateTime collectedAt) { + this.collectedAt = collectedAt; + } + + @Override + protected AisTargetEntity processItem(AisTargetDto dto) throws Exception { + // 유효성 검사: MMSI와 위치 정보는 필수 + if (dto.getMmsi() == null || dto.getLat() == null || dto.getLon() == null) { + log.debug("유효하지 않은 데이터 스킵 - MMSI: {}, Lat: {}, Lon: {}", + dto.getMmsi(), dto.getLat(), dto.getLon()); + return null; + } + + // MessageTimestamp 파싱 (PK의 일부) + OffsetDateTime messageTimestamp = parseTimestamp(dto.getMessageTimestamp()); + if (messageTimestamp == null) { + log.debug("MessageTimestamp 파싱 실패 - MMSI: {}, Timestamp: {}", + dto.getMmsi(), dto.getMessageTimestamp()); + return null; + } + + return AisTargetEntity.builder() + // PK + .mmsi(dto.getMmsi()) + .messageTimestamp(messageTimestamp) + // 선박 식별 정보 + .imo(dto.getImo()) + .name(dto.getName()) + .callsign(dto.getCallsign()) + .vesselType(dto.getVesselType()) + .extraInfo(dto.getExtraInfo()) + // 위치 정보 + .lat(dto.getLat()) + .lon(dto.getLon()) + // 항해 정보 + .heading(dto.getHeading()) + .sog(dto.getSog()) + .cog(dto.getCog()) + .rot(dto.getRot()) + // 선박 제원 + .length(dto.getLength()) + .width(dto.getWidth()) + .draught(dto.getDraught()) + .lengthBow(dto.getLengthBow()) + .lengthStern(dto.getLengthStern()) + .widthPort(dto.getWidthPort()) + .widthStarboard(dto.getWidthStarboard()) + // 목적지 정보 + .destination(dto.getDestination()) + .eta(parseEta(dto.getEta())) + .status(dto.getStatus()) + // AIS 메시지 정보 + .ageMinutes(dto.getAgeMinutes()) + .positionAccuracy(dto.getPositionAccuracy()) + .timestampUtc(dto.getTimestampUtc()) + .repeatIndicator(dto.getRepeatIndicator()) + .raimFlag(dto.getRaimFlag()) + .radioStatus(dto.getRadioStatus()) + .regional(dto.getRegional()) + .regional2(dto.getRegional2()) + .spare(dto.getSpare()) + .spare2(dto.getSpare2()) + .aisVersion(dto.getAisVersion()) + .positionFixType(dto.getPositionFixType()) + .dte(dto.getDte()) + .bandFlag(dto.getBandFlag()) + // 타임스탬프 + .receivedDate(parseTimestamp(dto.getReceivedDate())) + .collectedAt(collectedAt != null ? collectedAt : OffsetDateTime.now()) + // TargetEnhanced 컬럼 추가 + .tonnesCargo(dto.getTonnesCargo()) + .inSTS(dto.getInSTS()) + .onBerth(dto.getOnBerth()) + .dwt(dto.getDwt()) + .anomalous(dto.getAnomalous()) + .destinationPortID(dto.getDestinationPortID()) + .destinationTidied(dto.getDestinationTidied()) + .destinationUNLOCODE(dto.getDestinationUNLOCODE()) + .imoVerified(dto.getImoVerified()) + .lastStaticUpdateReceived(parseTimestamp(dto.getLastStaticUpdateReceived())) + .lpcCode(dto.getLpcCode()) + .messageType(dto.getMessageType()) + .source(dto.getSource()) + .stationId(dto.getStationId()) + .zoneId(dto.getZoneId()) + .build(); + } + + private OffsetDateTime parseTimestamp(String timestamp) { + if (timestamp == null || timestamp.isEmpty()) { + return null; + } + + try { + // ISO 8601 형식 파싱 (예: "2025-12-01T23:55:01.073Z") + return OffsetDateTime.parse(timestamp, ISO_FORMATTER); + } catch (DateTimeParseException e) { + log.trace("타임스탬프 파싱 실패: {}", timestamp); + return null; + } + } + + private OffsetDateTime parseEta(String eta) { + if (eta == null || eta.isEmpty() || "9999-12-31T23:59:59Z".equals(eta)) { + return null; // 유효하지 않은 ETA는 null 처리 + } + return parseTimestamp(eta); + } +} diff --git a/src/main/java/com/snp/batch/jobs/aistarget/batch/reader/AisTargetDataReader.java b/src/main/java/com/snp/batch/jobs/aistarget/batch/reader/AisTargetDataReader.java new file mode 100644 index 0000000..8b61026 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/aistarget/batch/reader/AisTargetDataReader.java @@ -0,0 +1,98 @@ +package com.snp.batch.jobs.aistarget.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.aistarget.batch.dto.AisTargetApiResponse; +import com.snp.batch.jobs.aistarget.batch.dto.AisTargetDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * AIS Target 데이터 Reader + * + * API: POST /AisSvc.svc/AIS/GetTargets + * Request: {"sinceSeconds": "60"} + * + * 동작: + * - 매 분 15초에 실행 (Quartz 스케줄) + * - 최근 60초 동안의 전체 선박 위치 정보 조회 + * - 약 33,000건/분 처리 + */ +@Slf4j +public class AisTargetDataReader extends BaseApiReader { + + private final int sinceSeconds; + + public AisTargetDataReader(WebClient webClient, int sinceSeconds) { + super(webClient); + this.sinceSeconds = sinceSeconds; + } + + @Override + protected String getReaderName() { + return "AisTargetDataReader"; + } + + @Override + protected String getApiPath() { + return "/AisSvc.svc/AIS/GetTargetsEnhanced"; + } + + @Override + protected String getHttpMethod() { + return "POST"; + } + + @Override + protected Object getRequestBody() { + return Map.of("sinceSeconds", String.valueOf(sinceSeconds)); + } + + @Override + protected Class getResponseType() { + return AisTargetApiResponse.class; + } + + @Override + protected void beforeFetch() { + log.info("[{}] AIS GetTargets API 호출 준비 - sinceSeconds: {}", getReaderName(), sinceSeconds); + } + + @Override + protected List fetchDataFromApi() { + try { + log.info("[{}] API 호출 시작: {} {}", getReaderName(), getHttpMethod(), getApiPath()); + + AisTargetApiResponse response = webClient.post() + .uri(getApiPath()) + .bodyValue(getRequestBody()) + .retrieve() + .bodyToMono(AisTargetApiResponse.class) + .block(); + + if (response != null && response.getTargetArr() != null) { + List targets = response.getTargetArr(); + log.info("[{}] API 호출 완료: {} 건 조회", getReaderName(), targets.size()); + updateApiCallStats(1, 1); + return targets; + } else { + log.warn("[{}] API 응답이 비어있습니다", getReaderName()); + return Collections.emptyList(); + } + + } catch (Exception e) { + log.error("[{}] API 호출 실패: {}", getReaderName(), e.getMessage(), e); + return handleApiError(e); + } + } + + @Override + protected void afterFetch(List data) { + if (data != null && !data.isEmpty()) { + log.info("[{}] 데이터 조회 완료 - 총 {} 건", getReaderName(), data.size()); + } + } +} diff --git a/src/main/java/com/snp/batch/jobs/aistarget/batch/repository/AisTargetRepository.java b/src/main/java/com/snp/batch/jobs/aistarget/batch/repository/AisTargetRepository.java new file mode 100644 index 0000000..afc1505 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/aistarget/batch/repository/AisTargetRepository.java @@ -0,0 +1,59 @@ +package com.snp.batch.jobs.aistarget.batch.repository; + +import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; + +/** + * AIS Target Repository 인터페이스 + */ +public interface AisTargetRepository { + + /** + * 복합키로 조회 (MMSI + MessageTimestamp) + */ + Optional findByMmsiAndMessageTimestamp(Long mmsi, OffsetDateTime messageTimestamp); + + /** + * MMSI로 최신 위치 조회 + */ + Optional findLatestByMmsi(Long mmsi); + + /** + * 여러 MMSI의 최신 위치 조회 + */ + List findLatestByMmsiIn(List mmsiList); + + /** + * 시간 범위 내 특정 MMSI의 항적 조회 + */ + List findByMmsiAndTimeRange(Long mmsi, OffsetDateTime start, OffsetDateTime end); + + /** + * 시간 범위 + 공간 범위 내 선박 조회 + */ + List findByTimeRangeAndArea( + OffsetDateTime start, + OffsetDateTime end, + Double centerLon, + Double centerLat, + Double radiusMeters + ); + + /** + * 배치 INSERT (UPSERT) + */ + void batchUpsert(List entities); + + /** + * 전체 건수 조회 + */ + long count(); + + /** + * 오래된 데이터 삭제 (보존 기간 이전 데이터) + */ + int deleteOlderThan(OffsetDateTime threshold); +} diff --git a/src/main/java/com/snp/batch/jobs/aistarget/batch/repository/AisTargetRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/aistarget/batch/repository/AisTargetRepositoryImpl.java new file mode 100644 index 0000000..77803fb --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/aistarget/batch/repository/AisTargetRepositoryImpl.java @@ -0,0 +1,367 @@ +package com.snp.batch.jobs.aistarget.batch.repository; + +import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; + +/** + * AIS Target Repository 구현체 + * + * 테이블: snp_data.ais_target + * PK: mmsi + message_timestamp (복합키) + */ +@Slf4j +@Repository +@RequiredArgsConstructor +public class AisTargetRepositoryImpl implements AisTargetRepository { + + private final JdbcTemplate jdbcTemplate; + + private static final String TABLE_NAME = "snp_data.ais_target"; + + // ==================== UPSERT SQL ==================== + + private static final String UPSERT_SQL = """ + INSERT INTO snp_data.ais_target ( + mmsi, message_timestamp, imo, name, callsign, vessel_type, extra_info, + lat, lon, geom, + heading, sog, cog, rot, + length, width, draught, length_bow, length_stern, width_port, width_starboard, + destination, eta, status, + age_minutes, position_accuracy, timestamp_utc, repeat_indicator, raim_flag, + radio_status, regional, regional2, spare, spare2, + ais_version, position_fix_type, dte, band_flag, + received_date, collected_at, created_at, updated_at, + tonnes_cargo, in_sts, on_berth, dwt, anomalous, + destination_port_id, destination_tidied, destination_unlocode, imo_verified, last_static_update_received, + lpc_code, message_type, "source", station_id, zone_id + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, + ?, ?, ST_SetSRID(ST_MakePoint(?, ?), 4326), + ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, NOW(), NOW(), + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ? + ) + ON CONFLICT (mmsi, message_timestamp) DO UPDATE SET + imo = EXCLUDED.imo, + name = EXCLUDED.name, + callsign = EXCLUDED.callsign, + vessel_type = EXCLUDED.vessel_type, + extra_info = EXCLUDED.extra_info, + lat = EXCLUDED.lat, + lon = EXCLUDED.lon, + geom = EXCLUDED.geom, + heading = EXCLUDED.heading, + sog = EXCLUDED.sog, + cog = EXCLUDED.cog, + rot = EXCLUDED.rot, + length = EXCLUDED.length, + width = EXCLUDED.width, + draught = EXCLUDED.draught, + length_bow = EXCLUDED.length_bow, + length_stern = EXCLUDED.length_stern, + width_port = EXCLUDED.width_port, + width_starboard = EXCLUDED.width_starboard, + destination = EXCLUDED.destination, + eta = EXCLUDED.eta, + status = EXCLUDED.status, + age_minutes = EXCLUDED.age_minutes, + position_accuracy = EXCLUDED.position_accuracy, + timestamp_utc = EXCLUDED.timestamp_utc, + repeat_indicator = EXCLUDED.repeat_indicator, + raim_flag = EXCLUDED.raim_flag, + radio_status = EXCLUDED.radio_status, + regional = EXCLUDED.regional, + regional2 = EXCLUDED.regional2, + spare = EXCLUDED.spare, + spare2 = EXCLUDED.spare2, + ais_version = EXCLUDED.ais_version, + position_fix_type = EXCLUDED.position_fix_type, + dte = EXCLUDED.dte, + band_flag = EXCLUDED.band_flag, + received_date = EXCLUDED.received_date, + collected_at = EXCLUDED.collected_at, + updated_at = NOW(), + tonnes_cargo = EXCLUDED.tonnes_cargo, + in_sts = EXCLUDED.in_sts, + on_berth = EXCLUDED.on_berth, + dwt = EXCLUDED.dwt, + anomalous = EXCLUDED.anomalous, + destination_port_id = EXCLUDED.destination_port_id, + destination_tidied = EXCLUDED.destination_tidied, + destination_unlocode = EXCLUDED.destination_unlocode, + imo_verified = EXCLUDED.imo_verified, + last_static_update_received = EXCLUDED.last_static_update_received, + lpc_code = EXCLUDED.lpc_code, + message_type = EXCLUDED.message_type, + "source" = EXCLUDED."source", + station_id = EXCLUDED.station_id, + zone_id = EXCLUDED.zone_id + """; + + // ==================== RowMapper ==================== + + private final RowMapper rowMapper = (rs, rowNum) -> AisTargetEntity.builder() + .mmsi(rs.getLong("mmsi")) + .messageTimestamp(toOffsetDateTime(rs.getTimestamp("message_timestamp"))) + .imo(rs.getObject("imo", Long.class)) + .name(rs.getString("name")) + .callsign(rs.getString("callsign")) + .vesselType(rs.getString("vessel_type")) + .extraInfo(rs.getString("extra_info")) + .lat(rs.getObject("lat", Double.class)) + .lon(rs.getObject("lon", Double.class)) + .heading(rs.getObject("heading", Double.class)) + .sog(rs.getObject("sog", Double.class)) + .cog(rs.getObject("cog", Double.class)) + .rot(rs.getObject("rot", Integer.class)) + .length(rs.getObject("length", Integer.class)) + .width(rs.getObject("width", Integer.class)) + .draught(rs.getObject("draught", Double.class)) + .lengthBow(rs.getObject("length_bow", Integer.class)) + .lengthStern(rs.getObject("length_stern", Integer.class)) + .widthPort(rs.getObject("width_port", Integer.class)) + .widthStarboard(rs.getObject("width_starboard", Integer.class)) + .destination(rs.getString("destination")) + .eta(toOffsetDateTime(rs.getTimestamp("eta"))) + .status(rs.getString("status")) + .ageMinutes(rs.getObject("age_minutes", Double.class)) + .positionAccuracy(rs.getObject("position_accuracy", Integer.class)) + .timestampUtc(rs.getObject("timestamp_utc", Integer.class)) + .repeatIndicator(rs.getObject("repeat_indicator", Integer.class)) + .raimFlag(rs.getObject("raim_flag", Integer.class)) + .radioStatus(rs.getObject("radio_status", Integer.class)) + .regional(rs.getObject("regional", Integer.class)) + .regional2(rs.getObject("regional2", Integer.class)) + .spare(rs.getObject("spare", Integer.class)) + .spare2(rs.getObject("spare2", Integer.class)) + .aisVersion(rs.getObject("ais_version", Integer.class)) + .positionFixType(rs.getObject("position_fix_type", Integer.class)) + .dte(rs.getObject("dte", Integer.class)) + .bandFlag(rs.getObject("band_flag", Integer.class)) + .receivedDate(toOffsetDateTime(rs.getTimestamp("received_date"))) + .collectedAt(toOffsetDateTime(rs.getTimestamp("collected_at"))) + .tonnesCargo(rs.getObject("tonnes_cargo", Integer.class)) + .inSTS(rs.getObject("in_sts", Integer.class)) + .onBerth(rs.getObject("on_berth", Boolean.class)) + .dwt(rs.getObject("dwt", Integer.class)) + .anomalous(rs.getString("anomalous")) + .destinationPortID(rs.getObject("destination_port_id", Integer.class)) + .destinationTidied(rs.getString("destination_tidied")) + .destinationUNLOCODE(rs.getString("destination_unlocode")) + .imoVerified(rs.getString("imo_verified")) + .lastStaticUpdateReceived(toOffsetDateTime(rs.getTimestamp("last_static_update_received"))) + .lpcCode(rs.getObject("lpc_code", Integer.class)) + .messageType(rs.getObject("message_type", Integer.class)) + .source(rs.getString("source")) + .stationId(rs.getString("station_id")) + .zoneId(rs.getObject("zone_id", Double.class)) + .build(); + + // ==================== Repository Methods ==================== + + @Override + public Optional findByMmsiAndMessageTimestamp(Long mmsi, OffsetDateTime messageTimestamp) { + String sql = "SELECT * FROM " + TABLE_NAME + " WHERE mmsi = ? AND message_timestamp = ?"; + List results = jdbcTemplate.query(sql, rowMapper, mmsi, toTimestamp(messageTimestamp)); + return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); + } + + @Override + public Optional findLatestByMmsi(Long mmsi) { + String sql = """ + SELECT * FROM %s + WHERE mmsi = ? + ORDER BY message_timestamp DESC + LIMIT 1 + """.formatted(TABLE_NAME); + List results = jdbcTemplate.query(sql, rowMapper, mmsi); + return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); + } + + @Override + public List findLatestByMmsiIn(List mmsiList) { + if (mmsiList == null || mmsiList.isEmpty()) { + return List.of(); + } + + // DISTINCT ON을 사용하여 각 MMSI별 최신 레코드 조회 + String sql = """ + SELECT DISTINCT ON (mmsi) * + FROM %s + WHERE mmsi = ANY(?) + ORDER BY mmsi, message_timestamp DESC + """.formatted(TABLE_NAME); + + Long[] mmsiArray = mmsiList.toArray(new Long[0]); + return jdbcTemplate.query(sql, rowMapper, (Object) mmsiArray); + } + + @Override + public List findByMmsiAndTimeRange(Long mmsi, OffsetDateTime start, OffsetDateTime end) { + String sql = """ + SELECT * FROM %s + WHERE mmsi = ? + AND message_timestamp BETWEEN ? AND ? + ORDER BY message_timestamp ASC + """.formatted(TABLE_NAME); + return jdbcTemplate.query(sql, rowMapper, mmsi, toTimestamp(start), toTimestamp(end)); + } + + @Override + public List findByTimeRangeAndArea( + OffsetDateTime start, + OffsetDateTime end, + Double centerLon, + Double centerLat, + Double radiusMeters + ) { + String sql = """ + SELECT DISTINCT ON (mmsi) * + FROM %s + WHERE message_timestamp BETWEEN ? AND ? + AND ST_DWithin( + geom::geography, + ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography, + ? + ) + ORDER BY mmsi, message_timestamp DESC + """.formatted(TABLE_NAME); + + return jdbcTemplate.query(sql, rowMapper, + toTimestamp(start), toTimestamp(end), + centerLon, centerLat, radiusMeters); + } + + @Override + @Transactional + public void batchUpsert(List entities) { + if (entities == null || entities.isEmpty()) { + return; + } + + log.info("AIS Target 배치 UPSERT 시작: {} 건", entities.size()); + + jdbcTemplate.batchUpdate(UPSERT_SQL, entities, 1000, (ps, entity) -> { + int idx = 1; + // PK + ps.setLong(idx++, entity.getMmsi()); + ps.setTimestamp(idx++, toTimestamp(entity.getMessageTimestamp())); + // 선박 식별 정보 + ps.setObject(idx++, entity.getImo()); + ps.setString(idx++, truncate(entity.getName(), 100)); + ps.setString(idx++, truncate(entity.getCallsign(), 20)); + ps.setString(idx++, truncate(entity.getVesselType(), 50)); + ps.setString(idx++, truncate(entity.getExtraInfo(), 100)); + // 위치 정보 + ps.setObject(idx++, entity.getLat()); + ps.setObject(idx++, entity.getLon()); + // geom용 lon, lat + ps.setObject(idx++, entity.getLon()); + ps.setObject(idx++, entity.getLat()); + // 항해 정보 + ps.setObject(idx++, entity.getHeading()); + ps.setObject(idx++, entity.getSog()); + ps.setObject(idx++, entity.getCog()); + ps.setObject(idx++, entity.getRot()); + // 선박 제원 + ps.setObject(idx++, entity.getLength()); + ps.setObject(idx++, entity.getWidth()); + ps.setObject(idx++, entity.getDraught()); + ps.setObject(idx++, entity.getLengthBow()); + ps.setObject(idx++, entity.getLengthStern()); + ps.setObject(idx++, entity.getWidthPort()); + ps.setObject(idx++, entity.getWidthStarboard()); + // 목적지 정보 + ps.setString(idx++, truncate(entity.getDestination(), 200)); + ps.setTimestamp(idx++, toTimestamp(entity.getEta())); + ps.setString(idx++, truncate(entity.getStatus(), 50)); + // AIS 메시지 정보 + ps.setObject(idx++, entity.getAgeMinutes()); + ps.setObject(idx++, entity.getPositionAccuracy()); + ps.setObject(idx++, entity.getTimestampUtc()); + ps.setObject(idx++, entity.getRepeatIndicator()); + ps.setObject(idx++, entity.getRaimFlag()); + ps.setObject(idx++, entity.getRadioStatus()); + ps.setObject(idx++, entity.getRegional()); + ps.setObject(idx++, entity.getRegional2()); + ps.setObject(idx++, entity.getSpare()); + ps.setObject(idx++, entity.getSpare2()); + ps.setObject(idx++, entity.getAisVersion()); + ps.setObject(idx++, entity.getPositionFixType()); + ps.setObject(idx++, entity.getDte()); + ps.setObject(idx++, entity.getBandFlag()); + // 타임스탬프 + ps.setTimestamp(idx++, toTimestamp(entity.getReceivedDate())); + ps.setTimestamp(idx++, toTimestamp(entity.getCollectedAt())); + // TargetEnhanced 컬럼 추가 + ps.setObject(idx++, entity.getTonnesCargo()); + ps.setObject(idx++, entity.getInSTS()); + ps.setObject(idx++, entity.getOnBerth()); + ps.setObject(idx++, entity.getDwt()); + ps.setObject(idx++, entity.getAnomalous()); + ps.setObject(idx++, entity.getDestinationPortID()); + ps.setObject(idx++, entity.getDestinationTidied()); + ps.setObject(idx++, entity.getDestinationUNLOCODE()); + ps.setObject(idx++, entity.getImoVerified()); + ps.setTimestamp(idx++, toTimestamp(entity.getLastStaticUpdateReceived())); + ps.setObject(idx++, entity.getLpcCode()); + ps.setObject(idx++, entity.getMessageType()); + ps.setObject(idx++, entity.getSource()); + ps.setObject(idx++, entity.getStationId()); + ps.setObject(idx++, entity.getZoneId()); + }); + + log.info("AIS Target 배치 UPSERT 완료: {} 건", entities.size()); + } + + @Override + public long count() { + String sql = "SELECT COUNT(*) FROM " + TABLE_NAME; + Long count = jdbcTemplate.queryForObject(sql, Long.class); + return count != null ? count : 0L; + } + + @Override + @Transactional + public int deleteOlderThan(OffsetDateTime threshold) { + String sql = "DELETE FROM " + TABLE_NAME + " WHERE message_timestamp < ?"; + int deleted = jdbcTemplate.update(sql, toTimestamp(threshold)); + log.info("AIS Target 오래된 데이터 삭제 완료: {} 건 (기준: {})", deleted, threshold); + return deleted; + } + + // ==================== Helper Methods ==================== + + private Timestamp toTimestamp(OffsetDateTime odt) { + return odt != null ? Timestamp.from(odt.toInstant()) : null; + } + + private OffsetDateTime toOffsetDateTime(Timestamp ts) { + return ts != null ? ts.toInstant().atOffset(ZoneOffset.UTC) : null; + } + + private String truncate(String value, int maxLength) { + if (value == null) return null; + return value.length() > maxLength ? value.substring(0, maxLength) : value; + } +} diff --git a/src/main/java/com/snp/batch/jobs/aistarget/batch/writer/AisTargetDataWriter.java b/src/main/java/com/snp/batch/jobs/aistarget/batch/writer/AisTargetDataWriter.java new file mode 100644 index 0000000..60dd073 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/aistarget/batch/writer/AisTargetDataWriter.java @@ -0,0 +1,52 @@ +package com.snp.batch.jobs.aistarget.batch.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity; +import com.snp.batch.jobs.aistarget.cache.AisTargetCacheManager; +import com.snp.batch.jobs.aistarget.classifier.AisClassTypeClassifier; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * AIS Target 데이터 Writer (캐시 전용) + * + * 동작: + * 1. ClassType 분류 (Core20 캐시 기반 A/B 분류) + * 2. 캐시에 최신 위치 정보 업데이트 (classType, core20Mmsi 포함) + * + * 참고: + * - DB 저장은 별도 Job(aisTargetDbSyncJob)에서 15분 주기로 수행 + * - 이 Writer는 캐시 업데이트만 담당 + */ +@Slf4j +@Component +public class AisTargetDataWriter extends BaseWriter { + + private final AisTargetCacheManager cacheManager; + private final AisClassTypeClassifier classTypeClassifier; + + public AisTargetDataWriter( + AisTargetCacheManager cacheManager, + AisClassTypeClassifier classTypeClassifier) { + super("AisTarget"); + this.cacheManager = cacheManager; + this.classTypeClassifier = classTypeClassifier; + } + + @Override + protected void writeItems(List items) throws Exception { + log.debug("AIS Target 캐시 업데이트 시작: {} 건", items.size()); + + // 1. ClassType 분류 (캐시 저장 전에 분류) + // - Core20 캐시의 IMO와 매칭하여 classType(A/B), core20Mmsi 설정 + classTypeClassifier.classifyAll(items); + + // 2. 캐시 업데이트 (classType, core20Mmsi 포함) + cacheManager.putAll(items); + + log.debug("AIS Target 캐시 업데이트 완료: {} 건 (캐시 크기: {})", + items.size(), cacheManager.size()); + } +} diff --git a/src/main/java/com/snp/batch/jobs/aistarget/cache/AisTargetCacheManager.java b/src/main/java/com/snp/batch/jobs/aistarget/cache/AisTargetCacheManager.java new file mode 100644 index 0000000..fb6e22b --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/aistarget/cache/AisTargetCacheManager.java @@ -0,0 +1,272 @@ +package com.snp.batch.jobs.aistarget.cache; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.RemovalCause; +import com.github.benmanes.caffeine.cache.stats.CacheStats; +import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import jakarta.annotation.PostConstruct; +import java.time.OffsetDateTime; +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** + * AIS Target 캐시 매니저 (Caffeine 기반) + * + * Caffeine 캐시의 장점: + * - 고성능: ConcurrentHashMap 대비 우수한 성능 + * - 자동 만료: expireAfterWrite/expireAfterAccess 내장 + * - 최대 크기 제한: maximumSize + LRU/LFU 자동 정리 + * - 통계: 히트율, 미스율, 로드 시간 등 상세 통계 + * - 비동기 지원: AsyncCache로 비동기 로딩 가능 + * + * 동작: + * - 배치 Writer에서 DB 저장과 동시에 캐시 업데이트 + * - API 조회 시 캐시 우선 조회 + * - 캐시 미스 시 DB 조회 후 캐시 갱신 + * - TTL: 마지막 쓰기 이후 N분 뒤 자동 만료 + */ +@Slf4j +@Component +public class AisTargetCacheManager { + + private Cache cache; + + @Value("${app.batch.ais-target-cache.ttl-minutes:5}") + private long ttlMinutes; + + @Value("${app.batch.ais-target-cache.max-size:100000}") + private int maxSize; + + @PostConstruct + public void init() { + this.cache = Caffeine.newBuilder() + // 최대 캐시 크기 (초과 시 LRU 방식으로 정리) + .maximumSize(maxSize) + // 마지막 쓰기 이후 TTL (데이터 업데이트 시 자동 갱신) + .expireAfterWrite(ttlMinutes, TimeUnit.MINUTES) + // 통계 수집 활성화 + .recordStats() + // 제거 리스너 (디버깅/모니터링용) + .removalListener((Long key, AisTargetEntity value, RemovalCause cause) -> { + if (cause != RemovalCause.REPLACED) { + log.trace("캐시 제거 - MMSI: {}, 원인: {}", key, cause); + } + }) + .build(); + + log.info("AIS Target Caffeine 캐시 초기화 - TTL: {}분, 최대 크기: {}", ttlMinutes, maxSize); + } + + // ==================== 단건 조회/업데이트 ==================== + + /** + * 캐시에서 최신 위치 조회 + * + * @param mmsi MMSI 번호 + * @return 캐시된 데이터 (없으면 Optional.empty) + */ + public Optional get(Long mmsi) { + AisTargetEntity entity = cache.getIfPresent(mmsi); + return Optional.ofNullable(entity); + } + + /** + * 캐시에 데이터 저장/업데이트 + * - 기존 데이터보다 최신인 경우에만 업데이트 + * - 업데이트 시 TTL 자동 갱신 (expireAfterWrite) + * + * @param entity AIS Target 엔티티 + */ + public void put(AisTargetEntity entity) { + if (entity == null || entity.getMmsi() == null) { + return; + } + + Long mmsi = entity.getMmsi(); + AisTargetEntity existing = cache.getIfPresent(mmsi); + + // 기존 데이터보다 최신인 경우에만 업데이트 + if (existing == null || isNewer(entity, existing)) { + cache.put(mmsi, entity); + log.trace("캐시 저장 - MMSI: {}", mmsi); + } + } + + // ==================== 배치 조회/업데이트 ==================== + + /** + * 여러 MMSI의 최신 위치 조회 + * + * @param mmsiList MMSI 목록 + * @return 캐시에서 찾은 데이터 맵 (MMSI -> Entity) + */ + public Map getAll(List mmsiList) { + if (mmsiList == null || mmsiList.isEmpty()) { + return Collections.emptyMap(); + } + + // Caffeine의 getAllPresent는 존재하는 키만 반환 + Map result = cache.getAllPresent(mmsiList); + + log.debug("캐시 배치 조회 - 요청: {}, 히트: {}", + mmsiList.size(), result.size()); + + return result; + } + + /** + * 여러 데이터 일괄 저장/업데이트 (배치 Writer에서 호출) + * + * @param entities AIS Target 엔티티 목록 + */ + public void putAll(List entities) { + if (entities == null || entities.isEmpty()) { + return; + } + + int updated = 0; + int skipped = 0; + + for (AisTargetEntity entity : entities) { + if (entity == null || entity.getMmsi() == null) { + continue; + } + + Long mmsi = entity.getMmsi(); + AisTargetEntity existing = cache.getIfPresent(mmsi); + + // 기존 데이터보다 최신인 경우에만 업데이트 + if (existing == null || isNewer(entity, existing)) { + cache.put(mmsi, entity); + updated++; + } else { + skipped++; + } + } + + log.debug("캐시 배치 업데이트 - 입력: {}, 업데이트: {}, 스킵: {}, 현재 크기: {}", + entities.size(), updated, skipped, cache.estimatedSize()); + } + + // ==================== 캐시 관리 ==================== + + /** + * 특정 MMSI 캐시 삭제 + */ + public void evict(Long mmsi) { + cache.invalidate(mmsi); + } + + /** + * 여러 MMSI 캐시 삭제 + */ + public void evictAll(List mmsiList) { + cache.invalidateAll(mmsiList); + } + + /** + * 전체 캐시 삭제 + */ + public void clear() { + long size = cache.estimatedSize(); + cache.invalidateAll(); + log.info("캐시 전체 삭제 - {} 건", size); + } + + /** + * 현재 캐시 크기 (추정값) + */ + public long size() { + return cache.estimatedSize(); + } + + /** + * 캐시 정리 (만료된 엔트리 즉시 제거) + */ + public void cleanup() { + cache.cleanUp(); + } + + // ==================== 통계 ==================== + + /** + * 캐시 통계 조회 + */ + public Map getStats() { + CacheStats stats = cache.stats(); + + Map result = new LinkedHashMap<>(); + result.put("estimatedSize", cache.estimatedSize()); + result.put("maxSize", maxSize); + result.put("ttlMinutes", ttlMinutes); + result.put("hitCount", stats.hitCount()); + result.put("missCount", stats.missCount()); + result.put("hitRate", String.format("%.2f%%", stats.hitRate() * 100)); + result.put("missRate", String.format("%.2f%%", stats.missRate() * 100)); + result.put("evictionCount", stats.evictionCount()); + result.put("loadCount", stats.loadCount()); + result.put("averageLoadPenalty", String.format("%.2fms", stats.averageLoadPenalty() / 1_000_000.0)); + result.put("utilizationPercent", String.format("%.2f%%", (cache.estimatedSize() * 100.0 / maxSize))); + + return result; + } + + /** + * 상세 통계 조회 (Caffeine CacheStats 원본) + */ + public CacheStats getCacheStats() { + return cache.stats(); + } + + // ==================== 전체 데이터 조회 (공간 필터링용) ==================== + + /** + * 캐시의 모든 데이터 조회 (공간 필터링용) + * 주의: 대용량 데이터이므로 신중하게 사용 + * + * @return 캐시된 모든 엔티티 + */ + public Collection getAllValues() { + return cache.asMap().values(); + } + + /** + * 시간 범위 내 데이터 필터링 + * + * @param minutes 최근 N분 + * @return 시간 범위 내 엔티티 목록 + */ + public List getByTimeRange(int minutes) { + java.time.OffsetDateTime threshold = java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC) + .minusMinutes(minutes); + + return cache.asMap().values().stream() + .filter(entity -> entity.getMessageTimestamp() != null) + .filter(entity -> entity.getMessageTimestamp().isAfter(threshold)) + .collect(java.util.stream.Collectors.toList()); + } + + // ==================== Private Methods ==================== + + /** + * 새 데이터가 기존 데이터보다 최신인지 확인 + */ + private boolean isNewer(AisTargetEntity newEntity, AisTargetEntity existing) { + OffsetDateTime newTimestamp = newEntity.getMessageTimestamp(); + OffsetDateTime existingTimestamp = existing.getMessageTimestamp(); + + if (newTimestamp == null) { + return false; + } + if (existingTimestamp == null) { + return true; + } + + return newTimestamp.isAfter(existingTimestamp); + } +} diff --git a/src/main/java/com/snp/batch/jobs/aistarget/cache/AisTargetFilterUtil.java b/src/main/java/com/snp/batch/jobs/aistarget/cache/AisTargetFilterUtil.java new file mode 100644 index 0000000..c884954 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/aistarget/cache/AisTargetFilterUtil.java @@ -0,0 +1,229 @@ +package com.snp.batch.jobs.aistarget.cache; + +import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity; +import com.snp.batch.jobs.aistarget.web.dto.AisTargetFilterRequest; +import com.snp.batch.jobs.aistarget.web.dto.AisTargetSearchRequest; +import com.snp.batch.jobs.aistarget.web.dto.NumericCondition; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * AIS Target 필터링 유틸리티 + * + * 캐시 데이터에 대한 조건 필터링 수행 + * - SOG, COG, Heading: 숫자 범위 조건 + * - Destination: 문자열 부분 일치 + * - Status: 다중 선택 일치 + * - ClassType: 선박 클래스 타입 (A/B) + */ +@Slf4j +@Component +public class AisTargetFilterUtil { + + /** + * 필터 조건에 따라 엔티티 목록 필터링 + * + * @param entities 원본 엔티티 목록 + * @param request 필터 조건 + * @return 필터링된 엔티티 목록 + */ + public List filter(List entities, AisTargetFilterRequest request) { + if (entities == null || entities.isEmpty()) { + return List.of(); + } + + if (!request.hasAnyFilter()) { + return entities; + } + + long startTime = System.currentTimeMillis(); + + List result = entities.parallelStream() + .filter(entity -> matchesSog(entity, request)) + .filter(entity -> matchesCog(entity, request)) + .filter(entity -> matchesHeading(entity, request)) + .filter(entity -> matchesDestination(entity, request)) + .filter(entity -> matchesStatus(entity, request)) + .filter(entity -> matchesClassType(entity, request)) + .collect(Collectors.toList()); + + long elapsed = System.currentTimeMillis() - startTime; + log.debug("필터링 완료 - 입력: {}, 결과: {}, 소요: {}ms", + entities.size(), result.size(), elapsed); + + return result; + } + + /** + * AisTargetSearchRequest 기반 ClassType 필터링 + * + * @param entities 원본 엔티티 목록 + * @param request 검색 조건 + * @return 필터링된 엔티티 목록 + */ + public List filterByClassType(List entities, AisTargetSearchRequest request) { + if (entities == null || entities.isEmpty()) { + return Collections.emptyList(); + } + + if (!request.hasClassTypeFilter()) { + return entities; + } + + long startTime = System.currentTimeMillis(); + + List result = entities.parallelStream() + .filter(entity -> matchesClassType(entity, request.getClassType())) + .collect(Collectors.toList()); + + long elapsed = System.currentTimeMillis() - startTime; + log.debug("ClassType 필터링 완료 - 입력: {}, 결과: {}, 필터: {}, 소요: {}ms", + entities.size(), result.size(), request.getClassType(), elapsed); + + return result; + } + + /** + * 문자열 classType으로 직접 필터링 + */ + private boolean matchesClassType(AisTargetEntity entity, String classTypeFilter) { + if (classTypeFilter == null) { + return true; + } + + String entityClassType = entity.getClassType(); + + // classType이 미분류(null)인 데이터 처리 + if (entityClassType == null) { + // B 필터인 경우 미분류 데이터도 포함 (보수적 접근) + return "B".equalsIgnoreCase(classTypeFilter); + } + + return classTypeFilter.equalsIgnoreCase(entityClassType); + } + + /** + * SOG (속도) 조건 매칭 + */ + private boolean matchesSog(AisTargetEntity entity, AisTargetFilterRequest request) { + if (!request.hasSogFilter()) { + return true; // 필터 없으면 통과 + } + + NumericCondition condition = NumericCondition.fromString(request.getSogCondition()); + if (condition == null) { + return true; + } + + return condition.matches( + entity.getSog(), + request.getSogValue(), + request.getSogMin(), + request.getSogMax() + ); + } + + /** + * COG (침로) 조건 매칭 + */ + private boolean matchesCog(AisTargetEntity entity, AisTargetFilterRequest request) { + if (!request.hasCogFilter()) { + return true; + } + + NumericCondition condition = NumericCondition.fromString(request.getCogCondition()); + if (condition == null) { + return true; + } + + return condition.matches( + entity.getCog(), + request.getCogValue(), + request.getCogMin(), + request.getCogMax() + ); + } + + /** + * Heading (선수방위) 조건 매칭 + */ + private boolean matchesHeading(AisTargetEntity entity, AisTargetFilterRequest request) { + if (!request.hasHeadingFilter()) { + return true; + } + + NumericCondition condition = NumericCondition.fromString(request.getHeadingCondition()); + if (condition == null) { + return true; + } + + return condition.matches( + entity.getHeading(), + request.getHeadingValue(), + request.getHeadingMin(), + request.getHeadingMax() + ); + } + + /** + * Destination (목적지) 조건 매칭 - 부분 일치, 대소문자 무시 + */ + private boolean matchesDestination(AisTargetEntity entity, AisTargetFilterRequest request) { + if (!request.hasDestinationFilter()) { + return true; + } + + String entityDestination = entity.getDestination(); + if (entityDestination == null || entityDestination.isEmpty()) { + return false; + } + + return entityDestination.toUpperCase().contains(request.getDestination().toUpperCase().trim()); + } + + /** + * Status (항행상태) 조건 매칭 - 다중 선택 중 하나라도 일치 + */ + private boolean matchesStatus(AisTargetEntity entity, AisTargetFilterRequest request) { + if (!request.hasStatusFilter()) { + return true; + } + + String entityStatus = entity.getStatus(); + if (entityStatus == null || entityStatus.isEmpty()) { + return false; + } + + // statusList에 포함되어 있으면 통과 + return request.getStatusList().stream() + .anyMatch(status -> entityStatus.equalsIgnoreCase(status.trim())); + } + + /** + * ClassType (선박 클래스 타입) 조건 매칭 + * + * - A: Core20에 등록된 선박 + * - B: Core20 미등록 선박 + * - 필터 미지정: 전체 통과 + * - classType이 null인 데이터: B 필터에만 포함 (보수적 접근) + */ + private boolean matchesClassType(AisTargetEntity entity, AisTargetFilterRequest request) { + if (!request.hasClassTypeFilter()) { + return true; + } + + String entityClassType = entity.getClassType(); + + // classType이 미분류(null)인 데이터 처리 + if (entityClassType == null) { + // B 필터인 경우 미분류 데이터도 포함 (보수적 접근) + return "B".equalsIgnoreCase(request.getClassType()); + } + + return request.getClassType().equalsIgnoreCase(entityClassType); + } +} diff --git a/src/main/java/com/snp/batch/jobs/aistarget/cache/SpatialFilterUtil.java b/src/main/java/com/snp/batch/jobs/aistarget/cache/SpatialFilterUtil.java new file mode 100644 index 0000000..b2c2fce --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/aistarget/cache/SpatialFilterUtil.java @@ -0,0 +1,317 @@ +package com.snp.batch.jobs.aistarget.cache; + +import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity; +import lombok.extern.slf4j.Slf4j; +import org.locationtech.jts.geom.*; +import org.locationtech.jts.geom.impl.CoordinateArraySequence; +import org.locationtech.jts.operation.distance.DistanceOp; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 공간 필터링 유틸리티 (JTS 기반) + * + * 지원 기능: + * - 원형 범위 내 선박 필터링 (Point + Radius) + * - 폴리곤 범위 내 선박 필터링 (Polygon) + * - 거리 계산 (Haversine 공식 - 지구 곡률 고려) + * + * 성능: + * - 25만 건 필터링: 약 50-100ms (병렬 처리 시) + * - 단순 거리 계산은 JTS 없이 Haversine으로 처리 (더 빠름) + * - 복잡한 폴리곤은 JTS 사용 + */ +@Slf4j +@Component +public class SpatialFilterUtil { + + private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory(new PrecisionModel(), 4326); + + // 지구 반경 (미터) + private static final double EARTH_RADIUS_METERS = 6_371_000; + + // ==================== 원형 범위 필터링 ==================== + + /** + * 원형 범위 내 선박 필터링 (Haversine 거리 계산 - 빠름) + * + * @param entities 전체 엔티티 목록 + * @param centerLon 중심 경도 + * @param centerLat 중심 위도 + * @param radiusMeters 반경 (미터) + * @return 범위 내 엔티티 목록 + */ + public List filterByCircle( + Collection entities, + double centerLon, + double centerLat, + double radiusMeters) { + + if (entities == null || entities.isEmpty()) { + return new ArrayList<>(); + } + + long startTime = System.currentTimeMillis(); + + // 병렬 스트림으로 필터링 (대용량 데이터 최적화) + List result = entities.parallelStream() + .filter(entity -> entity.getLat() != null && entity.getLon() != null) + .filter(entity -> { + double distance = haversineDistance( + centerLat, centerLon, + entity.getLat(), entity.getLon() + ); + return distance <= radiusMeters; + }) + .collect(Collectors.toList()); + + long elapsed = System.currentTimeMillis() - startTime; + log.debug("원형 필터링 완료 - 입력: {}, 결과: {}, 소요: {}ms", + entities.size(), result.size(), elapsed); + + return result; + } + + /** + * 원형 범위 내 선박 필터링 + 거리 정보 포함 + */ + public List filterByCircleWithDistance( + Collection entities, + double centerLon, + double centerLat, + double radiusMeters) { + + if (entities == null || entities.isEmpty()) { + return new ArrayList<>(); + } + + return entities.parallelStream() + .filter(entity -> entity.getLat() != null && entity.getLon() != null) + .map(entity -> { + double distance = haversineDistance( + centerLat, centerLon, + entity.getLat(), entity.getLon() + ); + return new EntityWithDistance(entity, distance); + }) + .filter(ewd -> ewd.getDistanceMeters() <= radiusMeters) + .sorted((a, b) -> Double.compare(a.getDistanceMeters(), b.getDistanceMeters())) + .collect(Collectors.toList()); + } + + // ==================== 폴리곤 범위 필터링 ==================== + + /** + * 폴리곤 범위 내 선박 필터링 (JTS 사용) + * + * @param entities 전체 엔티티 목록 + * @param polygonCoordinates 폴리곤 좌표 [[lon, lat], [lon, lat], ...] (닫힌 형태) + * @return 범위 내 엔티티 목록 + */ + public List filterByPolygon( + Collection entities, + double[][] polygonCoordinates) { + + if (entities == null || entities.isEmpty()) { + return new ArrayList<>(); + } + + if (polygonCoordinates == null || polygonCoordinates.length < 4) { + log.warn("유효하지 않은 폴리곤 좌표 (최소 4개 점 필요)"); + return new ArrayList<>(); + } + + long startTime = System.currentTimeMillis(); + + // JTS Polygon 생성 + Polygon polygon = createPolygon(polygonCoordinates); + + if (polygon == null || !polygon.isValid()) { + log.warn("유효하지 않은 폴리곤"); + return new ArrayList<>(); + } + + // 병렬 스트림으로 필터링 + List result = entities.parallelStream() + .filter(entity -> entity.getLat() != null && entity.getLon() != null) + .filter(entity -> { + Point point = createPoint(entity.getLon(), entity.getLat()); + return polygon.contains(point); + }) + .collect(Collectors.toList()); + + long elapsed = System.currentTimeMillis() - startTime; + log.debug("폴리곤 필터링 완료 - 입력: {}, 결과: {}, 소요: {}ms", + entities.size(), result.size(), elapsed); + + return result; + } + + /** + * WKT(Well-Known Text) 형식 폴리곤으로 필터링 + * + * @param entities 전체 엔티티 목록 + * @param wkt WKT 문자열 (예: "POLYGON((129 35, 130 35, 130 36, 129 36, 129 35))") + * @return 범위 내 엔티티 목록 + */ + public List filterByWkt( + Collection entities, + String wkt) { + + if (entities == null || entities.isEmpty()) { + return new ArrayList<>(); + } + + try { + Geometry geometry = new org.locationtech.jts.io.WKTReader(GEOMETRY_FACTORY).read(wkt); + + return entities.parallelStream() + .filter(entity -> entity.getLat() != null && entity.getLon() != null) + .filter(entity -> { + Point point = createPoint(entity.getLon(), entity.getLat()); + return geometry.contains(point); + }) + .collect(Collectors.toList()); + + } catch (Exception e) { + log.error("WKT 파싱 실패: {}", wkt, e); + return new ArrayList<>(); + } + } + + // ==================== GeoJSON 지원 ==================== + + /** + * GeoJSON 형식 폴리곤으로 필터링 + * + * @param entities 전체 엔티티 목록 + * @param geoJsonCoordinates GeoJSON coordinates 배열 [[[lon, lat], ...]] + * @return 범위 내 엔티티 목록 + */ + public List filterByGeoJson( + Collection entities, + double[][][] geoJsonCoordinates) { + + if (geoJsonCoordinates == null || geoJsonCoordinates.length == 0) { + return new ArrayList<>(); + } + + // GeoJSON의 첫 번째 링 (외부 경계) + return filterByPolygon(entities, geoJsonCoordinates[0]); + } + + // ==================== 거리 계산 ==================== + + /** + * Haversine 공식을 사용한 두 지점 간 거리 계산 (미터) + * 지구 곡률을 고려한 정확한 거리 계산 + */ + public double haversineDistance(double lat1, double lon1, double lat2, double lon2) { + double dLat = Math.toRadians(lat2 - lat1); + double dLon = Math.toRadians(lon2 - lon1); + + double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) + * Math.sin(dLon / 2) * Math.sin(dLon / 2); + + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return EARTH_RADIUS_METERS * c; + } + + /** + * 두 엔티티 간 거리 계산 (미터) + */ + public double calculateDistance(AisTargetEntity entity1, AisTargetEntity entity2) { + if (entity1.getLat() == null || entity1.getLon() == null || + entity2.getLat() == null || entity2.getLon() == null) { + return Double.MAX_VALUE; + } + + return haversineDistance( + entity1.getLat(), entity1.getLon(), + entity2.getLat(), entity2.getLon() + ); + } + + // ==================== JTS 헬퍼 메서드 ==================== + + /** + * JTS Point 생성 + */ + public Point createPoint(double lon, double lat) { + return GEOMETRY_FACTORY.createPoint(new Coordinate(lon, lat)); + } + + /** + * JTS Polygon 생성 + */ + public Polygon createPolygon(double[][] coordinates) { + try { + Coordinate[] coords = new Coordinate[coordinates.length]; + for (int i = 0; i < coordinates.length; i++) { + coords[i] = new Coordinate(coordinates[i][0], coordinates[i][1]); + } + + // 폴리곤이 닫혀있지 않으면 닫기 + if (!coords[0].equals(coords[coords.length - 1])) { + Coordinate[] closedCoords = new Coordinate[coords.length + 1]; + System.arraycopy(coords, 0, closedCoords, 0, coords.length); + closedCoords[coords.length] = coords[0]; + coords = closedCoords; + } + + LinearRing ring = GEOMETRY_FACTORY.createLinearRing(coords); + return GEOMETRY_FACTORY.createPolygon(ring); + + } catch (Exception e) { + log.error("폴리곤 생성 실패", e); + return null; + } + } + + /** + * 원형 폴리곤 생성 (근사치) + * + * @param centerLon 중심 경도 + * @param centerLat 중심 위도 + * @param radiusMeters 반경 (미터) + * @param numPoints 폴리곤 점 개수 (기본: 64) + */ + public Polygon createCirclePolygon(double centerLon, double centerLat, double radiusMeters, int numPoints) { + Coordinate[] coords = new Coordinate[numPoints + 1]; + + for (int i = 0; i < numPoints; i++) { + double angle = (2 * Math.PI * i) / numPoints; + + // 위도/경도 변환 (근사치) + double dLat = (radiusMeters / EARTH_RADIUS_METERS) * (180 / Math.PI); + double dLon = dLat / Math.cos(Math.toRadians(centerLat)); + + double lat = centerLat + dLat * Math.sin(angle); + double lon = centerLon + dLon * Math.cos(angle); + + coords[i] = new Coordinate(lon, lat); + } + coords[numPoints] = coords[0]; // 닫기 + + LinearRing ring = GEOMETRY_FACTORY.createLinearRing(coords); + return GEOMETRY_FACTORY.createPolygon(ring); + } + + // ==================== 내부 클래스 ==================== + + /** + * 엔티티 + 거리 정보 + */ + @lombok.Data + @lombok.AllArgsConstructor + public static class EntityWithDistance { + private AisTargetEntity entity; + private double distanceMeters; + } +} diff --git a/src/main/java/com/snp/batch/jobs/aistarget/classifier/AisClassTypeClassifier.java b/src/main/java/com/snp/batch/jobs/aistarget/classifier/AisClassTypeClassifier.java new file mode 100644 index 0000000..e8672ce --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/aistarget/classifier/AisClassTypeClassifier.java @@ -0,0 +1,160 @@ +package com.snp.batch.jobs.aistarget.classifier; + +import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; +import java.util.regex.Pattern; + +/** + * AIS Target ClassType 분류기 + * + * 분류 기준: + * - Core20 테이블에 IMO가 등록되어 있으면 Class A + * - 등록되어 있지 않으면 Class B (기본값) + * + * 분류 결과: + * - classType: "A" 또는 "B" + * - core20Mmsi: Core20에 등록된 MMSI (Class A일 때만, nullable) + * + * 특이 케이스: + * 1. IMO가 0이거나 null → Class B + * 2. IMO가 7자리가 아닌 의미없는 숫자 → Class B + * 3. IMO가 7자리이지만 Core20에 미등록 → Class B + * 4. IMO가 Core20에 있지만 MMSI가 null → Class A, core20Mmsi = null + * + * 향후 제거 가능하도록 독립적인 모듈로 구현 + */ +@Slf4j +@Component +public class AisClassTypeClassifier { + + /** + * 유효한 IMO 패턴 (7자리 숫자) + */ + private static final Pattern IMO_PATTERN = Pattern.compile("^\\d{7}$"); + + private final Core20CacheManager core20CacheManager; + + /** + * ClassType 분류 기능 활성화 여부 + */ + @Value("${app.batch.class-type.enabled:true}") + private boolean enabled; + + public AisClassTypeClassifier(Core20CacheManager core20CacheManager) { + this.core20CacheManager = core20CacheManager; + } + + /** + * 단일 Entity의 ClassType 분류 + * + * @param entity AIS Target Entity + */ + public void classify(AisTargetEntity entity) { + if (!enabled || entity == null) { + return; + } + + Long imo = entity.getImo(); + + // 1. IMO가 null이거나 0이면 Class B + if (imo == null || imo == 0) { + setClassB(entity); + return; + } + + // 2. IMO가 7자리 숫자인지 확인 + String imoStr = String.valueOf(imo); + if (!isValidImo(imoStr)) { + setClassB(entity); + return; + } + + // 3. Core20 캐시에서 IMO 존재 여부 확인 + if (core20CacheManager.containsImo(imoStr)) { + // Class A - Core20에 등록된 선박 + entity.setClassType("A"); + + // Core20의 MMSI 조회 (nullable - Core20에 MMSI가 없을 수도 있음) + Optional core20Mmsi = core20CacheManager.getMmsiByImo(imoStr); + entity.setCore20Mmsi(core20Mmsi.orElse(null)); + + return; + } + + // 4. Core20에 없음 - Class B + setClassB(entity); + } + + /** + * 여러 Entity 일괄 분류 + * + * @param entities AIS Target Entity 목록 + */ + public void classifyAll(List entities) { + if (!enabled || entities == null || entities.isEmpty()) { + return; + } + + int classACount = 0; + int classBCount = 0; + int classAWithMmsi = 0; + int classAWithoutMmsi = 0; + + for (AisTargetEntity entity : entities) { + classify(entity); + + if ("A".equals(entity.getClassType())) { + classACount++; + if (entity.getCore20Mmsi() != null) { + classAWithMmsi++; + } else { + classAWithoutMmsi++; + } + } else { + classBCount++; + } + } + + if (log.isDebugEnabled()) { + log.debug("ClassType 분류 완료 - 총: {}, Class A: {} (MMSI있음: {}, MMSI없음: {}), Class B: {}", + entities.size(), classACount, classAWithMmsi, classAWithoutMmsi, classBCount); + } + } + + /** + * Class B로 설정 (기본값) + */ + private void setClassB(AisTargetEntity entity) { + entity.setClassType("B"); + entity.setCore20Mmsi(null); + } + + /** + * 유효한 IMO 번호인지 확인 (7자리 숫자) + * + * @param imo IMO 문자열 + * @return 유효 여부 + */ + private boolean isValidImo(String imo) { + return imo != null && IMO_PATTERN.matcher(imo).matches(); + } + + /** + * 기능 활성화 여부 + */ + public boolean isEnabled() { + return enabled; + } + + /** + * Core20 캐시 상태 확인 + */ + public boolean isCacheReady() { + return core20CacheManager.isLoaded(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/aistarget/classifier/Core20CacheManager.java b/src/main/java/com/snp/batch/jobs/aistarget/classifier/Core20CacheManager.java new file mode 100644 index 0000000..0a63ae8 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/aistarget/classifier/Core20CacheManager.java @@ -0,0 +1,219 @@ +package com.snp.batch.jobs.aistarget.classifier; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Core20 테이블의 IMO → MMSI 매핑 캐시 매니저 + * + * 동작: + * - 애플리케이션 시작 시 또는 첫 조회 시 자동 로딩 + * - 매일 지정된 시간(기본 04:00)에 전체 갱신 + * - TTL 없음 (명시적 갱신만) + * + * 데이터 구조: + * - Key: IMO/LRNO (7자리 문자열, NOT NULL) + * - Value: MMSI (문자열, NULLABLE - 빈 문자열로 저장) + * + * 특이사항: + * - Core20에 IMO는 있지만 MMSI가 null인 경우도 존재 + * - 이 경우 containsImo()는 true, getMmsiByImo()는 Optional.empty() + * - ConcurrentHashMap은 null을 허용하지 않으므로 빈 문자열("")을 sentinel 값으로 사용 + */ +@Slf4j +@Component +public class Core20CacheManager { + + private final JdbcTemplate jdbcTemplate; + private final Core20Properties properties; + + /** + * MMSI가 없는 경우를 나타내는 sentinel 값 + * ConcurrentHashMap은 null을 허용하지 않으므로 빈 문자열 사용 + */ + private static final String NO_MMSI = ""; + + /** + * IMO → MMSI 매핑 캐시 + * - Key: IMO (NOT NULL) + * - Value: MMSI (빈 문자열이면 MMSI 없음) + */ + private volatile Map imoToMmsiMap = new ConcurrentHashMap<>(); + + /** + * 마지막 갱신 시간 + */ + private volatile LocalDateTime lastRefreshTime; + + /** + * Core20 캐시 갱신 시간 (기본: 04시) + */ + @Value("${app.batch.class-type.refresh-hour:4}") + private int refreshHour; + + public Core20CacheManager(JdbcTemplate jdbcTemplate, Core20Properties properties) { + this.jdbcTemplate = jdbcTemplate; + this.properties = properties; + } + + /** + * IMO로 MMSI 조회 + * + * @param imo IMO 번호 (문자열) + * @return MMSI 값 (없거나 null/빈 문자열이면 Optional.empty) + */ + public Optional getMmsiByImo(String imo) { + ensureCacheLoaded(); + + if (imo == null || !imoToMmsiMap.containsKey(imo)) { + return Optional.empty(); + } + + String mmsi = imoToMmsiMap.get(imo); + + // MMSI가 빈 문자열(NO_MMSI)인 경우 + if (mmsi == null || mmsi.isEmpty()) { + return Optional.empty(); + } + + return Optional.of(mmsi); + } + + /** + * IMO 존재 여부만 확인 (MMSI 유무와 무관) + * - Core20에 등록된 선박인지 판단하는 용도 + * - MMSI가 null이어도 IMO가 있으면 true + * + * @param imo IMO 번호 + * @return Core20에 등록 여부 + */ + public boolean containsImo(String imo) { + ensureCacheLoaded(); + return imo != null && imoToMmsiMap.containsKey(imo); + } + + /** + * 캐시 전체 갱신 (DB에서 다시 로딩) + */ + public synchronized void refresh() { + log.info("Core20 캐시 갱신 시작 - 테이블: {}", properties.getFullTableName()); + + try { + String sql = properties.buildSelectSql(); + log.debug("Core20 조회 SQL: {}", sql); + + Map newMap = new ConcurrentHashMap<>(); + + jdbcTemplate.query(sql, rs -> { + String imo = rs.getString(1); + String mmsi = rs.getString(2); // nullable + + if (imo != null && !imo.isBlank()) { + // IMO는 trim하여 저장, MMSI는 빈 문자열로 대체 (ConcurrentHashMap은 null 불가) + String trimmedImo = imo.trim(); + String trimmedMmsi = (mmsi != null && !mmsi.isBlank()) ? mmsi.trim() : NO_MMSI; + newMap.put(trimmedImo, trimmedMmsi); + } + }); + + this.imoToMmsiMap = newMap; + this.lastRefreshTime = LocalDateTime.now(); + + // 통계 로깅 + long withMmsi = newMap.values().stream() + .filter(v -> !v.isEmpty()) + .count(); + + log.info("Core20 캐시 갱신 완료 - 총 {} 건 (MMSI 있음: {} 건, MMSI 없음: {} 건)", + newMap.size(), withMmsi, newMap.size() - withMmsi); + + } catch (Exception e) { + log.error("Core20 캐시 갱신 실패: {}", e.getMessage(), e); + // 기존 캐시 유지 (실패해도 서비스 중단 방지) + } + } + + /** + * 캐시가 비어있으면 자동 로딩 + */ + private void ensureCacheLoaded() { + if (imoToMmsiMap.isEmpty() && lastRefreshTime == null) { + log.warn("Core20 캐시 비어있음 - 자동 로딩 실행"); + refresh(); + } + } + + /** + * 지정된 시간대에 갱신이 필요한지 확인 + * - 기본: 04:00 ~ 04:01 사이 + * - 같은 날 이미 갱신했으면 스킵 + * + * @return 갱신 필요 여부 + */ + public boolean shouldRefresh() { + LocalDateTime now = LocalDateTime.now(); + int currentHour = now.getHour(); + int currentMinute = now.getMinute(); + + // 지정된 시간(예: 04:00~04:01) 체크 + if (currentHour != refreshHour || currentMinute > 0) { + return false; + } + + // 오늘 해당 시간에 이미 갱신했으면 스킵 + if (lastRefreshTime != null && + lastRefreshTime.toLocalDate().equals(now.toLocalDate()) && + lastRefreshTime.getHour() == refreshHour) { + return false; + } + + return true; + } + + /** + * 현재 캐시 크기 + */ + public int size() { + return imoToMmsiMap.size(); + } + + /** + * 마지막 갱신 시간 + */ + public LocalDateTime getLastRefreshTime() { + return lastRefreshTime; + } + + /** + * 캐시가 로드되었는지 확인 + */ + public boolean isLoaded() { + return lastRefreshTime != null && !imoToMmsiMap.isEmpty(); + } + + /** + * 캐시 통계 조회 (모니터링/디버깅용) + */ + public Map getStats() { + Map stats = new LinkedHashMap<>(); + stats.put("totalCount", imoToMmsiMap.size()); + stats.put("withMmsiCount", imoToMmsiMap.values().stream() + .filter(v -> !v.isEmpty()).count()); + stats.put("withoutMmsiCount", imoToMmsiMap.values().stream() + .filter(String::isEmpty).count()); + stats.put("lastRefreshTime", lastRefreshTime); + stats.put("refreshHour", refreshHour); + stats.put("tableName", properties.getFullTableName()); + stats.put("imoColumn", properties.getImoColumn()); + stats.put("mmsiColumn", properties.getMmsiColumn()); + return stats; + } +} diff --git a/src/main/java/com/snp/batch/jobs/aistarget/classifier/Core20Properties.java b/src/main/java/com/snp/batch/jobs/aistarget/classifier/Core20Properties.java new file mode 100644 index 0000000..1e1eb3f --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/aistarget/classifier/Core20Properties.java @@ -0,0 +1,71 @@ +package com.snp.batch.jobs.aistarget.classifier; + +import jakarta.annotation.PostConstruct; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Core20 테이블 설정 프로퍼티 + * + * 환경별(dev/qa/prod)로 테이블명, 컬럼명이 다를 수 있으므로 + * 프로파일별 설정 파일에서 지정할 수 있도록 구성 + * + * 사용 예: + * - dev: snp_data.core20 (ihslrorimoshipno, maritimemobileserviceidentitymmsinumber) + * - prod: new_snp.core20 (lrno, mmsi) + */ +@Slf4j +@Getter +@Setter +@ConfigurationProperties(prefix = "app.batch.core20") +public class Core20Properties { + + /** + * 스키마명 (예: snp_data, new_snp) + */ + private String schema = "snp_data"; + + /** + * 테이블명 (예: core20) + */ + private String table = "core20"; + + /** + * IMO/LRNO 컬럼명 (PK, NOT NULL) + */ + private String imoColumn = "ihslrorimoshipno"; + + /** + * MMSI 컬럼명 (NULLABLE) + */ + private String mmsiColumn = "maritimemobileserviceidentitymmsinumber"; + + /** + * 전체 테이블명 반환 (schema.table) + */ + public String getFullTableName() { + if (schema != null && !schema.isBlank()) { + return schema + "." + table; + } + return table; + } + + /** + * SELECT 쿼리 생성 + * IMO가 NOT NULL인 레코드만 조회 + */ + public String buildSelectSql() { + return String.format( + "SELECT %s, %s FROM %s WHERE %s IS NOT NULL", + imoColumn, mmsiColumn, getFullTableName(), imoColumn + ); + } + + @PostConstruct + public void logConfig() { + log.info("Core20 설정 로드 - 테이블: {}, IMO컬럼: {}, MMSI컬럼: {}", + getFullTableName(), imoColumn, mmsiColumn); + } +} diff --git a/src/main/java/com/snp/batch/jobs/aistarget/web/controller/AisTargetController.java b/src/main/java/com/snp/batch/jobs/aistarget/web/controller/AisTargetController.java new file mode 100644 index 0000000..609fc70 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/aistarget/web/controller/AisTargetController.java @@ -0,0 +1,428 @@ +package com.snp.batch.jobs.aistarget.web.controller; + +import com.snp.batch.common.web.ApiResponse; +import com.snp.batch.jobs.aistarget.web.dto.AisTargetFilterRequest; +import com.snp.batch.jobs.aistarget.web.dto.AisTargetResponseDto; +import com.snp.batch.jobs.aistarget.web.dto.AisTargetSearchRequest; +import com.snp.batch.jobs.aistarget.web.service.AisTargetService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; +import java.util.List; +import java.util.Map; + +/** + * AIS Target REST API Controller + * + * 캐시 우선 조회 전략: + * - 캐시에서 먼저 조회 + * - 캐시 미스 시 DB 조회 후 캐시 업데이트 + */ +@Slf4j +@RestController +@RequestMapping("/api/ais-target") +@RequiredArgsConstructor +@Tag(name = "AIS Target", description = "AIS 선박 위치 정보 API") +public class AisTargetController { + + private final AisTargetService aisTargetService; + + // ==================== 단건 조회 ==================== + + @Operation( + summary = "MMSI로 최신 위치 조회", + description = "특정 MMSI의 최신 위치 정보를 조회합니다 (캐시 우선)" + ) + @GetMapping("/{mmsi}") + public ResponseEntity> getLatestByMmsi( + @Parameter(description = "MMSI 번호", required = true, example = "440123456") + @PathVariable Long mmsi) { + log.info("최신 위치 조회 요청 - MMSI: {}", mmsi); + + return aisTargetService.findLatestByMmsi(mmsi) + .map(dto -> ResponseEntity.ok(ApiResponse.success(dto))) + .orElse(ResponseEntity.notFound().build()); + } + + // ==================== 다건 조회 ==================== + + @Operation( + summary = "여러 MMSI의 최신 위치 조회", + description = "여러 MMSI의 최신 위치 정보를 일괄 조회합니다 (캐시 우선)" + ) + @PostMapping("/batch") + public ResponseEntity>> getLatestByMmsiList( + @Parameter(description = "MMSI 번호 목록", required = true) + @RequestBody List mmsiList) { + log.info("다건 최신 위치 조회 요청 - 요청 수: {}", mmsiList.size()); + + List result = aisTargetService.findLatestByMmsiList(mmsiList); + return ResponseEntity.ok(ApiResponse.success( + "조회 완료: " + result.size() + "/" + mmsiList.size() + " 건", + result + )); + } + + // ==================== 검색 조회 ==================== + + @Operation( + summary = "시간/공간 범위로 선박 검색", + description = """ + 시간 범위 (필수) + 공간 범위 (옵션) + 선박 클래스 타입 (옵션)으로 선박을 검색합니다. + + - minutes: 조회 범위 (분, 필수) + - centerLon, centerLat: 중심 좌표 (옵션) + - radiusMeters: 반경 (미터, 옵션) + - classType: 선박 클래스 타입 필터 (A/B, 옵션) + + --- + ## ClassType 설명 + - **A**: Core20에 등록된 선박 (Class A AIS 장착 의무 선박) + - **B**: Core20 미등록 선박 (Class B AIS 또는 미등록) + - 미지정: 전체 조회 + + --- + ## 응답 필드 설명 + - **classType**: 선박 클래스 타입 (A/B) + - **core20Mmsi**: Core20 테이블의 MMSI 값 (Class A인 경우에만 존재할 수 있음) + + 공간 범위가 지정되지 않으면 전체 선박의 최신 위치를 반환합니다. + """ + ) + @GetMapping("/search") + public ResponseEntity>> search( + @Parameter(description = "조회 범위 (분)", required = true, example = "5") + @RequestParam Integer minutes, + @Parameter(description = "중심 경도", example = "129.0") + @RequestParam(required = false) Double centerLon, + @Parameter(description = "중심 위도", example = "35.0") + @RequestParam(required = false) Double centerLat, + @Parameter(description = "반경 (미터)", example = "50000") + @RequestParam(required = false) Double radiusMeters, + @Parameter(description = "선박 클래스 타입 필터 (A: Core20 등록, B: 미등록)", example = "A") + @RequestParam(required = false) String classType) { + + log.info("선박 검색 요청 - minutes: {}, center: ({}, {}), radius: {}, classType: {}", + minutes, centerLon, centerLat, radiusMeters, classType); + + AisTargetSearchRequest request = AisTargetSearchRequest.builder() + .minutes(minutes) + .centerLon(centerLon) + .centerLat(centerLat) + .radiusMeters(radiusMeters) + .classType(classType) + .build(); + + List result = aisTargetService.search(request); + return ResponseEntity.ok(ApiResponse.success( + "검색 완료: " + result.size() + " 건", + result + )); + } + + @Operation( + summary = "시간/공간 범위로 선박 검색 (POST)", + description = """ + POST 방식으로 검색 조건을 전달합니다. + + --- + ## 요청 예시 + ```json + { + "minutes": 5, + "centerLon": 129.0, + "centerLat": 35.0, + "radiusMeters": 50000, + "classType": "A" + } + ``` + + --- + ## ClassType 설명 + - **A**: Core20에 등록된 선박 (Class A AIS 장착 의무 선박) + - **B**: Core20 미등록 선박 (Class B AIS 또는 미등록) + - 미지정: 전체 조회 + """ + ) + @PostMapping("/search") + public ResponseEntity>> searchPost( + @Valid @RequestBody AisTargetSearchRequest request) { + log.info("선박 검색 요청 (POST) - minutes: {}, hasArea: {}, classType: {}", + request.getMinutes(), request.hasAreaFilter(), request.getClassType()); + + List result = aisTargetService.search(request); + return ResponseEntity.ok(ApiResponse.success( + "검색 완료: " + result.size() + " 건", + result + )); + } + + // ==================== 조건 필터 검색 ==================== + + @Operation( + summary = "항해 조건 필터 검색", + description = """ + 속도(SOG), 침로(COG), 선수방위(Heading), 목적지, 항행상태로 선박을 필터링합니다. + + --- + ## 조건 타입 및 파라미터 사용법 + + | 조건 | 의미 | 사용 파라미터 | + |------|------|--------------| + | GTE | 이상 (>=) | *Value (예: sogValue) | + | GT | 초과 (>) | *Value | + | LTE | 이하 (<=) | *Value | + | LT | 미만 (<) | *Value | + | BETWEEN | 범위 | *Min, *Max (예: sogMin, sogMax) | + + --- + ## 요청 예시 + + **예시 1: 단일 값 조건 (속도 10knots 이상)** + ```json + { + "minutes": 5, + "sogCondition": "GTE", + "sogValue": 10.0 + } + ``` + + **예시 2: 범위 조건 (속도 5~15knots, 침로 90~180도)** + ```json + { + "minutes": 5, + "sogCondition": "BETWEEN", + "sogMin": 5.0, + "sogMax": 15.0, + "cogCondition": "BETWEEN", + "cogMin": 90.0, + "cogMax": 180.0 + } + ``` + + **예시 3: 복합 조건** + ```json + { + "minutes": 5, + "sogCondition": "GTE", + "sogValue": 10.0, + "cogCondition": "BETWEEN", + "cogMin": 90.0, + "cogMax": 180.0, + "headingCondition": "LT", + "headingValue": 180.0, + "destination": "BUSAN", + "statusList": ["0", "1", "5"] + } + ``` + + --- + ## 항행상태 코드 (statusList) + + | 코드 | 상태 | + |------|------| + | 0 | Under way using engine (기관 사용 항해 중) | + | 1 | At anchor (정박 중) | + | 2 | Not under command (조종불능) | + | 3 | Restricted manoeuverability (조종제한) | + | 4 | Constrained by her draught (흘수제약) | + | 5 | Moored (계류 중) | + | 6 | Aground (좌초) | + | 7 | Engaged in Fishing (어로 중) | + | 8 | Under way sailing (돛 항해 중) | + | 9-10 | Reserved for future use | + | 11 | Power-driven vessel towing astern | + | 12 | Power-driven vessel pushing ahead | + | 13 | Reserved for future use | + | 14 | AIS-SART, MOB-AIS, EPIRB-AIS | + | 15 | Undefined (default) | + + --- + **참고:** 모든 필터는 선택사항이며, 미지정 시 해당 필드는 조건에서 제외됩니다 (전체 값 포함). + """ + ) + @PostMapping("/search/filter") + public ResponseEntity>> searchByFilter( + @Valid @RequestBody AisTargetFilterRequest request) { + log.info("필터 검색 요청 - minutes: {}, sog: {}/{}, cog: {}/{}, heading: {}/{}, dest: {}, status: {}", + request.getMinutes(), + request.getSogCondition(), request.getSogValue(), + request.getCogCondition(), request.getCogValue(), + request.getHeadingCondition(), request.getHeadingValue(), + request.getDestination(), + request.getStatusList()); + + List result = aisTargetService.searchByFilter(request); + return ResponseEntity.ok(ApiResponse.success( + "필터 검색 완료: " + result.size() + " 건", + result + )); + } + + // ==================== 폴리곤 검색 ==================== + + @Operation( + summary = "폴리곤 범위 내 선박 검색", + description = """ + 폴리곤 범위 내 선박을 검색합니다. + + 요청 예시: + { + "minutes": 5, + "coordinates": [[129.0, 35.0], [130.0, 35.0], [130.0, 36.0], [129.0, 36.0], [129.0, 35.0]] + } + + 좌표는 [경도, 위도] 순서이며, 폴리곤은 닫힌 형태여야 합니다 (첫점 = 끝점). + """ + ) + @PostMapping("/search/polygon") + public ResponseEntity>> searchByPolygon( + @RequestBody PolygonSearchRequest request) { + log.info("폴리곤 검색 요청 - minutes: {}, points: {}", + request.getMinutes(), request.getCoordinates().length); + + List result = aisTargetService.searchByPolygon( + request.getMinutes(), + request.getCoordinates() + ); + return ResponseEntity.ok(ApiResponse.success( + "폴리곤 검색 완료: " + result.size() + " 건", + result + )); + } + + @Operation( + summary = "WKT 범위 내 선박 검색", + description = """ + WKT(Well-Known Text) 형식으로 정의된 범위 내 선박을 검색합니다. + + 요청 예시: + { + "minutes": 5, + "wkt": "POLYGON((129 35, 130 35, 130 36, 129 36, 129 35))" + } + + 지원 형식: POLYGON, MULTIPOLYGON + """ + ) + @PostMapping("/search/wkt") + public ResponseEntity>> searchByWkt( + @RequestBody WktSearchRequest request) { + log.info("WKT 검색 요청 - minutes: {}, wkt: {}", request.getMinutes(), request.getWkt()); + + List result = aisTargetService.searchByWkt( + request.getMinutes(), + request.getWkt() + ); + return ResponseEntity.ok(ApiResponse.success( + "WKT 검색 완료: " + result.size() + " 건", + result + )); + } + + @Operation( + summary = "거리 포함 원형 범위 검색", + description = """ + 원형 범위 내 선박을 검색하고, 중심점으로부터의 거리 정보를 함께 반환합니다. + 결과는 거리순으로 정렬됩니다. + """ + ) + @GetMapping("/search/with-distance") + public ResponseEntity>> searchWithDistance( + @Parameter(description = "조회 범위 (분)", required = true, example = "5") + @RequestParam Integer minutes, + @Parameter(description = "중심 경도", required = true, example = "129.0") + @RequestParam Double centerLon, + @Parameter(description = "중심 위도", required = true, example = "35.0") + @RequestParam Double centerLat, + @Parameter(description = "반경 (미터)", required = true, example = "50000") + @RequestParam Double radiusMeters) { + + log.info("거리 포함 검색 요청 - minutes: {}, center: ({}, {}), radius: {}", + minutes, centerLon, centerLat, radiusMeters); + + List result = + aisTargetService.searchWithDistance(minutes, centerLon, centerLat, radiusMeters); + return ResponseEntity.ok(ApiResponse.success( + "거리 포함 검색 완료: " + result.size() + " 건", + result + )); + } + + // ==================== 항적 조회 ==================== + + @Operation( + summary = "항적 조회", + description = "특정 MMSI의 시간 범위 내 항적 (위치 이력)을 조회합니다" + ) + @GetMapping("/{mmsi}/track") + public ResponseEntity>> getTrack( + @Parameter(description = "MMSI 번호", required = true, example = "440123456") + @PathVariable Long mmsi, + @Parameter(description = "조회 범위 (분)", required = true, example = "60") + @RequestParam Integer minutes) { + log.info("항적 조회 요청 - MMSI: {}, 범위: {}분", mmsi, minutes); + + List track = aisTargetService.getTrack(mmsi, minutes); + return ResponseEntity.ok(ApiResponse.success( + "항적 조회 완료: " + track.size() + " 포인트", + track + )); + } + + // ==================== 캐시 관리 ==================== + + @Operation( + summary = "캐시 통계 조회", + description = "AIS Target 캐시의 현재 상태를 조회합니다" + ) + @GetMapping("/cache/stats") + public ResponseEntity>> getCacheStats() { + Map stats = aisTargetService.getCacheStats(); + return ResponseEntity.ok(ApiResponse.success(stats)); + } + + @Operation( + summary = "캐시 초기화", + description = "AIS Target 캐시를 초기화합니다" + ) + @DeleteMapping("/cache") + public ResponseEntity> clearCache() { + log.warn("캐시 초기화 요청"); + aisTargetService.clearCache(); + return ResponseEntity.ok(ApiResponse.success("캐시가 초기화되었습니다", null)); + } + + // ==================== 요청 DTO (내부 클래스) ==================== + + /** + * 폴리곤 검색 요청 DTO + */ + @lombok.Data + public static class PolygonSearchRequest { + @Parameter(description = "조회 범위 (분)", required = true, example = "5") + private int minutes; + + @Parameter(description = "폴리곤 좌표 [[lon, lat], ...]", required = true) + private double[][] coordinates; + } + + /** + * WKT 검색 요청 DTO + */ + @lombok.Data + public static class WktSearchRequest { + @Parameter(description = "조회 범위 (분)", required = true, example = "5") + private int minutes; + + @Parameter(description = "WKT 문자열", required = true, + example = "POLYGON((129 35, 130 35, 130 36, 129 36, 129 35))") + private String wkt; + } +} diff --git a/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetFilterRequest.java b/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetFilterRequest.java new file mode 100644 index 0000000..66637d8 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetFilterRequest.java @@ -0,0 +1,165 @@ +package com.snp.batch.jobs.aistarget.web.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +/** + * AIS Target 필터 검색 요청 DTO + * + * 조건 타입 (condition): + * - GTE: 이상 (>=) + * - GT: 초과 (>) + * - LTE: 이하 (<=) + * - LT: 미만 (<) + * - BETWEEN: 범위 (min <= value <= max) + * + * 모든 필터는 선택사항이며, 미지정 시 해당 필드 전체 포함 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "AIS Target 필터 검색 요청") +public class AisTargetFilterRequest { + + @NotNull(message = "minutes는 필수입니다") + @Min(value = 1, message = "minutes는 최소 1분 이상이어야 합니다") + @Schema(description = "조회 범위 (분)", example = "5", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer minutes; + + // ==================== 속도 (SOG) 필터 ==================== + @Schema(description = """ + 속도(SOG) 조건 타입 + - GTE: 이상 (>=) - sogValue 사용 + - GT: 초과 (>) - sogValue 사용 + - LTE: 이하 (<=) - sogValue 사용 + - LT: 미만 (<) - sogValue 사용 + - BETWEEN: 범위 - sogMin, sogMax 사용 + """, + example = "GTE", allowableValues = {"GTE", "GT", "LTE", "LT", "BETWEEN"}) + private String sogCondition; + + @Schema(description = "속도 값 (knots) - GTE/GT/LTE/LT 조건에서 사용", example = "10.0") + private Double sogValue; + + @Schema(description = "속도 최소값 (knots) - BETWEEN 조건에서 사용 (sogMin <= 속도 <= sogMax)", example = "5.0") + private Double sogMin; + + @Schema(description = "속도 최대값 (knots) - BETWEEN 조건에서 사용 (sogMin <= 속도 <= sogMax)", example = "15.0") + private Double sogMax; + + // ==================== 침로 (COG) 필터 ==================== + @Schema(description = """ + 침로(COG) 조건 타입 + - GTE: 이상 (>=) - cogValue 사용 + - GT: 초과 (>) - cogValue 사용 + - LTE: 이하 (<=) - cogValue 사용 + - LT: 미만 (<) - cogValue 사용 + - BETWEEN: 범위 - cogMin, cogMax 사용 + """, + example = "BETWEEN", allowableValues = {"GTE", "GT", "LTE", "LT", "BETWEEN"}) + private String cogCondition; + + @Schema(description = "침로 값 (degrees, 0-360) - GTE/GT/LTE/LT 조건에서 사용", example = "180.0") + private Double cogValue; + + @Schema(description = "침로 최소값 (degrees) - BETWEEN 조건에서 사용 (cogMin <= 침로 <= cogMax)", example = "90.0") + private Double cogMin; + + @Schema(description = "침로 최대값 (degrees) - BETWEEN 조건에서 사용 (cogMin <= 침로 <= cogMax)", example = "270.0") + private Double cogMax; + + // ==================== 선수방위 (Heading) 필터 ==================== + @Schema(description = """ + 선수방위(Heading) 조건 타입 + - GTE: 이상 (>=) - headingValue 사용 + - GT: 초과 (>) - headingValue 사용 + - LTE: 이하 (<=) - headingValue 사용 + - LT: 미만 (<) - headingValue 사용 + - BETWEEN: 범위 - headingMin, headingMax 사용 + """, + example = "LTE", allowableValues = {"GTE", "GT", "LTE", "LT", "BETWEEN"}) + private String headingCondition; + + @Schema(description = "선수방위 값 (degrees, 0-360) - GTE/GT/LTE/LT 조건에서 사용", example = "90.0") + private Double headingValue; + + @Schema(description = "선수방위 최소값 (degrees) - BETWEEN 조건에서 사용 (headingMin <= 선수방위 <= headingMax)", example = "0.0") + private Double headingMin; + + @Schema(description = "선수방위 최대값 (degrees) - BETWEEN 조건에서 사용 (headingMin <= 선수방위 <= headingMax)", example = "180.0") + private Double headingMax; + + // ==================== 목적지 (Destination) 필터 ==================== + @Schema(description = "목적지 (부분 일치, 대소문자 무시)", example = "BUSAN") + private String destination; + + // ==================== 항행상태 (Status) 필터 ==================== + @Schema(description = """ + 항행상태 목록 (다중 선택 가능, 미선택 시 전체) + - Under way using engine (기관 사용 항해 중) + - Under way sailing (돛 항해 중) + - Anchored (정박 중) + - Moored (계류 중) + - Not under command (조종불능) + - Restriced manoeuverability (조종제한) + - Constrained by draught (흘수제약) + - Aground (좌초) + - Engaged in fishing (어로 중) + - Power Driven Towing Astern (예인선-후방) + - Power Driven Towing Alongside (예인선-측방) + - AIS Sart (비상위치지시기) + - N/A (정보없음) + """, + example = "[\"Under way using engine\", \"Anchored\", \"Moored\"]") + private List statusList; + + // ==================== 선박 클래스 타입 (ClassType) 필터 ==================== + @Schema(description = """ + 선박 클래스 타입 필터 + - A: Core20에 등록된 선박 (Class A AIS 장착 의무 선박) + - B: Core20 미등록 선박 (Class B AIS 또는 미등록) + - 미지정: 전체 조회 + """, + example = "A", allowableValues = {"A", "B"}) + private String classType; + + // ==================== 필터 존재 여부 확인 ==================== + + public boolean hasSogFilter() { + return sogCondition != null && !sogCondition.isEmpty(); + } + + public boolean hasCogFilter() { + return cogCondition != null && !cogCondition.isEmpty(); + } + + public boolean hasHeadingFilter() { + return headingCondition != null && !headingCondition.isEmpty(); + } + + public boolean hasDestinationFilter() { + return destination != null && !destination.trim().isEmpty(); + } + + public boolean hasStatusFilter() { + return statusList != null && !statusList.isEmpty(); + } + + public boolean hasClassTypeFilter() { + return classType != null && + (classType.equalsIgnoreCase("A") || classType.equalsIgnoreCase("B")); + } + + public boolean hasAnyFilter() { + return hasSogFilter() || hasCogFilter() || hasHeadingFilter() + || hasDestinationFilter() || hasStatusFilter() || hasClassTypeFilter(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetResponseDto.java b/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetResponseDto.java new file mode 100644 index 0000000..aa8f3f4 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetResponseDto.java @@ -0,0 +1,109 @@ +package com.snp.batch.jobs.aistarget.web.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.OffsetDateTime; + +/** + * AIS Target API 응답 DTO + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "AIS Target 응답") +public class AisTargetResponseDto { + + // 선박 식별 정보 + private Long mmsi; + private Long imo; + private String name; + private String callsign; + private String vesselType; + + // 위치 정보 + private Double lat; + private Double lon; + + // 항해 정보 + private Double heading; + private Double sog; // Speed over Ground + private Double cog; // Course over Ground + private Integer rot; // Rate of Turn + + // 선박 제원 + private Integer length; + private Integer width; + private Double draught; + + // 목적지 정보 + private String destination; + private OffsetDateTime eta; + private String status; + + // 타임스탬프 + private OffsetDateTime messageTimestamp; + private OffsetDateTime receivedDate; + + // 데이터 소스 (캐시/DB) + @Schema(description = "데이터 소스", example = "cache", allowableValues = {"cache", "db"}) + private String source; + + // ClassType 분류 정보 + @Schema(description = """ + 선박 클래스 타입 + - A: Core20에 등록된 선박 (Class A AIS 장착 의무 선박) + - B: Core20 미등록 선박 (Class B AIS 또는 미등록) + """, + example = "A", allowableValues = {"A", "B"}) + private String classType; + + @Schema(description = """ + Core20 테이블의 MMSI 값 + - Class A인 경우에만 값이 있을 수 있음 + - null: Class B 또는 Core20에 MMSI가 미등록된 경우 + """, + example = "440123456", nullable = true) + private String core20Mmsi; + + /** + * Entity -> DTO 변환 + */ + public static AisTargetResponseDto from(AisTargetEntity entity, String source) { + if (entity == null) { + return null; + } + + return AisTargetResponseDto.builder() + .mmsi(entity.getMmsi()) + .imo(entity.getImo()) + .name(entity.getName()) + .callsign(entity.getCallsign()) + .vesselType(entity.getVesselType()) + .lat(entity.getLat()) + .lon(entity.getLon()) + .heading(entity.getHeading()) + .sog(entity.getSog()) + .cog(entity.getCog()) + .rot(entity.getRot()) + .length(entity.getLength()) + .width(entity.getWidth()) + .draught(entity.getDraught()) + .destination(entity.getDestination()) + .eta(entity.getEta()) + .status(entity.getStatus()) + .messageTimestamp(entity.getMessageTimestamp()) + .receivedDate(entity.getReceivedDate()) + .source(source) + .classType(entity.getClassType()) + .core20Mmsi(entity.getCore20Mmsi()) + .build(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetSearchRequest.java b/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetSearchRequest.java new file mode 100644 index 0000000..33d334f --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetSearchRequest.java @@ -0,0 +1,66 @@ +package com.snp.batch.jobs.aistarget.web.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +/** + * AIS Target 검색 요청 DTO + * + * 필수 파라미터: + * - minutes: 분 단위 조회 범위 (1~60) + * + * 옵션 파라미터: + * - centerLon, centerLat, radiusMeters: 공간 범위 필터 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "AIS Target 검색 요청") +public class AisTargetSearchRequest { + + @NotNull(message = "minutes는 필수입니다") + @Min(value = 1, message = "minutes는 최소 1분 이상이어야 합니다") + @Schema(description = "조회 범위 (분)", example = "5", required = true) + private Integer minutes; + + @Schema(description = "중심 경도 (옵션)", example = "129.0") + private Double centerLon; + + @Schema(description = "중심 위도 (옵션)", example = "35.0") + private Double centerLat; + + @Schema(description = "반경 (미터, 옵션)", example = "50000") + private Double radiusMeters; + + @Schema(description = """ + 선박 클래스 타입 필터 + - A: Core20에 등록된 선박 (Class A AIS 장착 의무 선박) + - B: Core20 미등록 선박 (Class B AIS 또는 미등록) + - 미지정: 전체 조회 + """, + example = "A", allowableValues = {"A", "B"}) + private String classType; + + /** + * 공간 필터 사용 여부 + */ + public boolean hasAreaFilter() { + return centerLon != null && centerLat != null && radiusMeters != null; + } + + /** + * ClassType 필터 사용 여부 + * - "A" 또는 "B"인 경우에만 true + */ + public boolean hasClassTypeFilter() { + return classType != null && + (classType.equalsIgnoreCase("A") || classType.equalsIgnoreCase("B")); + } +} diff --git a/src/main/java/com/snp/batch/jobs/aistarget/web/dto/NumericCondition.java b/src/main/java/com/snp/batch/jobs/aistarget/web/dto/NumericCondition.java new file mode 100644 index 0000000..cca3f54 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/aistarget/web/dto/NumericCondition.java @@ -0,0 +1,90 @@ +package com.snp.batch.jobs.aistarget.web.dto; + +/** + * 숫자 비교 조건 열거형 + * + * 사용: SOG, COG, Heading 필터링 + */ +public enum NumericCondition { + /** + * 이상 (>=) + */ + GTE, + + /** + * 초과 (>) + */ + GT, + + /** + * 이하 (<=) + */ + LTE, + + /** + * 미만 (<) + */ + LT, + + /** + * 범위 (min <= value <= max) + */ + BETWEEN; + + /** + * 문자열을 NumericCondition으로 변환 + * + * @param value 조건 문자열 + * @return NumericCondition (null이면 null 반환) + */ + public static NumericCondition fromString(String value) { + if (value == null || value.trim().isEmpty()) { + return null; + } + try { + return NumericCondition.valueOf(value.toUpperCase().trim()); + } catch (IllegalArgumentException e) { + return null; + } + } + + /** + * 주어진 값이 조건을 만족하는지 확인 + * + * @param fieldValue 필드 값 + * @param compareValue 비교 값 (GTE, GT, LTE, LT용) + * @param minValue 최소값 (BETWEEN용) + * @param maxValue 최대값 (BETWEEN용) + * @return 조건 만족 여부 + */ + public boolean matches(Double fieldValue, Double compareValue, Double minValue, Double maxValue) { + if (fieldValue == null) { + return false; + } + + return switch (this) { + case GTE -> compareValue != null && fieldValue >= compareValue; + case GT -> compareValue != null && fieldValue > compareValue; + case LTE -> compareValue != null && fieldValue <= compareValue; + case LT -> compareValue != null && fieldValue < compareValue; + case BETWEEN -> minValue != null && maxValue != null + && fieldValue >= minValue && fieldValue <= maxValue; + }; + } + + /** + * SQL 조건절 생성 (DB 쿼리용) + * + * @param columnName 컬럼명 + * @return SQL 조건절 문자열 + */ + public String toSqlCondition(String columnName) { + return switch (this) { + case GTE -> columnName + " >= ?"; + case GT -> columnName + " > ?"; + case LTE -> columnName + " <= ?"; + case LT -> columnName + " < ?"; + case BETWEEN -> columnName + " BETWEEN ? AND ?"; + }; + } +} diff --git a/src/main/java/com/snp/batch/jobs/aistarget/web/service/AisTargetService.java b/src/main/java/com/snp/batch/jobs/aistarget/web/service/AisTargetService.java new file mode 100644 index 0000000..a7e7fe2 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/aistarget/web/service/AisTargetService.java @@ -0,0 +1,390 @@ +package com.snp.batch.jobs.aistarget.web.service; + +import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity; +import com.snp.batch.jobs.aistarget.batch.repository.AisTargetRepository; +import com.snp.batch.jobs.aistarget.cache.AisTargetCacheManager; +import com.snp.batch.jobs.aistarget.cache.AisTargetFilterUtil; +import com.snp.batch.jobs.aistarget.cache.SpatialFilterUtil; +import com.snp.batch.jobs.aistarget.web.dto.AisTargetFilterRequest; +import com.snp.batch.jobs.aistarget.web.dto.AisTargetResponseDto; +import com.snp.batch.jobs.aistarget.web.dto.AisTargetSearchRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.*; +import java.util.stream.Collectors; + +/** + * AIS Target 서비스 + * + * 조회 전략: + * 1. 캐시 우선 조회 (Caffeine 캐시) + * 2. 캐시 미스 시 DB Fallback + * 3. 공간 필터링은 캐시에서 수행 (JTS 기반) + * + * 성능: + * - 캐시 조회: O(1) + * - 공간 필터링: O(n) with 병렬 처리 (25만건 ~50-100ms) + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AisTargetService { + + private final AisTargetRepository aisTargetRepository; + private final AisTargetCacheManager cacheManager; + private final SpatialFilterUtil spatialFilterUtil; + private final AisTargetFilterUtil filterUtil; + + private static final String SOURCE_CACHE = "cache"; + private static final String SOURCE_DB = "db"; + + // ==================== 단건 조회 ==================== + + /** + * MMSI로 최신 위치 조회 (캐시 우선) + */ + public Optional findLatestByMmsi(Long mmsi) { + log.debug("최신 위치 조회 - MMSI: {}", mmsi); + + // 1. 캐시 조회 + Optional cached = cacheManager.get(mmsi); + if (cached.isPresent()) { + log.debug("캐시 히트 - MMSI: {}", mmsi); + return Optional.of(AisTargetResponseDto.from(cached.get(), SOURCE_CACHE)); + } + + // 2. DB 조회 (캐시 미스) + log.debug("캐시 미스, DB 조회 - MMSI: {}", mmsi); + Optional fromDb = aisTargetRepository.findLatestByMmsi(mmsi); + + if (fromDb.isPresent()) { + // 3. 캐시 업데이트 + cacheManager.put(fromDb.get()); + log.debug("DB 조회 성공, 캐시 업데이트 - MMSI: {}", mmsi); + return Optional.of(AisTargetResponseDto.from(fromDb.get(), SOURCE_DB)); + } + + return Optional.empty(); + } + + // ==================== 다건 조회 ==================== + + /** + * 여러 MMSI의 최신 위치 조회 (캐시 우선) + */ + public List findLatestByMmsiList(List mmsiList) { + if (mmsiList == null || mmsiList.isEmpty()) { + return Collections.emptyList(); + } + + log.debug("다건 최신 위치 조회 - 요청: {} 건", mmsiList.size()); + + List result = new ArrayList<>(); + + // 1. 캐시에서 조회 + Map cachedData = cacheManager.getAll(mmsiList); + for (AisTargetEntity entity : cachedData.values()) { + result.add(AisTargetResponseDto.from(entity, SOURCE_CACHE)); + } + + // 2. 캐시 미스 목록 + List missedMmsiList = mmsiList.stream() + .filter(mmsi -> !cachedData.containsKey(mmsi)) + .collect(Collectors.toList()); + + // 3. DB에서 캐시 미스 데이터 조회 + if (!missedMmsiList.isEmpty()) { + log.debug("캐시 미스 DB 조회 - {} 건", missedMmsiList.size()); + List fromDb = aisTargetRepository.findLatestByMmsiIn(missedMmsiList); + + for (AisTargetEntity entity : fromDb) { + // 캐시 업데이트 + cacheManager.put(entity); + result.add(AisTargetResponseDto.from(entity, SOURCE_DB)); + } + } + + log.debug("조회 완료 - 캐시: {}, DB: {}, 총: {}", + cachedData.size(), result.size() - cachedData.size(), result.size()); + + return result; + } + + // ==================== 검색 조회 (캐시 기반) ==================== + + /** + * 시간 범위 + 옵션 공간 범위로 선박 검색 (캐시 우선) + * + * 전략: + * 1. 캐시에서 시간 범위 내 데이터 조회 + * 2. 공간 필터 있으면 JTS로 필터링 + * 3. ClassType 필터 있으면 적용 + * 4. 캐시 데이터가 없으면 DB Fallback + */ + public List search(AisTargetSearchRequest request) { + log.debug("선박 검색 - minutes: {}, hasArea: {}, classType: {}", + request.getMinutes(), request.hasAreaFilter(), request.getClassType()); + + long startTime = System.currentTimeMillis(); + + // 1. 캐시에서 시간 범위 내 데이터 조회 + List entities = cacheManager.getByTimeRange(request.getMinutes()); + String source = SOURCE_CACHE; + + // 캐시가 비어있으면 DB Fallback + if (entities.isEmpty()) { + log.debug("캐시 비어있음, DB Fallback"); + entities = searchFromDb(request); + source = SOURCE_DB; + + // DB 결과를 캐시에 저장 + for (AisTargetEntity entity : entities) { + cacheManager.put(entity); + } + } else if (request.hasAreaFilter()) { + // 2. 공간 필터링 (JTS 기반, 병렬 처리) + entities = spatialFilterUtil.filterByCircle( + entities, + request.getCenterLon(), + request.getCenterLat(), + request.getRadiusMeters() + ); + } + + // 3. ClassType 필터 적용 + if (request.hasClassTypeFilter()) { + entities = filterUtil.filterByClassType(entities, request); + } + + long elapsed = System.currentTimeMillis() - startTime; + log.info("선박 검색 완료 - 소스: {}, 결과: {} 건, 소요: {}ms", + source, entities.size(), elapsed); + + final String finalSource = source; + return entities.stream() + .map(e -> AisTargetResponseDto.from(e, finalSource)) + .collect(Collectors.toList()); + } + + /** + * DB에서 검색 (Fallback) + */ + private List searchFromDb(AisTargetSearchRequest request) { + OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC); + OffsetDateTime start = now.minusMinutes(request.getMinutes()); + + if (request.hasAreaFilter()) { + return aisTargetRepository.findByTimeRangeAndArea( + start, now, + request.getCenterLon(), + request.getCenterLat(), + request.getRadiusMeters() + ); + } else { + // 공간 필터 없으면 전체 조회 (주의: 대량 데이터) + return aisTargetRepository.findByTimeRangeAndArea( + start, now, + 0.0, 0.0, Double.MAX_VALUE + ); + } + } + + // ==================== 조건 필터 검색 ==================== + + /** + * 항해 조건 필터 검색 (캐시 우선) + * + * 필터 조건: + * - SOG (속도): 이상/초과/이하/미만/범위 + * - COG (침로): 이상/초과/이하/미만/범위 + * - Heading (선수방위): 이상/초과/이하/미만/범위 + * - Destination (목적지): 부분 일치 + * - Status (항행상태): 다중 선택 + * + * @param request 필터 조건 + * @return 조건에 맞는 선박 목록 + */ + public List searchByFilter(AisTargetFilterRequest request) { + log.debug("필터 검색 - minutes: {}, hasFilter: {}", + request.getMinutes(), request.hasAnyFilter()); + + long startTime = System.currentTimeMillis(); + + // 1. 캐시에서 시간 범위 내 데이터 조회 + List entities = cacheManager.getByTimeRange(request.getMinutes()); + String source = SOURCE_CACHE; + + // 캐시가 비어있으면 DB Fallback + if (entities.isEmpty()) { + log.debug("캐시 비어있음, DB Fallback"); + entities = searchByFilterFromDb(request); + source = SOURCE_DB; + + // DB 결과를 캐시에 저장 + for (AisTargetEntity entity : entities) { + cacheManager.put(entity); + } + + // DB에서 가져온 후에도 필터 적용 (DB 쿼리는 시간 범위만 적용) + entities = filterUtil.filter(entities, request); + } else { + // 2. 캐시 데이터에 필터 적용 + entities = filterUtil.filter(entities, request); + } + + long elapsed = System.currentTimeMillis() - startTime; + log.info("필터 검색 완료 - 소스: {}, 결과: {} 건, 소요: {}ms", + source, entities.size(), elapsed); + + final String finalSource = source; + return entities.stream() + .map(e -> AisTargetResponseDto.from(e, finalSource)) + .collect(Collectors.toList()); + } + + /** + * DB에서 필터 검색 (Fallback) - 시간 범위만 적용 + */ + private List searchByFilterFromDb(AisTargetFilterRequest request) { + OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC); + OffsetDateTime start = now.minusMinutes(request.getMinutes()); + + // DB에서는 시간 범위만 조회하고, 나머지 필터는 메모리에서 적용 + return aisTargetRepository.findByTimeRangeAndArea( + start, now, + 0.0, 0.0, Double.MAX_VALUE + ); + } + + // ==================== 폴리곤 검색 ==================== + + /** + * 폴리곤 범위 내 선박 검색 (캐시 기반) + * + * @param minutes 시간 범위 (분) + * @param polygonCoordinates 폴리곤 좌표 [[lon, lat], ...] + * @return 범위 내 선박 목록 + */ + public List searchByPolygon(int minutes, double[][] polygonCoordinates) { + log.debug("폴리곤 검색 - minutes: {}, points: {}", minutes, polygonCoordinates.length); + + long startTime = System.currentTimeMillis(); + + // 1. 캐시에서 시간 범위 내 데이터 조회 + List entities = cacheManager.getByTimeRange(minutes); + + // 2. 폴리곤 필터링 (JTS 기반) + entities = spatialFilterUtil.filterByPolygon(entities, polygonCoordinates); + + long elapsed = System.currentTimeMillis() - startTime; + log.info("폴리곤 검색 완료 - 결과: {} 건, 소요: {}ms", entities.size(), elapsed); + + return entities.stream() + .map(e -> AisTargetResponseDto.from(e, SOURCE_CACHE)) + .collect(Collectors.toList()); + } + + /** + * WKT 형식 폴리곤으로 검색 + * + * @param minutes 시간 범위 (분) + * @param wkt WKT 문자열 (예: "POLYGON((129 35, 130 35, 130 36, 129 36, 129 35))") + * @return 범위 내 선박 목록 + */ + public List searchByWkt(int minutes, String wkt) { + log.debug("WKT 검색 - minutes: {}, wkt: {}", minutes, wkt); + + long startTime = System.currentTimeMillis(); + + // 1. 캐시에서 시간 범위 내 데이터 조회 + List entities = cacheManager.getByTimeRange(minutes); + + // 2. WKT 필터링 (JTS 기반) + entities = spatialFilterUtil.filterByWkt(entities, wkt); + + long elapsed = System.currentTimeMillis() - startTime; + log.info("WKT 검색 완료 - 결과: {} 건, 소요: {}ms", entities.size(), elapsed); + + return entities.stream() + .map(e -> AisTargetResponseDto.from(e, SOURCE_CACHE)) + .collect(Collectors.toList()); + } + + // ==================== 거리 포함 검색 ==================== + + /** + * 원형 범위 검색 + 거리 정보 포함 + */ + public List searchWithDistance( + int minutes, double centerLon, double centerLat, double radiusMeters) { + + log.debug("거리 포함 검색 - minutes: {}, center: ({}, {}), radius: {}", + minutes, centerLon, centerLat, radiusMeters); + + // 1. 캐시에서 시간 범위 내 데이터 조회 + List entities = cacheManager.getByTimeRange(minutes); + + // 2. 거리 포함 필터링 + List filtered = + spatialFilterUtil.filterByCircleWithDistance(entities, centerLon, centerLat, radiusMeters); + + return filtered.stream() + .map(ewd -> new AisTargetWithDistanceDto( + AisTargetResponseDto.from(ewd.getEntity(), SOURCE_CACHE), + ewd.getDistanceMeters() + )) + .collect(Collectors.toList()); + } + + // ==================== 항적 조회 ==================== + + /** + * 특정 MMSI의 시간 범위 내 항적 조회 + */ + public List getTrack(Long mmsi, Integer minutes) { + log.debug("항적 조회 - MMSI: {}, 범위: {}분", mmsi, minutes); + + OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC); + OffsetDateTime start = now.minusMinutes(minutes); + + List track = aisTargetRepository.findByMmsiAndTimeRange(mmsi, start, now); + + log.debug("항적 조회 완료 - MMSI: {}, 포인트: {} 개", mmsi, track.size()); + + return track.stream() + .map(e -> AisTargetResponseDto.from(e, SOURCE_DB)) + .collect(Collectors.toList()); + } + + // ==================== 캐시 관리 ==================== + + /** + * 캐시 통계 조회 + */ + public Map getCacheStats() { + return cacheManager.getStats(); + } + + /** + * 캐시 초기화 + */ + public void clearCache() { + cacheManager.clear(); + } + + // ==================== 내부 DTO ==================== + + /** + * 거리 정보 포함 응답 DTO + */ + @lombok.Data + @lombok.AllArgsConstructor + public static class AisTargetWithDistanceDto { + private AisTargetResponseDto target; + private double distanceMeters; + } +} diff --git a/src/main/java/com/snp/batch/jobs/aistargetdbsync/batch/config/AisTargetDbSyncJobConfig.java b/src/main/java/com/snp/batch/jobs/aistargetdbsync/batch/config/AisTargetDbSyncJobConfig.java new file mode 100644 index 0000000..48e723a --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/aistargetdbsync/batch/config/AisTargetDbSyncJobConfig.java @@ -0,0 +1,79 @@ +package com.snp.batch.jobs.aistargetdbsync.batch.config; + +import com.snp.batch.jobs.aistargetdbsync.batch.tasklet.AisTargetDbSyncTasklet; +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; + +/** + * AIS Target DB Sync Job Config + * + * 스케줄: 매 15분 (0 0/15 * * * ?) + * API: 없음 (캐시 기반) + * + * 동작: + * - Caffeine 캐시에서 최근 15분 이내 데이터 조회 + * - MMSI별 최신 위치 1건씩 DB에 UPSERT + * - 1분 주기 aisTargetImportJob과 분리하여 DB 볼륨 최적화 + * + * 데이터 흐름: + * - aisTargetImportJob (1분): API → 캐시 업데이트 + * - aisTargetDbSyncJob (15분): 캐시 → DB 저장 (이 Job) + */ +@Slf4j +@Configuration +public class AisTargetDbSyncJobConfig { + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final AisTargetDbSyncTasklet aisTargetDbSyncTasklet; + + public AisTargetDbSyncJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + AisTargetDbSyncTasklet aisTargetDbSyncTasklet) { + this.jobRepository = jobRepository; + this.transactionManager = transactionManager; + this.aisTargetDbSyncTasklet = aisTargetDbSyncTasklet; + } + + @Bean(name = "aisTargetDbSyncStep") + public Step aisTargetDbSyncStep() { + return new StepBuilder("aisTargetDbSyncStep", jobRepository) + .tasklet(aisTargetDbSyncTasklet, transactionManager) + .build(); + } + + @Bean(name = "aisTargetDbSyncJob") + public Job aisTargetDbSyncJob() { + log.info("Job 생성: aisTargetDbSyncJob"); + + return new JobBuilder("aisTargetDbSyncJob", jobRepository) + .listener(new JobExecutionListener() { + @Override + public void beforeJob(JobExecution jobExecution) { + log.info("[aisTargetDbSyncJob] DB Sync Job 시작"); + } + + @Override + public void afterJob(JobExecution jobExecution) { + long writeCount = jobExecution.getStepExecutions().stream() + .mapToLong(se -> se.getWriteCount()) + .sum(); + + log.info("[aisTargetDbSyncJob] DB Sync Job 완료 - 상태: {}, 저장 건수: {}", + jobExecution.getStatus(), writeCount); + } + }) + .start(aisTargetDbSyncStep()) + .build(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/aistargetdbsync/batch/tasklet/AisTargetDbSyncTasklet.java b/src/main/java/com/snp/batch/jobs/aistargetdbsync/batch/tasklet/AisTargetDbSyncTasklet.java new file mode 100644 index 0000000..ffe5d9f --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/aistargetdbsync/batch/tasklet/AisTargetDbSyncTasklet.java @@ -0,0 +1,83 @@ +package com.snp.batch.jobs.aistargetdbsync.batch.tasklet; + +import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity; +import com.snp.batch.jobs.aistarget.batch.repository.AisTargetRepository; +import com.snp.batch.jobs.aistarget.cache.AisTargetCacheManager; +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.stereotype.Component; + +import java.util.List; + +/** + * AIS Target DB Sync Tasklet + * + * 스케줄: 매 15분 (0 0/15 * * * ?) + * + * 동작: + * - Caffeine 캐시에서 최근 N분 이내 데이터 조회 + * - MMSI별 최신 위치 1건씩 DB에 UPSERT + * - 캐시의 모든 컬럼 정보를 그대로 DB에 저장 + * + * 참고: + * - 캐시에는 MMSI별 최신 데이터만 유지됨 (120분 TTL) + * - DB 저장은 15분 주기로 수행하여 볼륨 절감 + * - 기존 aisTargetImportJob은 캐시 업데이트만 수행 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AisTargetDbSyncTasklet implements Tasklet { + + private final AisTargetCacheManager cacheManager; + private final AisTargetRepository aisTargetRepository; + + /** + * DB 동기화 시 조회할 캐시 데이터 시간 범위 (분) + * 기본값: 15분 (스케줄 주기와 동일) + */ + @Value("${app.batch.ais-target-db-sync.time-range-minutes:15}") + private int timeRangeMinutes; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { + log.info("========================================"); + log.info("AIS Target DB Sync 시작"); + log.info("조회 범위: 최근 {}분", timeRangeMinutes); + log.info("현재 캐시 크기: {}", cacheManager.size()); + log.info("========================================"); + + long startTime = System.currentTimeMillis(); + + // 1. 캐시에서 최근 N분 이내 데이터 조회 + List entities = cacheManager.getByTimeRange(timeRangeMinutes); + + if (entities.isEmpty()) { + log.warn("캐시에서 조회된 데이터가 없습니다 (범위: {}분)", timeRangeMinutes); + return RepeatStatus.FINISHED; + } + + log.info("캐시에서 {} 건 조회 완료", entities.size()); + + // 2. DB에 UPSERT + aisTargetRepository.batchUpsert(entities); + + long elapsed = System.currentTimeMillis() - startTime; + + log.info("========================================"); + log.info("AIS Target DB Sync 완료"); + log.info("저장 건수: {} 건", entities.size()); + log.info("소요 시간: {}ms", elapsed); + log.info("========================================"); + + // Step 통계 업데이트 + contribution.incrementWriteCount(entities.size()); + + return RepeatStatus.FINISHED; + } +} diff --git a/src/main/java/com/snp/batch/jobs/common/batch/config/FlagCodeImportJobConfig.java b/src/main/java/com/snp/batch/jobs/common/batch/config/FlagCodeImportJobConfig.java new file mode 100644 index 0000000..a920d7e --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/common/batch/config/FlagCodeImportJobConfig.java @@ -0,0 +1,93 @@ +package com.snp.batch.jobs.common.batch.config; + +import com.snp.batch.common.batch.config.BaseJobConfig; +import com.snp.batch.jobs.common.batch.dto.FlagCodeDto; +import com.snp.batch.jobs.common.batch.entity.FlagCodeEntity; +import com.snp.batch.jobs.common.batch.processor.FlagCodeDataProcessor; +import com.snp.batch.jobs.common.batch.reader.FlagCodeDataReader; +import com.snp.batch.jobs.common.batch.repository.FlagCodeRepository; +import com.snp.batch.jobs.common.batch.writer.FlagCodeDataWriter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +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 FlagCodeRepository flagCodeRepository; + private final WebClient maritimeApiWebClient; + + @Value("${app.batch.chunk-size:1000}") + private int chunkSize; + + /** + * 생성자 주입 + * maritimeApiWebClient: MaritimeApiWebClientConfig에서 등록한 Bean 주입 + */ + public FlagCodeImportJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + FlagCodeRepository flagCodeRepository, + @Qualifier("maritimeApiWebClient") WebClient maritimeApiWebClient) { + super(jobRepository, transactionManager); + this.flagCodeRepository = flagCodeRepository; + this.maritimeApiWebClient = maritimeApiWebClient; + } + + @Override + protected String getJobName() { + return "FlagCodeImportJob"; + } + + @Override + protected String getStepName() { + return "FlagCodeImportStep"; + } + + @Override + protected ItemReader createReader() { + return new FlagCodeDataReader(maritimeApiWebClient); + } + + @Override + protected ItemProcessor createProcessor() { + return new FlagCodeDataProcessor(flagCodeRepository); + } + + @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/common/batch/config/Stat5CodeImportJobConfig.java b/src/main/java/com/snp/batch/jobs/common/batch/config/Stat5CodeImportJobConfig.java new file mode 100644 index 0000000..c1fcccf --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/common/batch/config/Stat5CodeImportJobConfig.java @@ -0,0 +1,68 @@ +package com.snp.batch.jobs.common.batch.config; + +import com.snp.batch.common.batch.config.BaseJobConfig; +import com.snp.batch.jobs.common.batch.dto.Stat5CodeDto; +import com.snp.batch.jobs.common.batch.entity.Stat5CodeEntity; +import com.snp.batch.jobs.common.batch.processor.Stat5CodeDataProcessor; +import com.snp.batch.jobs.common.batch.reader.Stat5CodeDataReader; +import com.snp.batch.jobs.common.batch.repository.Stat5CodeRepository; +import com.snp.batch.jobs.common.batch.writer.Stat5CodeDataWriter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +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.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 Stat5CodeRepository stat5CodeRepository; + private final WebClient maritimeAisApiWebClient; + public Stat5CodeImportJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + Stat5CodeRepository stat5CodeRepository, + @Qualifier("maritimeAisApiWebClient") WebClient maritimeAisApiWebClient) { + super(jobRepository, transactionManager); + this.stat5CodeRepository = stat5CodeRepository; + this.maritimeAisApiWebClient = maritimeAisApiWebClient; + } + + @Override + protected String getJobName() { return "Stat5CodeImportJob"; } + + @Override + protected String getStepName() { + return "Stat5CodeImportStep"; + } + + @Override + protected ItemReader createReader() { return new Stat5CodeDataReader(maritimeAisApiWebClient); } + + @Override + protected ItemProcessor createProcessor() { return new Stat5CodeDataProcessor(stat5CodeRepository); } + + @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/common/batch/dto/FlagCodeApiResponse.java b/src/main/java/com/snp/batch/jobs/common/batch/dto/FlagCodeApiResponse.java new file mode 100644 index 0000000..3e3a51f --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/common/batch/dto/FlagCodeApiResponse.java @@ -0,0 +1,26 @@ +package com.snp.batch.jobs.common.batch.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/common/batch/dto/FlagCodeDto.java b/src/main/java/com/snp/batch/jobs/common/batch/dto/FlagCodeDto.java new file mode 100644 index 0000000..c763da7 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/common/batch/dto/FlagCodeDto.java @@ -0,0 +1,40 @@ +package com.snp.batch.jobs.common.batch.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/common/batch/dto/Stat5CodeApiResponse.java b/src/main/java/com/snp/batch/jobs/common/batch/dto/Stat5CodeApiResponse.java new file mode 100644 index 0000000..f8f60e1 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/common/batch/dto/Stat5CodeApiResponse.java @@ -0,0 +1,18 @@ +package com.snp.batch.jobs.common.batch.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/common/batch/dto/Stat5CodeDto.java b/src/main/java/com/snp/batch/jobs/common/batch/dto/Stat5CodeDto.java new file mode 100644 index 0000000..c50fc10 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/common/batch/dto/Stat5CodeDto.java @@ -0,0 +1,40 @@ +package com.snp.batch.jobs.common.batch.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/common/batch/entity/FlagCodeEntity.java b/src/main/java/com/snp/batch/jobs/common/batch/entity/FlagCodeEntity.java new file mode 100644 index 0000000..995d108 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/common/batch/entity/FlagCodeEntity.java @@ -0,0 +1,26 @@ +package com.snp.batch.jobs.common.batch.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/common/batch/entity/Stat5CodeEntity.java b/src/main/java/com/snp/batch/jobs/common/batch/entity/Stat5CodeEntity.java new file mode 100644 index 0000000..b5e2d76 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/common/batch/entity/Stat5CodeEntity.java @@ -0,0 +1,40 @@ +package com.snp.batch.jobs.common.batch.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/common/batch/processor/FlagCodeDataProcessor.java b/src/main/java/com/snp/batch/jobs/common/batch/processor/FlagCodeDataProcessor.java new file mode 100644 index 0000000..fb6966b --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/common/batch/processor/FlagCodeDataProcessor.java @@ -0,0 +1,33 @@ +package com.snp.batch.jobs.common.batch.processor; + +import com.snp.batch.common.batch.processor.BaseProcessor; +import com.snp.batch.jobs.common.batch.dto.FlagCodeDto; +import com.snp.batch.jobs.common.batch.entity.FlagCodeEntity; +import com.snp.batch.jobs.common.batch.repository.FlagCodeRepository; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class FlagCodeDataProcessor extends BaseProcessor { + + private final FlagCodeRepository commonCodeRepository; + + public FlagCodeDataProcessor(FlagCodeRepository commonCodeRepository) { + this.commonCodeRepository = commonCodeRepository; + } + + @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()) + .build(); + + log.debug("국가코드 데이터 처리 완료: FlagCode={}", dto.getCode()); + + return entity; + } +} diff --git a/src/main/java/com/snp/batch/jobs/common/batch/processor/Stat5CodeDataProcessor.java b/src/main/java/com/snp/batch/jobs/common/batch/processor/Stat5CodeDataProcessor.java new file mode 100644 index 0000000..fd5c1b7 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/common/batch/processor/Stat5CodeDataProcessor.java @@ -0,0 +1,38 @@ +package com.snp.batch.jobs.common.batch.processor; + +import com.snp.batch.common.batch.processor.BaseProcessor; +import com.snp.batch.jobs.common.batch.dto.Stat5CodeDto; +import com.snp.batch.jobs.common.batch.entity.Stat5CodeEntity; +import com.snp.batch.jobs.common.batch.repository.Stat5CodeRepository; +import lombok.extern.slf4j.Slf4j; + + +@Slf4j +public class Stat5CodeDataProcessor extends BaseProcessor { + private final Stat5CodeRepository stat5CodeRepository; + + public Stat5CodeDataProcessor(Stat5CodeRepository stat5CodeRepository) { + this.stat5CodeRepository = stat5CodeRepository; + } + + @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())) + .build(); + + log.debug("Stat5Code 데이터 처리 완료: Stat5Code={}", dto.getLevel5()); + return entity; + } +} diff --git a/src/main/java/com/snp/batch/jobs/common/batch/reader/FlagCodeDataReader.java b/src/main/java/com/snp/batch/jobs/common/batch/reader/FlagCodeDataReader.java new file mode 100644 index 0000000..08802af --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/common/batch/reader/FlagCodeDataReader.java @@ -0,0 +1,56 @@ +package com.snp.batch.jobs.common.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.common.batch.dto.FlagCodeApiResponse; +import com.snp.batch.jobs.common.batch.dto.FlagCodeDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.ArrayList; +import java.util.List; + +@Slf4j +public class FlagCodeDataReader extends BaseApiReader { + + public FlagCodeDataReader(WebClient webClient) { + super(webClient); // BaseApiReader에 WebClient 전달 + } + + // ======================================== + // 필수 구현 메서드 + // ======================================== + + @Override + protected String getReaderName() { + return "FlagCodeDataReader"; + } + + @Override + protected List fetchDataFromApi() { + try { + log.info("GetAssociatedFlagISOByName API 호출 시작"); + + FlagCodeApiResponse response = webClient + .get() + .uri(uriBuilder -> uriBuilder + .path("/MaritimeWCF/APSShipService.svc/RESTFul/GetAssociatedFlagISOByName") + .build()) + .retrieve() + .bodyToMono(FlagCodeApiResponse.class) + .block(); + + if (response != null && response.getAssociatedFlagISODetails() != null) { + log.info("API 응답 성공: 총 {} 건의 국가코드 데이터 수신", response.getAssociatedCount()); + return response.getAssociatedFlagISODetails(); + } else { + log.warn("API 응답이 null이거나 국가코드 데이터가 없습니다"); + return new ArrayList<>(); + } + + } 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/common/batch/reader/Stat5CodeDataReader.java b/src/main/java/com/snp/batch/jobs/common/batch/reader/Stat5CodeDataReader.java new file mode 100644 index 0000000..238519d --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/common/batch/reader/Stat5CodeDataReader.java @@ -0,0 +1,49 @@ +package com.snp.batch.jobs.common.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.common.batch.dto.Stat5CodeApiResponse; +import com.snp.batch.jobs.common.batch.dto.Stat5CodeDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.ArrayList; +import java.util.List; + +@Slf4j +public class Stat5CodeDataReader extends BaseApiReader { + public Stat5CodeDataReader(WebClient webClient) { + super(webClient); // BaseApiReader에 WebClient 전달 + } + @Override + protected String getReaderName() { + return "Stat5CodeDataReader"; + } + @Override + protected List fetchDataFromApi() { + try { + log.info("GetStatcodes API 호출 시작"); + + Stat5CodeApiResponse response = webClient + .get() + .uri(uriBuilder -> uriBuilder + .path("/AisSvc.svc/AIS/GetStatcodes") + .build()) + .retrieve() + .bodyToMono(Stat5CodeApiResponse.class) + .block(); + + if (response != null && response.getStatcodeArr() != null) { + log.info("API 응답 성공: 총 {} 건의 Stat5Code 데이터 수신", response.getStatcodeArr().size()); + return response.getStatcodeArr(); + } else { + log.warn("API 응답이 null이거나 Stat5Code 데이터가 없습니다"); + return new ArrayList<>(); + } + + } 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/common/batch/repository/FlagCodeRepository.java b/src/main/java/com/snp/batch/jobs/common/batch/repository/FlagCodeRepository.java new file mode 100644 index 0000000..304bb8b --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/common/batch/repository/FlagCodeRepository.java @@ -0,0 +1,12 @@ +package com.snp.batch.jobs.common.batch.repository; + +import com.snp.batch.jobs.common.batch.entity.FlagCodeEntity; + +import java.util.List; + +public interface FlagCodeRepository { + + void saveAllFlagCode(List items); + + +} diff --git a/src/main/java/com/snp/batch/jobs/common/batch/repository/FlagCodeRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/common/batch/repository/FlagCodeRepositoryImpl.java new file mode 100644 index 0000000..ee2e3ab --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/common/batch/repository/FlagCodeRepositoryImpl.java @@ -0,0 +1,95 @@ +package com.snp.batch.jobs.common.batch.repository; + +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.common.batch.entity.FlagCodeEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.util.List; + +@Slf4j +@Repository("FlagCodeRepository") +public class FlagCodeRepositoryImpl extends BaseJdbcRepository implements FlagCodeRepository { + + public FlagCodeRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + protected String getEntityName() { + return "FlagCodeEntity"; + } + + @Override + protected String getTableName() { + return "snp_data.flagcode"; + } + + + @Override + protected String getInsertSql() { + return null; + } + + @Override + protected String getUpdateSql() { + return """ + INSERT INTO snp_data.flagcode ( + datasetversion, code, decode, iso2, iso3 + ) VALUES (?, ?, ?, ?, ?) + ON CONFLICT (code) + DO UPDATE SET + datasetversion = EXCLUDED.datasetversion, + decode = EXCLUDED.decode, + iso2 = EXCLUDED.iso2, + iso3 = EXCLUDED.iso3, + batch_flag = 'N' + """; + } + + @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()); + } + + @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/common/batch/repository/Stat5CodeRepository.java b/src/main/java/com/snp/batch/jobs/common/batch/repository/Stat5CodeRepository.java new file mode 100644 index 0000000..10edd49 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/common/batch/repository/Stat5CodeRepository.java @@ -0,0 +1,9 @@ +package com.snp.batch.jobs.common.batch.repository; + +import com.snp.batch.jobs.common.batch.entity.Stat5CodeEntity; + +import java.util.List; + +public interface Stat5CodeRepository { + void saveAllStat5Code(List items); +} diff --git a/src/main/java/com/snp/batch/jobs/common/batch/repository/Stat5CodeRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/common/batch/repository/Stat5CodeRepositoryImpl.java new file mode 100644 index 0000000..90251fb --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/common/batch/repository/Stat5CodeRepositoryImpl.java @@ -0,0 +1,109 @@ +package com.snp.batch.jobs.common.batch.repository; + +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.common.batch.entity.Stat5CodeEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.util.List; + +@Slf4j +@Repository("Stat5CodeRepository") +public class Stat5CodeRepositoryImpl extends BaseJdbcRepository implements Stat5CodeRepository{ + public Stat5CodeRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + protected String getEntityName() { + return "Stat5CodeEntity"; + } + + @Override + protected String getTableName() { + return "snp_data.stat5code"; + } + + @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 snp_data.stat5code ( + level1, level1decode, level2, level2decode, level3, level3decode, level4, level4decode, level5, level5decode, description, release + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (level1, level2, level3, level4, level5) + DO UPDATE SET + level1 = EXCLUDED.level1, + level1decode = EXCLUDED.level1decode, + level2 = EXCLUDED.level2, + level2decode = EXCLUDED.level2decode, + level3 = EXCLUDED.level3, + level3decode = EXCLUDED.level3decode, + level4 = EXCLUDED.level4, + level4decode = EXCLUDED.level4decode, + level5 = EXCLUDED.level5, + level5decode = EXCLUDED.level5decode, + description = EXCLUDED.description, + release = EXCLUDED.release, + batch_flag = 'N' + """; + } + + @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()); + } + + @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/common/batch/writer/FlagCodeDataWriter.java b/src/main/java/com/snp/batch/jobs/common/batch/writer/FlagCodeDataWriter.java new file mode 100644 index 0000000..5c5b82e --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/common/batch/writer/FlagCodeDataWriter.java @@ -0,0 +1,25 @@ +package com.snp.batch.jobs.common.batch.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.common.batch.entity.FlagCodeEntity; +import com.snp.batch.jobs.common.batch.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/common/batch/writer/Stat5CodeDataWriter.java b/src/main/java/com/snp/batch/jobs/common/batch/writer/Stat5CodeDataWriter.java new file mode 100644 index 0000000..9f8267e --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/common/batch/writer/Stat5CodeDataWriter.java @@ -0,0 +1,25 @@ +package com.snp.batch.jobs.common.batch.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.common.batch.entity.Stat5CodeEntity; +import com.snp.batch.jobs.common.batch.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/compliance/batch/config/CompanyComplianceImportRangeJobConfig.java b/src/main/java/com/snp/batch/jobs/compliance/batch/config/CompanyComplianceImportRangeJobConfig.java new file mode 100644 index 0000000..495a402 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/compliance/batch/config/CompanyComplianceImportRangeJobConfig.java @@ -0,0 +1,206 @@ +package com.snp.batch.jobs.compliance.batch.config; + +import com.snp.batch.common.batch.config.BaseMultiStepJobConfig; +import com.snp.batch.jobs.compliance.batch.dto.CompanyComplianceDto; +import com.snp.batch.jobs.compliance.batch.dto.ComplianceDto; +import com.snp.batch.jobs.compliance.batch.entity.CompanyComplianceEntity; +import com.snp.batch.jobs.compliance.batch.entity.ComplianceEntity; +import com.snp.batch.jobs.compliance.batch.processor.CompanyComplianceDataProcessor; +import com.snp.batch.jobs.compliance.batch.reader.CompanyComplianceDataRangeReader; +import com.snp.batch.jobs.compliance.batch.reader.ComplianceDataRangeReader; +import com.snp.batch.jobs.compliance.batch.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.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; + protected String getApiKey() {return "COMPANY_COMPLIANCE_IMPORT_API";} + protected String getBatchUpdateSql() { + return String.format("UPDATE SNP_DATA.BATCH_LAST_EXECUTION SET LAST_SUCCESS_DATE = NOW(), UPDATED_AT = NOW() WHERE API_KEY = '%s'", getApiKey());} + + @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()) // 1단계 실행 + .next(companyComplianceHistoryValueChangeManageStep()) // 2단계 실행 (2단계 실패 시 실행 안 됨) + .next(companyComplianceLastExecutionUpdateStep()) // 3단계: 모두 완료 시, BATCH_LAST_EXECUTION 마지막 성공일자 업데이트 + .build(); + } + + @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; + } + + @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. 프로시저 호출 (안전한 파라미터 바인딩 권장) + jdbcTemplate.update("CALL new_snp.company_compliance_history_value_change_manage(CAST(? AS TIMESTAMP), CAST(? AS TIMESTAMP))", 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 (contribution, chunkContext) -> { + log.info(">>>>> 모든 스텝 성공: BATCH_LAST_EXECUTION 업데이트 시작"); + + jdbcTemplate.execute(getBatchUpdateSql()); + + log.info(">>>>> BATCH_LAST_EXECUTION 업데이트 완료"); + return RepeatStatus.FINISHED; + }; + } + @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/compliance/batch/config/ComplianceImportJobConfig.java b/src/main/java/com/snp/batch/jobs/compliance/batch/config/ComplianceImportJobConfig.java new file mode 100644 index 0000000..5dfff81 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/compliance/batch/config/ComplianceImportJobConfig.java @@ -0,0 +1,86 @@ +package com.snp.batch.jobs.compliance.batch.config; + +import com.snp.batch.common.batch.config.BaseJobConfig; +import com.snp.batch.jobs.compliance.batch.dto.ComplianceDto; +import com.snp.batch.jobs.compliance.batch.entity.ComplianceEntity; +import com.snp.batch.jobs.compliance.batch.processor.ComplianceDataProcessor; +import com.snp.batch.jobs.compliance.batch.reader.ComplianceDataReader; +import com.snp.batch.jobs.compliance.batch.writer.ComplianceDataWriter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +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.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 ComplianceImportJobConfig extends BaseJobConfig { + private final JdbcTemplate jdbcTemplate; + private final WebClient maritimeServiceApiWebClient; + + private final ComplianceDataProcessor complianceDataProcessor; + + private final ComplianceDataWriter complianceDataWriter; + + @Override + protected int getChunkSize() { + return 5000; // API에서 5000개씩 가져오므로 chunk도 5000으로 설정 + } + public ComplianceImportJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + ComplianceDataProcessor complianceDataProcessor, + ComplianceDataWriter complianceDataWriter, + JdbcTemplate jdbcTemplate, + @Qualifier("maritimeServiceApiWebClient")WebClient maritimeServiceApiWebClient) { + super(jobRepository, transactionManager); + this.jdbcTemplate = jdbcTemplate; + this.maritimeServiceApiWebClient = maritimeServiceApiWebClient; + this.complianceDataProcessor = complianceDataProcessor; + this.complianceDataWriter = complianceDataWriter; + } + + @Override + protected String getJobName() { + return "ComplianceImportJob"; + } + + @Override + protected String getStepName() { + return "ComplianceImportStep"; + } + + @Override + protected ItemReader createReader() { + return new ComplianceDataReader(maritimeServiceApiWebClient, jdbcTemplate); + } + + @Override + protected ItemProcessor createProcessor() { + return complianceDataProcessor; + } + + @Override + protected ItemWriter createWriter() { + return complianceDataWriter; + } + + @Bean(name = "ComplianceImportJob") + public Job complianceImportJob() { + return job(); + } + + @Bean(name = "ComplianceImportStep") + public Step complianceImportStep() { + return step(); + } + +} diff --git a/src/main/java/com/snp/batch/jobs/compliance/batch/config/ComplianceImportRangeJobConfig.java b/src/main/java/com/snp/batch/jobs/compliance/batch/config/ComplianceImportRangeJobConfig.java new file mode 100644 index 0000000..612e821 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/compliance/batch/config/ComplianceImportRangeJobConfig.java @@ -0,0 +1,200 @@ +package com.snp.batch.jobs.compliance.batch.config; + +import com.snp.batch.common.batch.config.BaseMultiStepJobConfig; +import com.snp.batch.jobs.compliance.batch.dto.ComplianceDto; +import com.snp.batch.jobs.compliance.batch.entity.ComplianceEntity; +import com.snp.batch.jobs.compliance.batch.processor.ComplianceDataProcessor; +import com.snp.batch.jobs.compliance.batch.reader.ComplianceDataRangeReader; +import com.snp.batch.jobs.compliance.batch.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.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.*; +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; + protected String getApiKey() {return "COMPLIANCE_IMPORT_API";} + protected String getBatchUpdateSql() { + return String.format("UPDATE SNP_DATA.BATCH_LAST_EXECUTION SET LAST_SUCCESS_DATE = NOW(), UPDATED_AT = NOW() WHERE API_KEY = '%s'", getApiKey());} + + @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()) // 1단계 실행 + .next(complianceHistoryValueChangeManageStep()) // 2단계 실행 (2단계 실패 시 실행 안 됨) + .next(complianceLastExecutionUpdateStep()) // 3단계: 모두 완료 시, BATCH_LAST_EXECUTION 마지막 성공일자 업데이트 + .build(); + } + + @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; + } + + @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. 프로시저 호출 (안전한 파라미터 바인딩 권장) + jdbcTemplate.update("CALL new_snp.compliance_history_value_change_manage(CAST(? AS TIMESTAMP), CAST(? AS TIMESTAMP))", 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 (contribution, chunkContext) -> { + log.info(">>>>> 모든 스텝 성공: BATCH_LAST_EXECUTION 업데이트 시작"); + + jdbcTemplate.execute(getBatchUpdateSql()); + + log.info(">>>>> BATCH_LAST_EXECUTION 업데이트 완료"); + return RepeatStatus.FINISHED; + }; + } + @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/compliance/batch/dto/CompanyComplianceDto.java b/src/main/java/com/snp/batch/jobs/compliance/batch/dto/CompanyComplianceDto.java new file mode 100644 index 0000000..ecbc837 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/compliance/batch/dto/CompanyComplianceDto.java @@ -0,0 +1,61 @@ +package com.snp.batch.jobs.compliance.batch.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/compliance/batch/dto/ComplianceDto.java b/src/main/java/com/snp/batch/jobs/compliance/batch/dto/ComplianceDto.java new file mode 100644 index 0000000..4b7d032 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/compliance/batch/dto/ComplianceDto.java @@ -0,0 +1,121 @@ +package com.snp.batch.jobs.compliance.batch.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/compliance/batch/entity/CompanyComplianceEntity.java b/src/main/java/com/snp/batch/jobs/compliance/batch/entity/CompanyComplianceEntity.java new file mode 100644 index 0000000..1ea272c --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/compliance/batch/entity/CompanyComplianceEntity.java @@ -0,0 +1,47 @@ +package com.snp.batch.jobs.compliance.batch.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/compliance/batch/entity/ComplianceEntity.java b/src/main/java/com/snp/batch/jobs/compliance/batch/entity/ComplianceEntity.java new file mode 100644 index 0000000..e363042 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/compliance/batch/entity/ComplianceEntity.java @@ -0,0 +1,89 @@ +package com.snp.batch.jobs.compliance.batch.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/compliance/batch/processor/CompanyComplianceDataProcessor.java b/src/main/java/com/snp/batch/jobs/compliance/batch/processor/CompanyComplianceDataProcessor.java new file mode 100644 index 0000000..ca9d012 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/compliance/batch/processor/CompanyComplianceDataProcessor.java @@ -0,0 +1,36 @@ +package com.snp.batch.jobs.compliance.batch.processor; + +import com.snp.batch.common.batch.processor.BaseProcessor; +import com.snp.batch.jobs.compliance.batch.dto.CompanyComplianceDto; +import com.snp.batch.jobs.compliance.batch.entity.CompanyComplianceEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class CompanyComplianceDataProcessor extends BaseProcessor { + + @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()) + .build(); + return entity; + } +} diff --git a/src/main/java/com/snp/batch/jobs/compliance/batch/processor/ComplianceDataProcessor.java b/src/main/java/com/snp/batch/jobs/compliance/batch/processor/ComplianceDataProcessor.java new file mode 100644 index 0000000..5245205 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/compliance/batch/processor/ComplianceDataProcessor.java @@ -0,0 +1,57 @@ +package com.snp.batch.jobs.compliance.batch.processor; + +import com.snp.batch.common.batch.processor.BaseProcessor; +import com.snp.batch.jobs.compliance.batch.dto.ComplianceDto; +import com.snp.batch.jobs.compliance.batch.entity.ComplianceEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class ComplianceDataProcessor extends BaseProcessor { + @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()) + .build(); + + return entity; + } +} diff --git a/src/main/java/com/snp/batch/jobs/compliance/batch/reader/CompanyComplianceDataRangeReader.java b/src/main/java/com/snp/batch/jobs/compliance/batch/reader/CompanyComplianceDataRangeReader.java new file mode 100644 index 0000000..6170041 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/compliance/batch/reader/CompanyComplianceDataRangeReader.java @@ -0,0 +1,108 @@ +package com.snp.batch.jobs.compliance.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.compliance.batch.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/compliance/batch/reader/ComplianceDataRangeReader.java b/src/main/java/com/snp/batch/jobs/compliance/batch/reader/ComplianceDataRangeReader.java new file mode 100644 index 0000000..6c5c185 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/compliance/batch/reader/ComplianceDataRangeReader.java @@ -0,0 +1,116 @@ +package com.snp.batch.jobs.compliance.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.compliance.batch.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/compliance/batch/reader/ComplianceDataReader.java b/src/main/java/com/snp/batch/jobs/compliance/batch/reader/ComplianceDataReader.java new file mode 100644 index 0000000..aff76d7 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/compliance/batch/reader/ComplianceDataReader.java @@ -0,0 +1,147 @@ +package com.snp.batch.jobs.compliance.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.compliance.batch.dto.ComplianceDto; +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.Collections; +import java.util.List; + +@Slf4j +public class ComplianceDataReader extends BaseApiReader { + + //TODO : + // 1. Core20 IMO_NUMBER 전체 조회 + // 2. IMO번호에 대한 마지막 AIS 신호 요청 (1회 최대 5000개 : Chunk 단위로 반복) + // 3. Response Data -> Core20에 업데이트 (Chunk 단위로 반복) + + private final JdbcTemplate jdbcTemplate; + + private List allImoNumbers; + private int currentBatchIndex = 0; + private final int batchSize = 100; + + public ComplianceDataReader(WebClient webClient, JdbcTemplate jdbcTemplate) { + super(webClient); + this.jdbcTemplate = jdbcTemplate; + enableChunkMode(); // ✨ Chunk 모드 활성화 + } + + @Override + protected String getReaderName() { + return "ComplianceDataReader"; + } + + @Override + protected void resetCustomState() { + this.currentBatchIndex = 0; + this.allImoNumbers = null; + } + + @Override + protected String getApiPath() { + return "/RiskAndCompliance/CompliancesByImos"; + } + + private String getTargetTable(){ + return "snp_data.core20"; + } + private String GET_CORE_IMO_LIST = +// "SELECT ihslrorimoshipno FROM " + getTargetTable() + " ORDER BY ihslrorimoshipno"; + "select imo_number as ihslrorimoshipno from snp_data.ship_data order by imo_number"; + @Override + protected void beforeFetch(){ + log.info("[{}] Core20 테이블에서 IMO 번호 조회 시작...", getReaderName()); + + allImoNumbers = jdbcTemplate.queryForList(GET_CORE_IMO_LIST, String.class); + + int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize); + + log.info("[{}] 총 {} 개의 IMO 번호 조회 완료", getReaderName(), allImoNumbers.size()); + log.info("[{}] {}개씩 배치로 분할하여 API 호출 예정", getReaderName(), batchSize); + log.info("[{}] 예상 배치 수: {} 개", getReaderName(), totalBatches); + + updateApiCallStats(totalBatches, 0); + } + + @Override + protected List fetchNextBatch() throws Exception { + // 모든 배치 처리 완료 확인 + if (allImoNumbers == null || currentBatchIndex >= allImoNumbers.size()) { + return null; // Job 종료 + } + + // 현재 배치의 시작/끝 인덱스 계산 + int startIndex = currentBatchIndex; + int endIndex = Math.min(currentBatchIndex + batchSize, allImoNumbers.size()); + + // 현재 배치의 IMO 번호 추출 (100개) + 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()); + + try { + // IMO 번호를 쉼표로 연결 (예: "1000019,1000021,1000033,...") + String imoParam = String.join(",", currentBatch); + + // API 호출 + List response = callAisApiWithBatch(imoParam); + + // 다음 배치로 인덱스 이동 + currentBatchIndex = endIndex; + + // 응답 처리 + if (response != null) { +// List targets = response; + log.info("[{}] 배치 {}/{} 완료: {} 건 조회", + getReaderName(), currentBatchNumber, totalBatches, response.size()); + + // API 호출 통계 업데이트 + updateApiCallStats(totalBatches, currentBatchNumber); + + // API 과부하 방지 (다음 배치 전 0.5초 대기) + if (currentBatchIndex < allImoNumbers.size()) { + Thread.sleep(500); + } + + return response; + } 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(); + } + } + + private List callAisApiWithBatch(String imoNumbers) { + String url = getApiPath() + "?imos=" + imoNumbers; + log.debug("[{}] API 호출: {}", getReaderName(), url); + return webClient.get() + .uri(url) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() {}) + .block(); + } + +} diff --git a/src/main/java/com/snp/batch/jobs/compliance/batch/repository/CompanyComplianceRepository.java b/src/main/java/com/snp/batch/jobs/compliance/batch/repository/CompanyComplianceRepository.java new file mode 100644 index 0000000..351873f --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/compliance/batch/repository/CompanyComplianceRepository.java @@ -0,0 +1,10 @@ +package com.snp.batch.jobs.compliance.batch.repository; + +import com.snp.batch.jobs.compliance.batch.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/compliance/batch/repository/CompanyComplianceRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/compliance/batch/repository/CompanyComplianceRepositoryImpl.java new file mode 100644 index 0000000..13fe748 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/compliance/batch/repository/CompanyComplianceRepositoryImpl.java @@ -0,0 +1,137 @@ +package com.snp.batch.jobs.compliance.batch.repository; + +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.compliance.batch.entity.CompanyComplianceEntity; +import lombok.extern.slf4j.Slf4j; +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{ + public CompanyComplianceRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + protected String getTableName() { + return null; + } + + @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 null; + } + + protected String getUpdateSql(String targetTable, String targetIndex) { + return """ + INSERT INTO new_snp.%s( + owcode, lastupdated, + companyoverallcompliancestatus, companyonaustraliansanctionlist, companyonbessanctionlist, companyoncanadiansanctionlist, companyinofacsanctionedcountry, + companyinfatfjurisdiction, companyoneusanctionlist, companyonofacsanctionlist, companyonofacnonsdnsanctionlist, companyonofacssilist, + companyonswisssanctionlist, companyonuaesanctionlist, companyonunsanctionlist, parentcompanycompliancerisk + )VALUES( + ?, ?::timestamp, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + )ON CONFLICT (%s) + DO UPDATE SET + companyoverallcompliancestatus = EXCLUDED.companyoverallcompliancestatus, + companyonaustraliansanctionlist = EXCLUDED.companyonaustraliansanctionlist, + companyonbessanctionlist = EXCLUDED.companyonbessanctionlist, + companyoncanadiansanctionlist = EXCLUDED.companyoncanadiansanctionlist, + companyinofacsanctionedcountry = EXCLUDED.companyinofacsanctionedcountry, + companyinfatfjurisdiction = EXCLUDED.companyinfatfjurisdiction, + companyoneusanctionlist = EXCLUDED.companyoneusanctionlist, + companyonofacsanctionlist = EXCLUDED.companyonofacsanctionlist, + companyonofacnonsdnsanctionlist = EXCLUDED.companyonofacnonsdnsanctionlist, + companyonofacssilist = EXCLUDED.companyonofacssilist, + companyonswisssanctionlist = EXCLUDED.companyonswisssanctionlist, + companyonuaesanctionlist = EXCLUDED.companyonuaesanctionlist, + companyonunsanctionlist = EXCLUDED.companyonunsanctionlist, + parentcompanycompliancerisk = EXCLUDED.parentcompanycompliancerisk + """.formatted(targetTable, targetIndex); + } + + @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); + } + + @Override + protected String getEntityName() { + return "CompanyComplianceEntity"; + } + + @Override + public void saveCompanyComplianceAll(List items) { + if (items == null || items.isEmpty()) { + return; + } + jdbcTemplate.batchUpdate(getUpdateSql("tb_company_compliance_info", "owcode"), 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 saveCompanyComplianceHistoryAll(List items) { + if (items == null || items.isEmpty()) { + return; + } + jdbcTemplate.batchUpdate(getUpdateSql("tb_company_compliance_hstry", "owcode, lastupdated"), 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/compliance/batch/repository/ComplianceRepository.java b/src/main/java/com/snp/batch/jobs/compliance/batch/repository/ComplianceRepository.java new file mode 100644 index 0000000..7712a22 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/compliance/batch/repository/ComplianceRepository.java @@ -0,0 +1,10 @@ +package com.snp.batch.jobs.compliance.batch.repository; + +import com.snp.batch.jobs.compliance.batch.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/compliance/batch/repository/ComplianceRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/compliance/batch/repository/ComplianceRepositoryImpl.java new file mode 100644 index 0000000..68585c0 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/compliance/batch/repository/ComplianceRepositoryImpl.java @@ -0,0 +1,186 @@ +package com.snp.batch.jobs.compliance.batch.repository; + +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.compliance.batch.entity.ComplianceEntity; +import lombok.extern.slf4j.Slf4j; +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 { + + public ComplianceRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + protected String getTableName() { + return null; + } + + @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 null; + } + + protected String getUpdateSql(String targetTable, String targetIndex) { + return """ + INSERT INTO new_snp.%s ( + lrimoshipno, dateamended, legaloverall, shipbessanctionlist, shipdarkactivityindicator, + shipdetailsnolongermaintained, shipeusanctionlist, shipflagdisputed, shipflagsanctionedcountry, + shiphistoricalflagsanctionedcountry, shipofacnonsdnsanctionlist, shipofacsanctionlist, + shipofacadvisorylist, shipownerofacssilist, shipowneraustraliansanctionlist, shipownerbessanctionlist, + shipownercanadiansanctionlist, shipownereusanctionlist, shipownerfatfjurisdiction, + shipownerhistoricalofacsanctionedcountry, shipownerofacsanctionlist, shipownerofacsanctionedcountry, + shipownerparentcompanynoncompliance, shipownerparentfatfjurisdiction, shipownerparentofacsanctionedcountry, + shipownerswisssanctionlist, shipowneruaesanctionlist, shipownerunsanctionlist, + shipsanctionedcountryportcalllast12m, shipsanctionedcountryportcalllast3m, shipsanctionedcountryportcalllast6m, + shipsecuritylegaldisputeevent, shipstspartnernoncompliancelast12m, shipswisssanctionlist, + shipunsanctionlist + ) + VALUES ( + ?, ?::timestamptz, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ) + ON CONFLICT (%s) + DO UPDATE SET + legaloverall = EXCLUDED.legaloverall, + shipbessanctionlist = EXCLUDED.shipbessanctionlist, + shipdarkactivityindicator = EXCLUDED.shipdarkactivityindicator, + shipdetailsnolongermaintained = EXCLUDED.shipdetailsnolongermaintained, + shipeusanctionlist = EXCLUDED.shipeusanctionlist, + shipflagdisputed = EXCLUDED.shipflagdisputed, + shipflagsanctionedcountry = EXCLUDED.shipflagsanctionedcountry, + shiphistoricalflagsanctionedcountry = EXCLUDED.shiphistoricalflagsanctionedcountry, + shipofacnonsdnsanctionlist = EXCLUDED.shipofacnonsdnsanctionlist, + shipofacsanctionlist = EXCLUDED.shipofacsanctionlist, + shipofacadvisorylist = EXCLUDED.shipofacadvisorylist, + shipownerofacssilist = EXCLUDED.shipownerofacssilist, + shipowneraustraliansanctionlist = EXCLUDED.shipowneraustraliansanctionlist, + shipownerbessanctionlist = EXCLUDED.shipownerbessanctionlist, + shipownercanadiansanctionlist = EXCLUDED.shipownercanadiansanctionlist, + shipownereusanctionlist = EXCLUDED.shipownereusanctionlist, + shipownerfatfjurisdiction = EXCLUDED.shipownerfatfjurisdiction, + shipownerhistoricalofacsanctionedcountry = EXCLUDED.shipownerhistoricalofacsanctionedcountry, + shipownerofacsanctionlist = EXCLUDED.shipownerofacsanctionlist, + shipownerofacsanctionedcountry = EXCLUDED.shipownerofacsanctionedcountry, + shipownerparentcompanynoncompliance = EXCLUDED.shipownerparentcompanynoncompliance, + shipownerparentfatfjurisdiction = EXCLUDED.shipownerparentfatfjurisdiction, + shipownerparentofacsanctionedcountry = EXCLUDED.shipownerparentofacsanctionedcountry, + shipownerswisssanctionlist = EXCLUDED.shipownerswisssanctionlist, + shipowneruaesanctionlist = EXCLUDED.shipowneruaesanctionlist, + shipownerunsanctionlist = EXCLUDED.shipownerunsanctionlist, + shipsanctionedcountryportcalllast12m = EXCLUDED.shipsanctionedcountryportcalllast12m, + shipsanctionedcountryportcalllast3m = EXCLUDED.shipsanctionedcountryportcalllast3m, + shipsanctionedcountryportcalllast6m = EXCLUDED.shipsanctionedcountryportcalllast6m, + shipsecuritylegaldisputeevent = EXCLUDED.shipsecuritylegaldisputeevent, + shipstspartnernoncompliancelast12m = EXCLUDED.shipstspartnernoncompliancelast12m, + shipswisssanctionlist = EXCLUDED.shipswisssanctionlist, + shipunsanctionlist = EXCLUDED.shipunsanctionlist + """.formatted(targetTable, targetIndex); + } + + @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); + } + + @Override + protected String getEntityName() { + return "ComplianceEntity"; + } + + @Override + public void saveComplianceAll(List items) { + if (items == null || items.isEmpty()) { + return; + } + jdbcTemplate.batchUpdate(getUpdateSql("compliance", "lrimoshipno"), 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 saveComplianceHistoryAll(List items) { + if (items == null || items.isEmpty()) { + return; + } + jdbcTemplate.batchUpdate(getUpdateSql("compliance_history", "lrimoshipno, dateamended"), 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/compliance/batch/writer/CompanyComplianceDataWriter.java b/src/main/java/com/snp/batch/jobs/compliance/batch/writer/CompanyComplianceDataWriter.java new file mode 100644 index 0000000..eff49cb --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/compliance/batch/writer/CompanyComplianceDataWriter.java @@ -0,0 +1,25 @@ +package com.snp.batch.jobs.compliance.batch.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.compliance.batch.entity.CompanyComplianceEntity; +import com.snp.batch.jobs.compliance.batch.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/compliance/batch/writer/ComplianceDataWriter.java b/src/main/java/com/snp/batch/jobs/compliance/batch/writer/ComplianceDataWriter.java new file mode 100644 index 0000000..50e6e8f --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/compliance/batch/writer/ComplianceDataWriter.java @@ -0,0 +1,24 @@ +package com.snp.batch.jobs.compliance.batch.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.compliance.batch.entity.ComplianceEntity; +import com.snp.batch.jobs.compliance.batch.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/event/batch/config/EventImportJobConfig.java b/src/main/java/com/snp/batch/jobs/event/batch/config/EventImportJobConfig.java new file mode 100644 index 0000000..5abb174 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/event/batch/config/EventImportJobConfig.java @@ -0,0 +1,146 @@ +package com.snp.batch.jobs.event.batch.config; + +import com.snp.batch.common.batch.config.BaseMultiStepJobConfig; +import com.snp.batch.jobs.event.batch.dto.EventDetailDto; +import com.snp.batch.jobs.event.batch.entity.EventDetailEntity; +import com.snp.batch.jobs.event.batch.processor.EventDataProcessor; +import com.snp.batch.jobs.event.batch.reader.EventDataReader; +import com.snp.batch.jobs.event.batch.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.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; + +@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; + + protected String getApiKey() {return "EVENT_IMPORT_API";} + protected String getBatchUpdateSql() { + return String.format("UPDATE SNP_DATA.BATCH_LAST_EXECUTION SET LAST_SUCCESS_DATE = NOW(), UPDATED_AT = NOW() WHERE API_KEY = '%s'", getApiKey());} + + @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(eventLastExecutionUpdateStep()) + .build(); + } + + @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; + } + + @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 (contribution, chunkContext) -> { + log.info(">>>>> 모든 스텝 성공: BATCH_LAST_EXECUTION 업데이트 시작"); + + jdbcTemplate.execute(getBatchUpdateSql()); + + log.info(">>>>> BATCH_LAST_EXECUTION 업데이트 완료"); + return RepeatStatus.FINISHED; + }; + } + @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/event/batch/dto/CargoDto.java b/src/main/java/com/snp/batch/jobs/event/batch/dto/CargoDto.java new file mode 100644 index 0000000..8a91382 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/event/batch/dto/CargoDto.java @@ -0,0 +1,50 @@ +package com.snp.batch.jobs.event.batch.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.snp.batch.jobs.event.batch.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/event/batch/dto/EventDetailDto.java b/src/main/java/com/snp/batch/jobs/event/batch/dto/EventDetailDto.java new file mode 100644 index 0000000..38a2fd6 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/event/batch/dto/EventDetailDto.java @@ -0,0 +1,109 @@ +package com.snp.batch.jobs.event.batch.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/event/batch/dto/EventDetailResponse.java b/src/main/java/com/snp/batch/jobs/event/batch/dto/EventDetailResponse.java new file mode 100644 index 0000000..fe67261 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/event/batch/dto/EventDetailResponse.java @@ -0,0 +1,18 @@ +package com.snp.batch.jobs.event.batch.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/event/batch/dto/EventDto.java b/src/main/java/com/snp/batch/jobs/event/batch/dto/EventDto.java new file mode 100644 index 0000000..00d2e07 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/event/batch/dto/EventDto.java @@ -0,0 +1,51 @@ +package com.snp.batch.jobs.event.batch.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/event/batch/dto/EventPeriod.java b/src/main/java/com/snp/batch/jobs/event/batch/dto/EventPeriod.java new file mode 100644 index 0000000..05e4703 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/event/batch/dto/EventPeriod.java @@ -0,0 +1,12 @@ +package com.snp.batch.jobs.event.batch.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/event/batch/dto/EventResponse.java b/src/main/java/com/snp/batch/jobs/event/batch/dto/EventResponse.java new file mode 100644 index 0000000..7c9118c --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/event/batch/dto/EventResponse.java @@ -0,0 +1,21 @@ +package com.snp.batch.jobs.event.batch.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/event/batch/dto/HumanCasualtyDto.java b/src/main/java/com/snp/batch/jobs/event/batch/dto/HumanCasualtyDto.java new file mode 100644 index 0000000..09ed21c --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/event/batch/dto/HumanCasualtyDto.java @@ -0,0 +1,35 @@ +package com.snp.batch.jobs.event.batch.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.snp.batch.jobs.event.batch.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/event/batch/dto/RelationshipDto.java b/src/main/java/com/snp/batch/jobs/event/batch/dto/RelationshipDto.java new file mode 100644 index 0000000..1d1beae --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/event/batch/dto/RelationshipDto.java @@ -0,0 +1,41 @@ +package com.snp.batch.jobs.event.batch.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.snp.batch.jobs.event.batch.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/event/batch/entity/CargoEntity.java b/src/main/java/com/snp/batch/jobs/event/batch/entity/CargoEntity.java new file mode 100644 index 0000000..8e2e95d --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/event/batch/entity/CargoEntity.java @@ -0,0 +1,26 @@ +package com.snp.batch.jobs.event.batch.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/event/batch/entity/EventDetailEntity.java b/src/main/java/com/snp/batch/jobs/event/batch/entity/EventDetailEntity.java new file mode 100644 index 0000000..58b4bda --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/event/batch/entity/EventDetailEntity.java @@ -0,0 +1,67 @@ +package com.snp.batch.jobs.event.batch.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/event/batch/entity/EventEntity.java b/src/main/java/com/snp/batch/jobs/event/batch/entity/EventEntity.java new file mode 100644 index 0000000..6a55161 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/event/batch/entity/EventEntity.java @@ -0,0 +1,30 @@ +package com.snp.batch.jobs.event.batch.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/event/batch/entity/HumanCasualtyEntity.java b/src/main/java/com/snp/batch/jobs/event/batch/entity/HumanCasualtyEntity.java new file mode 100644 index 0000000..d52bd9e --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/event/batch/entity/HumanCasualtyEntity.java @@ -0,0 +1,21 @@ +package com.snp.batch.jobs.event.batch.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/event/batch/entity/RelationshipEntity.java b/src/main/java/com/snp/batch/jobs/event/batch/entity/RelationshipEntity.java new file mode 100644 index 0000000..e3c5ea7 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/event/batch/entity/RelationshipEntity.java @@ -0,0 +1,23 @@ +package com.snp.batch.jobs.event.batch.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/event/batch/processor/EventDataProcessor.java b/src/main/java/com/snp/batch/jobs/event/batch/processor/EventDataProcessor.java new file mode 100644 index 0000000..74dd115 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/event/batch/processor/EventDataProcessor.java @@ -0,0 +1,76 @@ +package com.snp.batch.jobs.event.batch.processor; + +import com.snp.batch.common.batch.processor.BaseProcessor; +import com.snp.batch.jobs.event.batch.dto.CargoDto; +import com.snp.batch.jobs.event.batch.dto.EventDetailDto; +import com.snp.batch.jobs.event.batch.dto.HumanCasualtyDto; +import com.snp.batch.jobs.event.batch.dto.RelationshipDto; +import com.snp.batch.jobs.event.batch.entity.EventDetailEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.stream.Collectors; + +@Slf4j +@Component +public class EventDataProcessor extends BaseProcessor { + @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()) + .cargoes(dto.getCargoes() != null ? + dto.getCargoes().stream().map(CargoDto::toEntity).collect(Collectors.toList()) : null) + .humanCasualties(dto.getHumanCasualties() != null ? + dto.getHumanCasualties().stream().map(HumanCasualtyDto::toEntity).collect(Collectors.toList()) : null) + .relationships(dto.getRelationships() != null ? + dto.getRelationships().stream().map(RelationshipDto::toEntity).collect(Collectors.toList()) : null) + .build(); + + log.debug("Event 데이터 처리 완료: Event ID = {}", dto.getEventID()); + + return entity; + } +} diff --git a/src/main/java/com/snp/batch/jobs/event/batch/reader/EventDataReader.java b/src/main/java/com/snp/batch/jobs/event/batch/reader/EventDataReader.java new file mode 100644 index 0000000..602c2b5 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/event/batch/reader/EventDataReader.java @@ -0,0 +1,239 @@ +package com.snp.batch.jobs.event.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.event.batch.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/event/batch/repository/EventRepository.java b/src/main/java/com/snp/batch/jobs/event/batch/repository/EventRepository.java new file mode 100644 index 0000000..da65ba6 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/event/batch/repository/EventRepository.java @@ -0,0 +1,15 @@ +package com.snp.batch.jobs.event.batch.repository; + +import com.snp.batch.jobs.event.batch.entity.CargoEntity; +import com.snp.batch.jobs.event.batch.entity.EventDetailEntity; +import com.snp.batch.jobs.event.batch.entity.HumanCasualtyEntity; +import com.snp.batch.jobs.event.batch.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/event/batch/repository/EventRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/event/batch/repository/EventRepositoryImpl.java new file mode 100644 index 0000000..813e14a --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/event/batch/repository/EventRepositoryImpl.java @@ -0,0 +1,236 @@ +package com.snp.batch.jobs.event.batch.repository; + +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.event.batch.entity.CargoEntity; +import com.snp.batch.jobs.event.batch.entity.EventDetailEntity; +import com.snp.batch.jobs.event.batch.entity.HumanCasualtyEntity; +import com.snp.batch.jobs.event.batch.entity.RelationshipEntity; +import com.snp.batch.jobs.shipdetail.batch.entity.GroupBeneficialOwnerHistoryEntity; +import com.snp.batch.jobs.shipdetail.batch.repository.ShipDetailSql; +import lombok.extern.slf4j.Slf4j; +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 { + + public EventRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + protected String getTableName() { + return null; + } + + @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 + } + 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()); + } + 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()); + } + 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()); + } + + 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/event/batch/repository/EventSql.java b/src/main/java/com/snp/batch/jobs/event/batch/repository/EventSql.java new file mode 100644 index 0000000..0b6d1a7 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/event/batch/repository/EventSql.java @@ -0,0 +1,122 @@ +package com.snp.batch.jobs.event.batch.repository; + +public class EventSql { + public static String getEventDetailUpdateSql(){ + return """ + INSERT INTO new_snp.event ( + event_id, incident_id, ihslrorimoshipno, published_date, event_start_date, event_end_date, + attempted_boarding, cargo_loading_status_code, casualty_action, + casualty_zone, casualty_zone_code, component2, country_code, + date_of_build, description, environment_location, location_name, + marsden_grid_reference, town_name, event_type, event_type_detail, + event_type_detail_id, event_type_id, fired_upon, headline, + ldt_at_time, significance, weather, pollutant, pollutant_quantity, + pollutant_unit, registered_owner_code_at_time, registered_owner_at_time, + registered_owner_country_code_at_time, registered_owner_country_at_time, + vessel_dwt, vessel_flag_code, vessel_flag_decode, vessel_gt, + vessel_name, vessel_type, vessel_type_decode + ) + VALUES ( + ?, ?, ?, ?::timestamptz,?::timestamptz,?::timestamptz, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ) + ON CONFLICT (event_id) + DO UPDATE SET + incident_id = EXCLUDED.incident_id, + ihslrorimoshipno = EXCLUDED.ihslrorimoshipno, + published_date = EXCLUDED.published_date, + event_start_date = EXCLUDED.event_start_date, + event_end_date = EXCLUDED.event_end_date, + attempted_boarding = EXCLUDED.attempted_boarding, + cargo_loading_status_code = EXCLUDED.cargo_loading_status_code, + casualty_action = EXCLUDED.casualty_action, + casualty_zone = EXCLUDED.casualty_zone, + casualty_zone_code = EXCLUDED.casualty_zone_code, + component2 = EXCLUDED.component2, + country_code = EXCLUDED.country_code, + date_of_build = EXCLUDED.date_of_build, + description = EXCLUDED.description, + environment_location = EXCLUDED.environment_location, + location_name = EXCLUDED.location_name, + marsden_grid_reference = EXCLUDED.marsden_grid_reference, + town_name = EXCLUDED.town_name, + event_type = EXCLUDED.event_type, + event_type_detail = EXCLUDED.event_type_detail, + event_type_detail_id = EXCLUDED.event_type_detail_id, + event_type_id = EXCLUDED.event_type_id, + fired_upon = EXCLUDED.fired_upon, + headline = EXCLUDED.headline, + ldt_at_time = EXCLUDED.ldt_at_time, + significance = EXCLUDED.significance, + weather = EXCLUDED.weather, + pollutant = EXCLUDED.pollutant, + pollutant_quantity = EXCLUDED.pollutant_quantity, + pollutant_unit = EXCLUDED.pollutant_unit, + registered_owner_code_at_time = EXCLUDED.registered_owner_code_at_time, + registered_owner_at_time = EXCLUDED.registered_owner_at_time, + registered_owner_country_code_at_time = EXCLUDED.registered_owner_country_code_at_time, + registered_owner_country_at_time = EXCLUDED.registered_owner_country_at_time, + vessel_dwt = EXCLUDED.vessel_dwt, + vessel_flag_code = EXCLUDED.vessel_flag_code, + vessel_flag_decode = EXCLUDED.vessel_flag_decode, + vessel_gt = EXCLUDED.vessel_gt, + vessel_name = EXCLUDED.vessel_name, + vessel_type = EXCLUDED.vessel_type, + vessel_type_decode = EXCLUDED.vessel_type_decode + """; + } + + public static String getEventCargoSql(){ + return """ + INSERT INTO new_snp.event_cargo ( + event_id, "sequence", ihslrorimoshipno, "type", quantity, + unit_short, unit, cargo_damage, dangerous, "text" + ) + VALUES ( + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ? + ) + ON CONFLICT (event_id, ihslrorimoshipno, "type", "sequence") + DO UPDATE SET + quantity = EXCLUDED.quantity, + unit_short = EXCLUDED.unit_short, + unit = EXCLUDED.unit, + cargo_damage = EXCLUDED.cargo_damage, + dangerous = EXCLUDED.dangerous, + "text" = EXCLUDED."text" + """; + } + + public static String getEventRelationshipSql(){ + return """ + INSERT INTO new_snp.event_relationship ( + incident_id, event_id, relationship_type, relationship_type_code, + event_id_2, event_type, event_type_code + ) + VALUES ( + ?, ?, ?, ?, + ?, ?, ? + ) + ON CONFLICT (incident_id, event_id, event_id_2, event_type_code, relationship_type_code) + DO UPDATE SET + relationship_type = EXCLUDED.relationship_type, + event_type = EXCLUDED.event_type + """; + } + + public static String getEventHumanCasualtySql(){ + return """ + INSERT INTO new_snp.event_humancasualty ( + event_id, "scope", "type", qualifier, "count" + ) + VALUES ( + ?, ?, ?, ?, ? + ) + ON CONFLICT (event_id, "scope", "type", qualifier) + DO UPDATE SET + "count" = EXCLUDED."count" + """; + } +} diff --git a/src/main/java/com/snp/batch/jobs/event/batch/writer/EventDataWriter.java b/src/main/java/com/snp/batch/jobs/event/batch/writer/EventDataWriter.java new file mode 100644 index 0000000..7185f9c --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/event/batch/writer/EventDataWriter.java @@ -0,0 +1,48 @@ +package com.snp.batch.jobs.event.batch.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.event.batch.entity.EventDetailEntity; +import com.snp.batch.jobs.event.batch.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/facility/batch/config/PortImportJobConfig.java b/src/main/java/com/snp/batch/jobs/facility/batch/config/PortImportJobConfig.java new file mode 100644 index 0000000..bcbc2f6 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/facility/batch/config/PortImportJobConfig.java @@ -0,0 +1,84 @@ +package com.snp.batch.jobs.facility.batch.config; + +import com.snp.batch.common.batch.config.BaseJobConfig; +import com.snp.batch.jobs.facility.batch.dto.PortDto; +import com.snp.batch.jobs.facility.batch.entity.PortEntity; +import com.snp.batch.jobs.facility.batch.processor.PortDataProcessor; +import com.snp.batch.jobs.facility.batch.reader.PortDataReader; +import com.snp.batch.jobs.facility.batch.writer.PortDataWriter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +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.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 PortDataProcessor portDataProcessor; + + private final PortDataWriter portDataWriter; + + @Override + protected int getChunkSize() { + return 5000; // API에서 5000개씩 가져오므로 chunk도 5000으로 설정 + } + public PortImportJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + PortDataProcessor portDataProcessor, + PortDataWriter portDataWriter, + JdbcTemplate jdbcTemplate, + @Qualifier("maritimeServiceApiWebClient")WebClient maritimeServiceApiWebClient) { + super(jobRepository, transactionManager); + this.jdbcTemplate = jdbcTemplate; + this.maritimeServiceApiWebClient = maritimeServiceApiWebClient; + this.portDataProcessor = portDataProcessor; + this.portDataWriter = portDataWriter; + } + + @Override + protected String getJobName() { + return "portImportJob"; + } + + @Override + protected String getStepName() { + return "portImportStep"; + } + + @Override + protected ItemReader createReader() { + return new PortDataReader(maritimeServiceApiWebClient, jdbcTemplate); + } + + @Override + protected ItemProcessor createProcessor() { + return portDataProcessor; + } + + @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/facility/batch/dto/PortDto.java b/src/main/java/com/snp/batch/jobs/facility/batch/dto/PortDto.java new file mode 100644 index 0000000..f324028 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/facility/batch/dto/PortDto.java @@ -0,0 +1,172 @@ +package com.snp.batch.jobs.facility.batch.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/facility/batch/dto/PortResponse.java b/src/main/java/com/snp/batch/jobs/facility/batch/dto/PortResponse.java new file mode 100644 index 0000000..99918db --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/facility/batch/dto/PortResponse.java @@ -0,0 +1,16 @@ +package com.snp.batch.jobs.facility.batch.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/facility/batch/entity/PortEntity.java b/src/main/java/com/snp/batch/jobs/facility/batch/entity/PortEntity.java new file mode 100644 index 0000000..b4b7875 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/facility/batch/entity/PortEntity.java @@ -0,0 +1,78 @@ +package com.snp.batch.jobs.facility.batch.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/facility/batch/processor/PortDataProcessor.java b/src/main/java/com/snp/batch/jobs/facility/batch/processor/PortDataProcessor.java new file mode 100644 index 0000000..2e0ce64 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/facility/batch/processor/PortDataProcessor.java @@ -0,0 +1,80 @@ +package com.snp.batch.jobs.facility.batch.processor; + +import com.snp.batch.common.batch.processor.BaseProcessor; +import com.snp.batch.jobs.facility.batch.dto.PortDto; +import com.snp.batch.jobs.facility.batch.entity.PortEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class PortDataProcessor extends BaseProcessor { + @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()) + .build(); + + log.debug("Port 데이터 처리 완료: Port ID = {}", dto.getPortId()); + + return entity; + } +} diff --git a/src/main/java/com/snp/batch/jobs/facility/batch/reader/PortDataReader.java b/src/main/java/com/snp/batch/jobs/facility/batch/reader/PortDataReader.java new file mode 100644 index 0000000..42b7dae --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/facility/batch/reader/PortDataReader.java @@ -0,0 +1,69 @@ +package com.snp.batch.jobs.facility.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.facility.batch.dto.PortDto; +import com.snp.batch.jobs.shipimport.batch.dto.ShipApiResponse; +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 = 100; + + public PortDataReader(WebClient webClient, JdbcTemplate jdbcTemplate) { + super(webClient); + this.jdbcTemplate = jdbcTemplate; + } + + @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() { + String url = getApiPath(); + log.debug("[{}] API 호출: {}", getReaderName(), url); + return webClient.get() + .uri(url) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() {}) + .block(); + } + +} diff --git a/src/main/java/com/snp/batch/jobs/facility/batch/repository/FacilityRepository.java b/src/main/java/com/snp/batch/jobs/facility/batch/repository/FacilityRepository.java new file mode 100644 index 0000000..358b068 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/facility/batch/repository/FacilityRepository.java @@ -0,0 +1,9 @@ +package com.snp.batch.jobs.facility.batch.repository; + +import com.snp.batch.jobs.facility.batch.entity.PortEntity; + +import java.util.List; + +public interface FacilityRepository { + void savePortAll(List items); +} diff --git a/src/main/java/com/snp/batch/jobs/facility/batch/repository/FacilityRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/facility/batch/repository/FacilityRepositoryImpl.java new file mode 100644 index 0000000..1ef9e0c --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/facility/batch/repository/FacilityRepositoryImpl.java @@ -0,0 +1,237 @@ +package com.snp.batch.jobs.facility.batch.repository; + +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.facility.batch.entity.PortEntity; +import lombok.extern.slf4j.Slf4j; +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 { + + public FacilityRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + protected String getTableName() { + return null; + } + + @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 snp_data.facility_port ( + port_ID, old_ID, status, port_Name, unlocode, countryCode, country_Name, region_Name, continent_Name, master_POID, + dec_Lat, dec_Long, position_lat, position_long, position_z, position_m, position_hasZ, position_hasM, position_isNull, position_stSrid, time_Zone, dayLight_Saving_Time, + maximum_Draft, max_LOA, max_Beam, max_DWT, max_Offshore_Draught, max_Offshore_LOA, max_Offshore_BCM, max_Offshore_DWT, + breakbulk_Facilities, container_Facilities, dry_Bulk_Facilities, liquid_Facilities, roRo_Facilities, passenger_Facilities, dry_Dock_Facilities, + lpG_Facilities, lnG_Facilities, lnG_Bunker, dO_Bunker, fO_Bunker, ispS_Compliant, csI_Compliant, free_Trade_Zone, ecO_Port, emission_Control_Area, wS_Port, + last_Update, entry_Date, batch_flag + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?::timestamptz, ?::timestamptz, 'N' + ) ON CONFLICT (port_ID) DO UPDATE + SET + old_ID = EXCLUDED.old_ID, + status = EXCLUDED.status, + port_Name = EXCLUDED.port_Name, + unlocode = EXCLUDED.unlocode, + countryCode = EXCLUDED.countryCode, + country_Name = EXCLUDED.country_Name, + region_Name = EXCLUDED.region_Name, + continent_Name = EXCLUDED.continent_Name, + master_POID = EXCLUDED.master_POID, + dec_Lat = EXCLUDED.dec_Lat, + dec_Long = EXCLUDED.dec_Long, + position_lat = EXCLUDED.position_lat, + position_long = EXCLUDED.position_long, + position_z = EXCLUDED.position_z, + position_m = EXCLUDED.position_m, + position_hasZ = EXCLUDED.position_hasZ, + position_hasM = EXCLUDED.position_hasM, + position_isNull = EXCLUDED.position_isNull, + position_stSrid = EXCLUDED.position_stSrid, + time_Zone = EXCLUDED.time_Zone, + dayLight_Saving_Time = EXCLUDED.dayLight_Saving_Time, + maximum_Draft = EXCLUDED.maximum_Draft, + max_LOA = EXCLUDED.max_LOA, + max_Beam = EXCLUDED.max_Beam, + max_DWT = EXCLUDED.max_DWT, + max_Offshore_Draught = EXCLUDED.max_Offshore_Draught, + max_Offshore_LOA = EXCLUDED.max_Offshore_LOA, + max_Offshore_BCM = EXCLUDED.max_Offshore_BCM, + max_Offshore_DWT = EXCLUDED.max_Offshore_DWT, + breakbulk_Facilities = EXCLUDED.breakbulk_Facilities, + container_Facilities = EXCLUDED.container_Facilities, + dry_Bulk_Facilities = EXCLUDED.dry_Bulk_Facilities, + liquid_Facilities = EXCLUDED.liquid_Facilities, + roRo_Facilities = EXCLUDED.roRo_Facilities, + passenger_Facilities = EXCLUDED.passenger_Facilities, + dry_Dock_Facilities = EXCLUDED.dry_Dock_Facilities, + lpG_Facilities = EXCLUDED.lpG_Facilities, + lnG_Facilities = EXCLUDED.lnG_Facilities, + lnG_Bunker = EXCLUDED.lnG_Bunker, + dO_Bunker = EXCLUDED.dO_Bunker, + fO_Bunker = EXCLUDED.fO_Bunker, + ispS_Compliant = EXCLUDED.ispS_Compliant, + csI_Compliant = EXCLUDED.csI_Compliant, + free_Trade_Zone = EXCLUDED.free_Trade_Zone, + ecO_Port = EXCLUDED.ecO_Port, + emission_Control_Area = EXCLUDED.emission_Control_Area, + wS_Port = EXCLUDED.wS_Port, + last_Update = EXCLUDED.last_Update, + entry_Date = EXCLUDED.entry_Date, + batch_flag = 'N' + """; + } + + @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 표준에 적합합니다. + } + + @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/facility/batch/writer/PortDataWriter.java b/src/main/java/com/snp/batch/jobs/facility/batch/writer/PortDataWriter.java new file mode 100644 index 0000000..2ddafcd --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/facility/batch/writer/PortDataWriter.java @@ -0,0 +1,26 @@ +package com.snp.batch.jobs.facility.batch.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.facility.batch.entity.PortEntity; +import com.snp.batch.jobs.facility.batch.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/movement/batch/config/AnchorageCallsJobConfig.java b/src/main/java/com/snp/batch/jobs/movement/batch/config/AnchorageCallsJobConfig.java new file mode 100644 index 0000000..5ee929c --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/config/AnchorageCallsJobConfig.java @@ -0,0 +1,104 @@ +package com.snp.batch.jobs.movement.batch.config; + +import com.snp.batch.common.batch.config.BaseJobConfig; +import com.snp.batch.jobs.movement.batch.entity.AnchorageCallsEntity; +import com.snp.batch.jobs.movement.batch.processor.AnchorageCallsProcessor; +import com.snp.batch.jobs.movement.batch.reader.AnchorageCallsReader; +import com.snp.batch.jobs.movement.batch.writer.AnchorageCallsWriter; +import com.snp.batch.jobs.movement.batch.dto.AnchorageCallsDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +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.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 Job Config + * + * 특징: + * - ship_data 테이블에서 IMO 번호 조회 + * - IMO 번호를 100개씩 배치로 분할 + * - Maritime API GetShipsByIHSLRorIMONumbers 호출 + * TODO : GetShipsByIHSLRorIMONumbersAll 호출로 변경 + * - 선박 상세 정보를 ship_detail 테이블에 저장 (UPSERT) + * + * 데이터 흐름: + * AnchorageCallsReader (ship_data → Maritime API) + * ↓ (AnchorageCallsDto) + * AnchorageCallsProcessor + * ↓ (AnchorageCallsEntity) + * AnchorageCallsWriter + * ↓ (t_anchoragecall 테이블) + */ + +@Slf4j +@Configuration +public class AnchorageCallsJobConfig extends BaseJobConfig { + + private final AnchorageCallsProcessor anchorageCallsProcessor; + private final AnchorageCallsWriter anchorageCallsWriter; + private final JdbcTemplate jdbcTemplate; + private final WebClient maritimeApiWebClient; + + public AnchorageCallsJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + AnchorageCallsProcessor anchorageCallsProcessor, + AnchorageCallsWriter anchorageCallsWriter, JdbcTemplate jdbcTemplate, + @Qualifier("maritimeServiceApiWebClient") WebClient maritimeApiWebClient + ) { // ObjectMapper 주입 추가 + super(jobRepository, transactionManager); + this.anchorageCallsProcessor = anchorageCallsProcessor; + this.anchorageCallsWriter = anchorageCallsWriter; + this.jdbcTemplate = jdbcTemplate; + this.maritimeApiWebClient = maritimeApiWebClient; + } + + @Override + protected String getJobName() { + return "AnchorageCallsImportJob"; + } + + @Override + protected String getStepName() { + return "AnchorageCallsImportStep"; + } + + @Override + protected ItemReader createReader() { // 타입 변경 + return new AnchorageCallsReader(maritimeApiWebClient, jdbcTemplate); + } + + @Override + protected ItemProcessor createProcessor() { + return anchorageCallsProcessor; + } + + @Override + protected ItemWriter createWriter() { // 타입 변경 + return anchorageCallsWriter; + } + + @Override + protected int getChunkSize() { + return 50; // API에서 100개씩 가져오므로 chunk도 100으로 설정 + } + + @Bean(name = "AnchorageCallsImportJob") + public Job anchorageCallsImportJob() { + return job(); + } + + @Bean(name = "AnchorageCallsImportStep") + public Step anchorageCallsImportStep() { + return step(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/config/AnchorageCallsRangeJobConfig.java b/src/main/java/com/snp/batch/jobs/movement/batch/config/AnchorageCallsRangeJobConfig.java new file mode 100644 index 0000000..603c617 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/config/AnchorageCallsRangeJobConfig.java @@ -0,0 +1,150 @@ +package com.snp.batch.jobs.movement.batch.config; + +import com.snp.batch.common.batch.config.BaseMultiStepJobConfig; +import com.snp.batch.jobs.movement.batch.dto.AnchorageCallsDto; +import com.snp.batch.jobs.movement.batch.entity.AnchorageCallsEntity; +import com.snp.batch.jobs.movement.batch.processor.AnchorageCallsProcessor; +import com.snp.batch.jobs.movement.batch.reader.AnchorageCallsRangeReader; +import com.snp.batch.jobs.movement.batch.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.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; + +@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; + + @Value("${app.batch.webservice-api.url}") + private String maritimeServiceApiUrl; + protected String getApiKey() {return "ANCHORAGE_CALLS_IMPORT_API";} + protected String getBatchUpdateSql() { + return String.format("UPDATE SNP_DATA.BATCH_LAST_EXECUTION SET LAST_SUCCESS_DATE = NOW(), UPDATED_AT = NOW() WHERE API_KEY = '%s'", getApiKey());} + + + public AnchorageCallsRangeJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + AnchorageCallsProcessor anchorageCallsProcessor, + AnchorageCallsWriter anchorageCallsWriter, + AnchorageCallsRangeReader anchorageCallsRangeReader, + @Qualifier("maritimeServiceApiWebClient")WebClient maritimeServiceApiWebClient, + JdbcTemplate jdbcTemplate, + BatchDateService batchDateService, + BatchApiLogService batchApiLogService + ) { + super(jobRepository, transactionManager); + this.anchorageCallsProcessor = anchorageCallsProcessor; + this.anchorageCallsWriter = anchorageCallsWriter; + this.anchorageCallsRangeReader = anchorageCallsRangeReader; + this.maritimeServiceApiWebClient = maritimeServiceApiWebClient; + this.jdbcTemplate = jdbcTemplate; + this.batchDateService = batchDateService; + this.batchApiLogService = batchApiLogService; + } + + @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; + } + + @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 (contribution, chunkContext) -> { + log.info(">>>>> 모든 스텝 성공: BATCH_LAST_EXECUTION 업데이트 시작"); + + jdbcTemplate.execute(getBatchUpdateSql()); + + log.info(">>>>> BATCH_LAST_EXECUTION 업데이트 완료"); + return RepeatStatus.FINISHED; + }; + } + @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/movement/batch/config/BerthCallsJobConfig.java b/src/main/java/com/snp/batch/jobs/movement/batch/config/BerthCallsJobConfig.java new file mode 100644 index 0000000..5f3efe2 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/config/BerthCallsJobConfig.java @@ -0,0 +1,107 @@ +package com.snp.batch.jobs.movement.batch.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.config.BaseJobConfig; +import com.snp.batch.jobs.movement.batch.entity.BerthCallsEntity; +import com.snp.batch.jobs.movement.batch.processor.BerthCallsProcessor; +import com.snp.batch.jobs.movement.batch.writer.BerthCallsWriter; +import com.snp.batch.jobs.movement.batch.dto.BerthCallsDto; +import com.snp.batch.jobs.movement.batch.reader.BerthCallsReader; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +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.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 Job Config + * + * 특징: + * - ship_data 테이블에서 IMO 번호 조회 + * - IMO 번호를 100개씩 배치로 분할 + * - Maritime API GetShipsByIHSLRorIMONumbers 호출 + * TODO : GetShipsByIHSLRorIMONumbersAll 호출로 변경 + * - 선박 상세 정보를 ship_detail 테이블에 저장 (UPSERT) + * + * 데이터 흐름: + * ShipMovementReader (ship_data → Maritime API) + * ↓ (PortCallDto) + * ShipMovementProcessor + * ↓ (ShipMovementEntity) + * ShipDetailDataWriter + * ↓ (ship_movement 테이블) + */ + +@Slf4j +@Configuration +public class BerthCallsJobConfig extends BaseJobConfig { + + private final BerthCallsProcessor berthCallsProcessor; + private final BerthCallsWriter berthCallsWriter; + private final JdbcTemplate jdbcTemplate; + private final WebClient maritimeApiWebClient; + private final ObjectMapper objectMapper; // ObjectMapper 주입 추가 + + public BerthCallsJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + BerthCallsProcessor berthCallsProcessor, + BerthCallsWriter berthCallsWriter, JdbcTemplate jdbcTemplate, + @Qualifier("maritimeServiceApiWebClient") WebClient maritimeApiWebClient, + ObjectMapper objectMapper) { // ObjectMapper 주입 추가 + super(jobRepository, transactionManager); + this.berthCallsProcessor = berthCallsProcessor; + this.berthCallsWriter = berthCallsWriter; + this.jdbcTemplate = jdbcTemplate; + this.maritimeApiWebClient = maritimeApiWebClient; + this.objectMapper = objectMapper; // ObjectMapper 초기화 + } + + @Override + protected String getJobName() { + return "BerthCallsImportJob"; + } + + @Override + protected String getStepName() { + return "BerthCallsImportStep"; + } + + @Override + protected ItemReader createReader() { // 타입 변경 + return new BerthCallsReader(maritimeApiWebClient, jdbcTemplate); + } + + @Override + protected ItemProcessor createProcessor() { + return berthCallsProcessor; + } + + @Override + protected ItemWriter createWriter() { // 타입 변경 + return berthCallsWriter; + } + + @Override + protected int getChunkSize() { + return 200; // API에서 100개씩 가져오므로 chunk도 100으로 설정 + } + + @Bean(name = "BerthCallsImportJob") + public Job berthCallsImportJob() { + return job(); + } + + @Bean(name = "BerthCallsImportStep") + public Step berthCallsImportStep() { + return step(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/config/BerthCallsRangJobConfig.java b/src/main/java/com/snp/batch/jobs/movement/batch/config/BerthCallsRangJobConfig.java new file mode 100644 index 0000000..0a17a5e --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/config/BerthCallsRangJobConfig.java @@ -0,0 +1,146 @@ +package com.snp.batch.jobs.movement.batch.config; + +import com.snp.batch.common.batch.config.BaseMultiStepJobConfig; +import com.snp.batch.jobs.movement.batch.dto.BerthCallsDto; +import com.snp.batch.jobs.movement.batch.entity.BerthCallsEntity; +import com.snp.batch.jobs.movement.batch.processor.BerthCallsProcessor; +import com.snp.batch.jobs.movement.batch.reader.BerthCallsRangeReader; +import com.snp.batch.jobs.movement.batch.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.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; + +@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; + protected String getApiKey() {return "BERTH_CALLS_IMPORT_API";} + protected String getBatchUpdateSql() { + return String.format("UPDATE SNP_DATA.BATCH_LAST_EXECUTION SET LAST_SUCCESS_DATE = NOW(), UPDATED_AT = NOW() WHERE API_KEY = '%s'", getApiKey());} + + 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; + } + + @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 (contribution, chunkContext) -> { + log.info(">>>>> 모든 스텝 성공: BATCH_LAST_EXECUTION 업데이트 시작"); + + jdbcTemplate.execute(getBatchUpdateSql()); + + log.info(">>>>> BATCH_LAST_EXECUTION 업데이트 완료"); + return RepeatStatus.FINISHED; + }; + } + @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/movement/batch/config/CurrentlyAtJobConfig.java b/src/main/java/com/snp/batch/jobs/movement/batch/config/CurrentlyAtJobConfig.java new file mode 100644 index 0000000..4f5acb2 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/config/CurrentlyAtJobConfig.java @@ -0,0 +1,103 @@ +package com.snp.batch.jobs.movement.batch.config; + +import com.snp.batch.common.batch.config.BaseJobConfig; +import com.snp.batch.jobs.movement.batch.processor.CurrentlyAtProcessor; +import com.snp.batch.jobs.movement.batch.reader.CurrentlyAtReader; +import com.snp.batch.jobs.movement.batch.writer.CurrentlyAtWriter; +import com.snp.batch.jobs.movement.batch.dto.CurrentlyAtDto; +import com.snp.batch.jobs.movement.batch.entity.CurrentlyAtEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +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.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 Job Config + * + * 특징: + * - ship_data 테이블에서 IMO 번호 조회 + * - IMO 번호를 100개씩 배치로 분할 + * - Maritime API GetShipsByIHSLRorIMONumbers 호출 + * TODO : GetShipsByIHSLRorIMONumbersAll 호출로 변경 + * - 선박 상세 정보를 ship_detail 테이블에 저장 (UPSERT) + * + * 데이터 흐름: + * CurrentlyAtReader (ship_data → Maritime API) + * ↓ (CurrentlyAtDto) + * CurrentlyAtProcessor + * ↓ (CurrentlyAtEntity) + * CurrentlyAtWriter + * ↓ (currentlyat 테이블) + */ + +@Slf4j +@Configuration +public class CurrentlyAtJobConfig extends BaseJobConfig { + + private final CurrentlyAtProcessor currentlyAtProcessor; + private final CurrentlyAtWriter currentlyAtWriter; + private final JdbcTemplate jdbcTemplate; + private final WebClient maritimeApiWebClient; + + public CurrentlyAtJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + CurrentlyAtProcessor currentlyAtProcessor, + CurrentlyAtWriter currentlyAtWriter, JdbcTemplate jdbcTemplate, + @Qualifier("maritimeServiceApiWebClient") WebClient maritimeApiWebClient) { // ObjectMapper 주입 추가 + super(jobRepository, transactionManager); + this.currentlyAtProcessor = currentlyAtProcessor; + this.currentlyAtWriter = currentlyAtWriter; + this.jdbcTemplate = jdbcTemplate; + this.maritimeApiWebClient = maritimeApiWebClient; + } + + @Override + protected String getJobName() { + return "CurrentlyAtImportJob"; + } + + @Override + protected String getStepName() { + return "CurrentlyAtImportStep"; + } + + @Override + protected ItemReader createReader() { // 타입 변경 + return new CurrentlyAtReader(maritimeApiWebClient, jdbcTemplate); + } + + @Override + protected ItemProcessor createProcessor() { + return currentlyAtProcessor; + } + + @Override + protected ItemWriter createWriter() { // 타입 변경 + return currentlyAtWriter; + } + + @Override + protected int getChunkSize() { + return 50; // API에서 100개씩 가져오므로 chunk도 100으로 설정 + } + + @Bean(name = "CurrentlyAtImportJob") + public Job currentlyAtImportJob() { + return job(); + } + + @Bean(name = "CurrentlyAtImportStep") + public Step currentlyAtImportStep() { + return step(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/config/CurrentlyAtRangeJobConfig.java b/src/main/java/com/snp/batch/jobs/movement/batch/config/CurrentlyAtRangeJobConfig.java new file mode 100644 index 0000000..82c2549 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/config/CurrentlyAtRangeJobConfig.java @@ -0,0 +1,146 @@ +package com.snp.batch.jobs.movement.batch.config; + +import com.snp.batch.common.batch.config.BaseMultiStepJobConfig; +import com.snp.batch.jobs.movement.batch.dto.CurrentlyAtDto; +import com.snp.batch.jobs.movement.batch.entity.CurrentlyAtEntity; +import com.snp.batch.jobs.movement.batch.processor.CurrentlyAtProcessor; +import com.snp.batch.jobs.movement.batch.reader.CurrentlyAtRangeReader; +import com.snp.batch.jobs.movement.batch.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.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; + +@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; + protected String getApiKey() {return "CURRENTLY_AT_IMPORT_API";} + protected String getBatchUpdateSql() { + return String.format("UPDATE SNP_DATA.BATCH_LAST_EXECUTION SET LAST_SUCCESS_DATE = NOW(), UPDATED_AT = NOW() WHERE API_KEY = '%s'", getApiKey());} + + 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; + } + + @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 (contribution, chunkContext) -> { + log.info(">>>>> 모든 스텝 성공: BATCH_LAST_EXECUTION 업데이트 시작"); + + jdbcTemplate.execute(getBatchUpdateSql()); + + log.info(">>>>> BATCH_LAST_EXECUTION 업데이트 완료"); + return RepeatStatus.FINISHED; + }; + } + @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/movement/batch/config/DarkActivityJobConfig.java b/src/main/java/com/snp/batch/jobs/movement/batch/config/DarkActivityJobConfig.java new file mode 100644 index 0000000..98f7b48 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/config/DarkActivityJobConfig.java @@ -0,0 +1,106 @@ +package com.snp.batch.jobs.movement.batch.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.config.BaseJobConfig; +import com.snp.batch.jobs.movement.batch.processor.DarkActivityProcessor; +import com.snp.batch.jobs.movement.batch.reader.DarkActivityReader; +import com.snp.batch.jobs.movement.batch.writer.DarkActivityWriter; +import com.snp.batch.jobs.movement.batch.dto.DarkActivityDto; +import com.snp.batch.jobs.movement.batch.entity.DarkActivityEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +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.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 Job Config + * + * 특징: + * - ship_data 테이블에서 IMO 번호 조회 + * - IMO 번호를 100개씩 배치로 분할 + * - Maritime API GetShipsByIHSLRorIMONumbers 호출 + * TODO : GetShipsByIHSLRorIMONumbersAll 호출로 변경 + * - 선박 상세 정보를 ship_detail 테이블에 저장 (UPSERT) + * + * 데이터 흐름: + * DarkActivityReader (ship_data → Maritime API) + * ↓ (DarkActivityDto) + * DarkActivityProcessor + * ↓ (DarkActivityEntity) + * DarkActivityWriter + * ↓ (t_darkactivity 테이블) + */ + +@Slf4j +@Configuration +public class DarkActivityJobConfig extends BaseJobConfig { + + private final DarkActivityProcessor darkActivityProcessor; + private final DarkActivityWriter darkActivityWriter; + private final JdbcTemplate jdbcTemplate; + private final WebClient maritimeApiWebClient; + + public DarkActivityJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + DarkActivityProcessor darkActivityProcessor, + DarkActivityWriter darkActivityWriter, JdbcTemplate jdbcTemplate, + @Qualifier("maritimeServiceApiWebClient") WebClient maritimeApiWebClient, + ObjectMapper objectMapper) { // ObjectMapper 주입 추가 + super(jobRepository, transactionManager); + this.darkActivityProcessor = darkActivityProcessor; + this.darkActivityWriter = darkActivityWriter; + this.jdbcTemplate = jdbcTemplate; + this.maritimeApiWebClient = maritimeApiWebClient; + } + + @Override + protected String getJobName() { + return "DarkActivityImportJob"; + } + + @Override + protected String getStepName() { + return "DarkActivityImportStep"; + } + + @Override + protected ItemReader createReader() { // 타입 변경 + // Reader 생성자 수정: ObjectMapper를 전달합니다. + return new DarkActivityReader(maritimeApiWebClient, jdbcTemplate); + } + + @Override + protected ItemProcessor createProcessor() { + return darkActivityProcessor; + } + + @Override + protected ItemWriter createWriter() { // 타입 변경 + return darkActivityWriter; + } + + @Override + protected int getChunkSize() { + return 5; // API에서 100개씩 가져오므로 chunk도 100으로 설정 + } + + @Bean(name = "DarkActivityImportJob") + public Job darkActivityImportJob() { + return job(); + } + + @Bean(name = "DarkActivityImportStep") + public Step darkActivityImportStep() { + return step(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/config/DarkActivityRangeJobConfig.java b/src/main/java/com/snp/batch/jobs/movement/batch/config/DarkActivityRangeJobConfig.java new file mode 100644 index 0000000..bddd338 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/config/DarkActivityRangeJobConfig.java @@ -0,0 +1,119 @@ +package com.snp.batch.jobs.movement.batch.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.config.BaseJobConfig; +import com.snp.batch.jobs.movement.batch.dto.DarkActivityDto; +import com.snp.batch.jobs.movement.batch.entity.DarkActivityEntity; +import com.snp.batch.jobs.movement.batch.processor.DarkActivityProcessor; +import com.snp.batch.jobs.movement.batch.writer.DarkActivityWriter; +import com.snp.batch.jobs.movement.batch.reader.DarkActivityRangeReader; +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; + +/** + * 선박 상세 정보 Import Job Config + * + * 특징: + * - ship_data 테이블에서 IMO 번호 조회 + * - IMO 번호를 100개씩 배치로 분할 + * - Maritime API GetShipsByIHSLRorIMONumbers 호출 + * TODO : GetShipsByIHSLRorIMONumbersAll 호출로 변경 + * - 선박 상세 정보를 ship_detail 테이블에 저장 (UPSERT) + * + * 데이터 흐름: + * DarkActivityReader (ship_data → Maritime API) + * ↓ (DarkActivityDto) + * DarkActivityProcessor + * ↓ (DarkActivityEntity) + * DarkActivityWriter + * ↓ (t_darkactivity 테이블) + */ + +@Slf4j +@Configuration +public class DarkActivityRangeJobConfig extends BaseJobConfig { + + private final DarkActivityProcessor darkActivityProcessor; + private final DarkActivityWriter darkActivityWriter; + private final DarkActivityRangeReader darkActivityRangeReader; + private final JdbcTemplate jdbcTemplate; + private final WebClient maritimeApiWebClient; + + public DarkActivityRangeJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + DarkActivityProcessor darkActivityProcessor, + DarkActivityWriter darkActivityWriter, JdbcTemplate jdbcTemplate, + @Qualifier("maritimeServiceApiWebClient") WebClient maritimeApiWebClient, + ObjectMapper objectMapper, DarkActivityRangeReader darkActivityRangeReader) { // ObjectMapper 주입 추가 + super(jobRepository, transactionManager); + this.darkActivityProcessor = darkActivityProcessor; + this.darkActivityWriter = darkActivityWriter; + this.jdbcTemplate = jdbcTemplate; + this.maritimeApiWebClient = maritimeApiWebClient; + this.darkActivityRangeReader = darkActivityRangeReader; + } + + @Override + protected String getJobName() { + return "DarkActivityRangeImportJob"; + } + + @Override + protected String getStepName() { + return "DarkActivityRangeImportStep"; + } + + @Override + protected ItemReader createReader() { // 타입 변경 + // Reader 생성자 수정: ObjectMapper를 전달합니다. + return darkActivityRangeReader; + } + @Bean + @StepScope + public DarkActivityRangeReader darkActivityReader( + @Value("#{jobParameters['startDate']}") String startDate, + @Value("#{jobParameters['stopDate']}") String stopDate + ) { + // jobParameters 없으면 null 넘어오고 Reader에서 default 처리 + return new DarkActivityRangeReader(maritimeApiWebClient, startDate, stopDate); + } + + @Override + protected ItemProcessor createProcessor() { + return darkActivityProcessor; + } + + @Override + protected ItemWriter createWriter() { // 타입 변경 + return darkActivityWriter; + } + + @Override + protected int getChunkSize() { + return 5000; // API에서 100개씩 가져오므로 chunk도 100으로 설정 + } + + @Bean(name = "DarkActivityRangeImportJob") + public Job darkActivityRangeImportJob() { + return job(); + } + + @Bean(name = "DarkActivityRangeImportStep") + public Step darkActivityRangeImportStep() { + return step(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/config/DestinationsJobConfig.java b/src/main/java/com/snp/batch/jobs/movement/batch/config/DestinationsJobConfig.java new file mode 100644 index 0000000..92e7e30 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/config/DestinationsJobConfig.java @@ -0,0 +1,103 @@ +package com.snp.batch.jobs.movement.batch.config; + +import com.snp.batch.common.batch.config.BaseJobConfig; +import com.snp.batch.jobs.movement.batch.entity.DestinationEntity; +import com.snp.batch.jobs.movement.batch.processor.DestinationProcessor; +import com.snp.batch.jobs.movement.batch.reader.DestinationReader; +import com.snp.batch.jobs.movement.batch.writer.DestinationWriter; +import com.snp.batch.jobs.movement.batch.dto.DestinationDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +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.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 Job Config + * + * 특징: + * - ship_data 테이블에서 IMO 번호 조회 + * - IMO 번호를 100개씩 배치로 분할 + * - Maritime API GetShipsByIHSLRorIMONumbers 호출 + * TODO : GetShipsByIHSLRorIMONumbersAll 호출로 변경 + * - 선박 상세 정보를 ship_detail 테이블에 저장 (UPSERT) + * + * 데이터 흐름: + * DestinationReader (ship_data → Maritime API) + * ↓ (DestinationDto) + * DestinationProcessor + * ↓ (DestinationEntity) + * DestinationProcessor + * ↓ (t_destination 테이블) + */ + +@Slf4j +@Configuration +public class DestinationsJobConfig extends BaseJobConfig { + + private final DestinationProcessor destinationProcessor; + private final DestinationWriter destinationWriter; + private final JdbcTemplate jdbcTemplate; + private final WebClient maritimeApiWebClient; + + public DestinationsJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + DestinationProcessor destinationProcessor, + DestinationWriter destinationWriter, JdbcTemplate jdbcTemplate, + @Qualifier("maritimeServiceApiWebClient") WebClient maritimeApiWebClient) { // ObjectMapper 주입 추가 + super(jobRepository, transactionManager); + this.destinationProcessor = destinationProcessor; + this.destinationWriter = destinationWriter; + this.jdbcTemplate = jdbcTemplate; + this.maritimeApiWebClient = maritimeApiWebClient; + } + + @Override + protected String getJobName() { + return "DestinationsImportJob"; + } + + @Override + protected String getStepName() { + return "DestinationsImportStep"; + } + + @Override + protected ItemReader createReader() { // 타입 변경 + return new DestinationReader(maritimeApiWebClient, jdbcTemplate); + } + + @Override + protected ItemProcessor createProcessor() { + return destinationProcessor; + } + + @Override + protected ItemWriter createWriter() { // 타입 변경 + return destinationWriter; + } + + @Override + protected int getChunkSize() { + return 1000; // API에서 100개씩 가져오므로 chunk도 100으로 설정 + } + + @Bean(name = "DestinationsImportJob") + public Job destinationsImportJob() { + return job(); + } + + @Bean(name = "DestinationsImportStep") + public Step destinationsImportStep() { + return step(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/config/DestinationsRangeJobConfig.java b/src/main/java/com/snp/batch/jobs/movement/batch/config/DestinationsRangeJobConfig.java new file mode 100644 index 0000000..928d502 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/config/DestinationsRangeJobConfig.java @@ -0,0 +1,147 @@ +package com.snp.batch.jobs.movement.batch.config; + +import com.snp.batch.common.batch.config.BaseMultiStepJobConfig; +import com.snp.batch.jobs.movement.batch.dto.DestinationDto; +import com.snp.batch.jobs.movement.batch.entity.DestinationEntity; +import com.snp.batch.jobs.movement.batch.processor.DestinationProcessor; +import com.snp.batch.jobs.movement.batch.reader.DestinationRangeReader; +import com.snp.batch.jobs.movement.batch.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.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; + +@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; + protected String getApiKey() {return "DESTINATIONS_IMPORT_API";} + protected String getBatchUpdateSql() { + return String.format("UPDATE SNP_DATA.BATCH_LAST_EXECUTION SET LAST_SUCCESS_DATE = NOW(), UPDATED_AT = NOW() WHERE API_KEY = '%s'", getApiKey());} + + + 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; + } + + @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 (contribution, chunkContext) -> { + log.info(">>>>> 모든 스텝 성공: BATCH_LAST_EXECUTION 업데이트 시작"); + + jdbcTemplate.execute(getBatchUpdateSql()); + + log.info(">>>>> BATCH_LAST_EXECUTION 업데이트 완료"); + return RepeatStatus.FINISHED; + }; + } + @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/movement/batch/config/ShipPortCallsJobConfig.java b/src/main/java/com/snp/batch/jobs/movement/batch/config/ShipPortCallsJobConfig.java new file mode 100644 index 0000000..fd10482 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/config/ShipPortCallsJobConfig.java @@ -0,0 +1,132 @@ +package com.snp.batch.jobs.movement.batch.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.config.BaseJobConfig; +import com.snp.batch.jobs.movement.batch.dto.PortCallsDto; +import com.snp.batch.jobs.movement.batch.entity.PortCallsEntity; +import com.snp.batch.jobs.movement.batch.processor.PortCallsProcessor; +import com.snp.batch.jobs.movement.batch.reader.PortCallsReader; +import com.snp.batch.jobs.movement.batch.writer.PortCallsWriter; +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; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +/** + * 선박 상세 정보 Import Job Config + * + * 특징: + * - ship_data 테이블에서 IMO 번호 조회 + * - IMO 번호를 100개씩 배치로 분할 + * - Maritime API GetShipsByIHSLRorIMONumbers 호출 + * TODO : GetShipsByIHSLRorIMONumbersAll 호출로 변경 + * - 선박 상세 정보를 ship_detail 테이블에 저장 (UPSERT) + * + * 데이터 흐름: + * PortCallsReader (ship_data → Maritime API) + * ↓ (PortCallDto) + * PortCallsProcessor + * ↓ (PortCallsEntity) + * ShipDetailDataWriter + * ↓ (ship_movement 테이블) + */ + +@Slf4j +@Configuration +public class ShipPortCallsJobConfig extends BaseJobConfig { + + private final PortCallsProcessor portCallsProcessor; + private final PortCallsWriter portCallsWriter; + private final JdbcTemplate jdbcTemplate; + private final WebClient maritimeApiWebClient; + private final ObjectMapper objectMapper; // ObjectMapper 주입 추가 + + public ShipPortCallsJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + PortCallsProcessor portCallsProcessor, + PortCallsWriter portCallsWriter, JdbcTemplate jdbcTemplate, + @Qualifier("maritimeServiceApiWebClient") WebClient maritimeApiWebClient, + ObjectMapper objectMapper) { // ObjectMapper 주입 추가 + super(jobRepository, transactionManager); + this.portCallsProcessor = portCallsProcessor; + this.portCallsWriter = portCallsWriter; + this.jdbcTemplate = jdbcTemplate; + this.maritimeApiWebClient = maritimeApiWebClient; + this.objectMapper = objectMapper; // ObjectMapper 초기화 + } + + @Override + protected String getJobName() { + return "PortCallsImportJob"; + } + + @Override + protected String getStepName() { + return "PortCallsImportStep"; + } + + @Bean + @StepScope + public PortCallsReader portCallsReader( + @Value("#{jobParameters['startDate']}") String startDate, + @Value("#{jobParameters['stopDate']}") String stopDate) { + if (startDate == null || startDate.isBlank() || + stopDate == null || stopDate.isBlank()) { + + LocalDate yesterday = LocalDate.now().minusDays(1); + startDate = yesterday.atStartOfDay().format(DateTimeFormatter.ISO_DATE_TIME) + "Z"; + stopDate = yesterday.plusDays(1).atStartOfDay().format(DateTimeFormatter.ISO_DATE_TIME) + "Z"; + } + + PortCallsReader reader = new PortCallsReader(maritimeApiWebClient, jdbcTemplate, objectMapper); + reader.setStartDate(startDate); + reader.setStopDate(stopDate); + return reader; + } + @Override + protected ItemReader createReader() { // 타입 변경 + // Reader 생성자 수정: ObjectMapper를 전달합니다. + return portCallsReader( null, null); + //return new PortCallsReader(maritimeApiWebClient, jdbcTemplate, objectMapper); + } + + @Override + protected ItemProcessor createProcessor() { + return portCallsProcessor; + } + + @Override + protected ItemWriter createWriter() { // 타입 변경 + return portCallsWriter; + } + + @Override + protected int getChunkSize() { + return 1000; // API에서 5000개씩 가져오므로 chunk도 5000으로 설정 + } + + @Bean(name = "PortCallsImportJob") + public Job portCallsImportJob() { + return job(); + } + + @Bean(name = "PortCallsImportStep") + public Step portCallsImportStep() { + return step(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/config/ShipPortCallsRangeJobConfig.java b/src/main/java/com/snp/batch/jobs/movement/batch/config/ShipPortCallsRangeJobConfig.java new file mode 100644 index 0000000..bc95359 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/config/ShipPortCallsRangeJobConfig.java @@ -0,0 +1,148 @@ +package com.snp.batch.jobs.movement.batch.config; + +import com.snp.batch.common.batch.config.BaseMultiStepJobConfig; +import com.snp.batch.jobs.movement.batch.dto.PortCallsDto; +import com.snp.batch.jobs.movement.batch.entity.PortCallsEntity; +import com.snp.batch.jobs.movement.batch.processor.PortCallsProcessor; +import com.snp.batch.jobs.movement.batch.reader.PortCallsRangeReader; +import com.snp.batch.jobs.movement.batch.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.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; + +@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; + protected String getApiKey() {return "PORT_CALLS_IMPORT_API";} + protected String getBatchUpdateSql() { + return String.format("UPDATE SNP_DATA.BATCH_LAST_EXECUTION SET LAST_SUCCESS_DATE = NOW(), UPDATED_AT = NOW() WHERE API_KEY = '%s'", getApiKey());} + + 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; + } + + @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 (contribution, chunkContext) -> { + log.info(">>>>> 모든 스텝 성공: BATCH_LAST_EXECUTION 업데이트 시작"); + + jdbcTemplate.execute(getBatchUpdateSql()); + + log.info(">>>>> BATCH_LAST_EXECUTION 업데이트 완료"); + return RepeatStatus.FINISHED; + }; + } + @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/movement/batch/config/StsOperationJobConfig.java b/src/main/java/com/snp/batch/jobs/movement/batch/config/StsOperationJobConfig.java new file mode 100644 index 0000000..e2c961e --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/config/StsOperationJobConfig.java @@ -0,0 +1,104 @@ +package com.snp.batch.jobs.movement.batch.config; + +import com.snp.batch.common.batch.config.BaseJobConfig; +import com.snp.batch.jobs.movement.batch.processor.StsOperationProcessor; +import com.snp.batch.jobs.movement.batch.reader.StsOperationReader; +import com.snp.batch.jobs.movement.batch.writer.StsOperationWriter; +import com.snp.batch.jobs.movement.batch.dto.StsOperationDto; +import com.snp.batch.jobs.movement.batch.entity.StsOperationEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +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.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 Job Config + * + * 특징: + * - ship_data 테이블에서 IMO 번호 조회 + * - IMO 번호를 100개씩 배치로 분할 + * - Maritime API GetShipsByIHSLRorIMONumbers 호출 + * TODO : GetShipsByIHSLRorIMONumbersAll 호출로 변경 + * - 선박 상세 정보를 ship_detail 테이블에 저장 (UPSERT) + * + * 데이터 흐름: + * StsOperationReader (ship_data → Maritime API) + * ↓ (StsOperationDto) + * StsOperationProcessor + * ↓ (StsOperationEntity) + * StsOperationWriter + * ↓ (t_stsoperation 테이블) + */ + +@Slf4j +@Configuration +public class StsOperationJobConfig extends BaseJobConfig { + + private final StsOperationProcessor stsOperationProcessor; + private final StsOperationWriter stsOperationWriter; + private final JdbcTemplate jdbcTemplate; + private final WebClient maritimeApiWebClient; + + public StsOperationJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + StsOperationProcessor stsOperationProcessor, + StsOperationWriter stsOperationWriter, JdbcTemplate jdbcTemplate, + @Qualifier("maritimeServiceApiWebClient") WebClient maritimeApiWebClient) { // ObjectMapper 주입 추가 + super(jobRepository, transactionManager); + this.stsOperationProcessor = stsOperationProcessor; + this.stsOperationWriter = stsOperationWriter; + this.jdbcTemplate = jdbcTemplate; + this.maritimeApiWebClient = maritimeApiWebClient; + } + + @Override + protected String getJobName() { + return "STSOperationImportJob"; + } + + @Override + protected String getStepName() { + return "STSOperationImportStep"; + } + + @Override + protected ItemReader createReader() { // 타입 변경 + // Reader 생성자 수정: ObjectMapper를 전달합니다. + return new StsOperationReader(maritimeApiWebClient, jdbcTemplate); + } + + @Override + protected ItemProcessor createProcessor() { + return stsOperationProcessor; + } + + @Override + protected ItemWriter createWriter() { // 타입 변경 + return stsOperationWriter; + } + + @Override + protected int getChunkSize() { + return 200; // API에서 100개씩 가져오므로 chunk도 100으로 설정 + } + + @Bean(name = "STSOperationImportJob") + public Job stsOperationImportJob() { + return job(); + } + + @Bean(name = "STSOperationImportStep") + public Step stsOperationImportStep() { + return step(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/config/StsOperationRangeJobConfig.java b/src/main/java/com/snp/batch/jobs/movement/batch/config/StsOperationRangeJobConfig.java new file mode 100644 index 0000000..33a94f9 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/config/StsOperationRangeJobConfig.java @@ -0,0 +1,147 @@ +package com.snp.batch.jobs.movement.batch.config; + +import com.snp.batch.common.batch.config.BaseMultiStepJobConfig; +import com.snp.batch.jobs.movement.batch.dto.StsOperationDto; +import com.snp.batch.jobs.movement.batch.entity.StsOperationEntity; +import com.snp.batch.jobs.movement.batch.processor.StsOperationProcessor; +import com.snp.batch.jobs.movement.batch.reader.StsOperationRangeReader; +import com.snp.batch.jobs.movement.batch.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.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; + +@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; + protected String getApiKey() {return "STS_OPERATION_IMPORT_API";} + protected String getBatchUpdateSql() { + return String.format("UPDATE SNP_DATA.BATCH_LAST_EXECUTION SET LAST_SUCCESS_DATE = NOW(), UPDATED_AT = NOW() WHERE API_KEY = '%s'", getApiKey());} + + + 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; + } + + @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 (contribution, chunkContext) -> { + log.info(">>>>> 모든 스텝 성공: BATCH_LAST_EXECUTION 업데이트 시작"); + + jdbcTemplate.execute(getBatchUpdateSql()); + + log.info(">>>>> BATCH_LAST_EXECUTION 업데이트 완료"); + return RepeatStatus.FINISHED; + }; + } + @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/movement/batch/config/TerminalCallsJobConfig.java b/src/main/java/com/snp/batch/jobs/movement/batch/config/TerminalCallsJobConfig.java new file mode 100644 index 0000000..228ce73 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/config/TerminalCallsJobConfig.java @@ -0,0 +1,103 @@ +package com.snp.batch.jobs.movement.batch.config; + +import com.snp.batch.common.batch.config.BaseJobConfig; +import com.snp.batch.jobs.movement.batch.entity.TerminalCallsEntity; +import com.snp.batch.jobs.movement.batch.processor.TerminalCallsProcessor; +import com.snp.batch.jobs.movement.batch.reader.TerminalCallsReader; +import com.snp.batch.jobs.movement.batch.writer.TerminalCallsWriter; +import com.snp.batch.jobs.movement.batch.dto.TerminalCallsDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +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.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 Job Config + * + * 특징: + * - ship_data 테이블에서 IMO 번호 조회 + * - IMO 번호를 100개씩 배치로 분할 + * - Maritime API GetShipsByIHSLRorIMONumbers 호출 + * TODO : GetShipsByIHSLRorIMONumbersAll 호출로 변경 + * - 선박 상세 정보를 ship_detail 테이블에 저장 (UPSERT) + * + * 데이터 흐름: + * TerminalCallsReader (ship_data → Maritime API) + * ↓ (TerminalCallsDto) + * TerminalCallsProcessor + * ↓ (TerminalCallsEntity) + * TerminalCallsWriter + * ↓ (t_terminalcall 테이블) + */ + +@Slf4j +@Configuration +public class TerminalCallsJobConfig extends BaseJobConfig { + + private final TerminalCallsProcessor terminalCallsProcessor; + private final TerminalCallsWriter terminalCallsWriter; + private final JdbcTemplate jdbcTemplate; + private final WebClient maritimeApiWebClient; + + public TerminalCallsJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + TerminalCallsProcessor terminalCallsProcessor, + TerminalCallsWriter terminalCallsWriter, JdbcTemplate jdbcTemplate, + @Qualifier("maritimeServiceApiWebClient") WebClient maritimeApiWebClient) { // ObjectMapper 주입 추가 + super(jobRepository, transactionManager); + this.terminalCallsProcessor = terminalCallsProcessor; + this.terminalCallsWriter = terminalCallsWriter; + this.jdbcTemplate = jdbcTemplate; + this.maritimeApiWebClient = maritimeApiWebClient; + } + + @Override + protected String getJobName() { + return "TerminalCallsImportJob"; + } + + @Override + protected String getStepName() { + return "TerminalCallImportStep"; + } + + @Override + protected ItemReader createReader() { // 타입 변경 + return new TerminalCallsReader(maritimeApiWebClient, jdbcTemplate); + } + + @Override + protected ItemProcessor createProcessor() { + return terminalCallsProcessor; + } + + @Override + protected ItemWriter createWriter() { // 타입 변경 + return terminalCallsWriter; + } + + @Override + protected int getChunkSize() { + return 1000; // API에서 100개씩 가져오므로 chunk도 100으로 설정 + } + + @Bean(name = "TerminalCallsImportJob") + public Job terminalCallsImportJob() { + return job(); + } + + @Bean(name = "TerminalCallImportStep") + public Step terminalCallImportStep() { + return step(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/config/TerminalCallsRangeJobConfig.java b/src/main/java/com/snp/batch/jobs/movement/batch/config/TerminalCallsRangeJobConfig.java new file mode 100644 index 0000000..0d33208 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/config/TerminalCallsRangeJobConfig.java @@ -0,0 +1,147 @@ +package com.snp.batch.jobs.movement.batch.config; + +import com.snp.batch.common.batch.config.BaseMultiStepJobConfig; +import com.snp.batch.jobs.movement.batch.dto.TerminalCallsDto; +import com.snp.batch.jobs.movement.batch.entity.TerminalCallsEntity; +import com.snp.batch.jobs.movement.batch.processor.TerminalCallsProcessor; +import com.snp.batch.jobs.movement.batch.reader.TerminalCallsRangeReader; +import com.snp.batch.jobs.movement.batch.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.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; + +@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; + protected String getApiKey() {return "TERMINAL_CALLS_IMPORT_API";} + protected String getBatchUpdateSql() { + return String.format("UPDATE SNP_DATA.BATCH_LAST_EXECUTION SET LAST_SUCCESS_DATE = NOW(), UPDATED_AT = NOW() WHERE API_KEY = '%s'", getApiKey());} + + + 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; + } + + @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 (contribution, chunkContext) -> { + log.info(">>>>> 모든 스텝 성공: BATCH_LAST_EXECUTION 업데이트 시작"); + + jdbcTemplate.execute(getBatchUpdateSql()); + + log.info(">>>>> BATCH_LAST_EXECUTION 업데이트 완료"); + return RepeatStatus.FINISHED; + }; + } + @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/movement/batch/config/TransitsJobConfig.java b/src/main/java/com/snp/batch/jobs/movement/batch/config/TransitsJobConfig.java new file mode 100644 index 0000000..18f027e --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/config/TransitsJobConfig.java @@ -0,0 +1,103 @@ +package com.snp.batch.jobs.movement.batch.config; + +import com.snp.batch.common.batch.config.BaseJobConfig; +import com.snp.batch.jobs.movement.batch.dto.TransitsDto; +import com.snp.batch.jobs.movement.batch.entity.TransitsEntity; +import com.snp.batch.jobs.movement.batch.processor.TransitsProcessor; +import com.snp.batch.jobs.movement.batch.reader.TransitsReader; +import com.snp.batch.jobs.movement.batch.writer.TransitsWriter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +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.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 Job Config + * + * 특징: + * - ship_data 테이블에서 IMO 번호 조회 + * - IMO 번호를 100개씩 배치로 분할 + * - Maritime API GetShipsByIHSLRorIMONumbers 호출 + * TODO : GetShipsByIHSLRorIMONumbersAll 호출로 변경 + * - 선박 상세 정보를 ship_detail 테이블에 저장 (UPSERT) + * + * 데이터 흐름: + * TransitsReader (ship_data → Maritime API) + * ↓ (TransitsDto) + * TransitsProcessor + * ↓ (TransitsEntity) + * TransitsWriter + * ↓ (t_transit 테이블) + */ + +@Slf4j +@Configuration +public class TransitsJobConfig extends BaseJobConfig { + + private final TransitsProcessor transitsProcessor; + private final TransitsWriter transitsWriter; + private final JdbcTemplate jdbcTemplate; + private final WebClient maritimeApiWebClient; + + public TransitsJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + TransitsProcessor TransitsProcessor, + TransitsWriter transitsWriter, JdbcTemplate jdbcTemplate, + @Qualifier("maritimeServiceApiWebClient") WebClient maritimeApiWebClient) { // ObjectMapper 주입 추가 + super(jobRepository, transactionManager); + this.transitsProcessor = TransitsProcessor; + this.transitsWriter = transitsWriter; + this.jdbcTemplate = jdbcTemplate; + this.maritimeApiWebClient = maritimeApiWebClient; + } + + @Override + protected String getJobName() { + return "TransitsImportJob"; + } + + @Override + protected String getStepName() { + return "TransitsImportStep"; + } + + @Override + protected ItemReader createReader() { // 타입 변경 + return new TransitsReader(maritimeApiWebClient, jdbcTemplate); + } + + @Override + protected ItemProcessor createProcessor() { + return transitsProcessor; + } + + @Override + protected ItemWriter createWriter() { // 타입 변경 + return transitsWriter; + } + + @Override + protected int getChunkSize() { + return 1000; // API에서 100개씩 가져오므로 chunk도 100으로 설정 + } + + @Bean(name = "TransitsImportJob") + public Job transitsImportJob() { + return job(); + } + + @Bean(name = "TransitsImportStep") + public Step transitsImportStep() { + return step(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/config/TransitsRangeJobConfig.java b/src/main/java/com/snp/batch/jobs/movement/batch/config/TransitsRangeJobConfig.java new file mode 100644 index 0000000..11d2bcd --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/config/TransitsRangeJobConfig.java @@ -0,0 +1,146 @@ +package com.snp.batch.jobs.movement.batch.config; + +import com.snp.batch.common.batch.config.BaseMultiStepJobConfig; +import com.snp.batch.jobs.movement.batch.dto.TransitsDto; +import com.snp.batch.jobs.movement.batch.entity.TransitsEntity; +import com.snp.batch.jobs.movement.batch.processor.TransitsProcessor; +import com.snp.batch.jobs.movement.batch.reader.TransitsRangeReader; +import com.snp.batch.jobs.movement.batch.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.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; + +@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; + protected String getApiKey() {return "TRANSITS_IMPORT_API";} + protected String getBatchUpdateSql() { + return String.format("UPDATE SNP_DATA.BATCH_LAST_EXECUTION SET LAST_SUCCESS_DATE = NOW(), UPDATED_AT = NOW() WHERE API_KEY = '%s'", getApiKey());} + + 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; + } + + @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 (contribution, chunkContext) -> { + log.info(">>>>> 모든 스텝 성공: BATCH_LAST_EXECUTION 업데이트 시작"); + + jdbcTemplate.execute(getBatchUpdateSql()); + + log.info(">>>>> BATCH_LAST_EXECUTION 업데이트 완료"); + return RepeatStatus.FINISHED; + }; + } + @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/movement/batch/dto/AnchorageCallsDto.java b/src/main/java/com/snp/batch/jobs/movement/batch/dto/AnchorageCallsDto.java new file mode 100644 index 0000000..0d4e885 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/dto/AnchorageCallsDto.java @@ -0,0 +1,31 @@ +package com.snp.batch.jobs.movement.batch.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/movement/batch/dto/AnchorageCallsPositionDto.java b/src/main/java/com/snp/batch/jobs/movement/batch/dto/AnchorageCallsPositionDto.java new file mode 100644 index 0000000..419eb7d --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/dto/AnchorageCallsPositionDto.java @@ -0,0 +1,19 @@ +package com.snp.batch.jobs.movement.batch.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/movement/batch/dto/BerthCallsDto.java b/src/main/java/com/snp/batch/jobs/movement/batch/dto/BerthCallsDto.java new file mode 100644 index 0000000..d133f60 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/dto/BerthCallsDto.java @@ -0,0 +1,32 @@ +package com.snp.batch.jobs.movement.batch.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/movement/batch/dto/BerthCallsPositionDto.java b/src/main/java/com/snp/batch/jobs/movement/batch/dto/BerthCallsPositionDto.java new file mode 100644 index 0000000..365cce3 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/dto/BerthCallsPositionDto.java @@ -0,0 +1,17 @@ +package com.snp.batch.jobs.movement.batch.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/movement/batch/dto/CurrentlyAtDto.java b/src/main/java/com/snp/batch/jobs/movement/batch/dto/CurrentlyAtDto.java new file mode 100644 index 0000000..7cdc0a9 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/dto/CurrentlyAtDto.java @@ -0,0 +1,36 @@ +package com.snp.batch.jobs.movement.batch.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/movement/batch/dto/CurrentlyAtPositionDto.java b/src/main/java/com/snp/batch/jobs/movement/batch/dto/CurrentlyAtPositionDto.java new file mode 100644 index 0000000..96e8b1b --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/dto/CurrentlyAtPositionDto.java @@ -0,0 +1,17 @@ +package com.snp.batch.jobs.movement.batch.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/movement/batch/dto/DarkActivityDto.java b/src/main/java/com/snp/batch/jobs/movement/batch/dto/DarkActivityDto.java new file mode 100644 index 0000000..02e5826 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/dto/DarkActivityDto.java @@ -0,0 +1,29 @@ +package com.snp.batch.jobs.movement.batch.dto; + +import lombok.Data; + +@Data +public class DarkActivityDto { + private String movementType; + private String imolRorIHSNumber; + private String movementDate; + + 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 DarkActivityPositionDto position; + + private String eventStartDate; +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/dto/DarkActivityPositionDto.java b/src/main/java/com/snp/batch/jobs/movement/batch/dto/DarkActivityPositionDto.java new file mode 100644 index 0000000..866563c --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/dto/DarkActivityPositionDto.java @@ -0,0 +1,17 @@ +package com.snp.batch.jobs.movement.batch.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class DarkActivityPositionDto { + 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/movement/batch/dto/DestinationDto.java b/src/main/java/com/snp/batch/jobs/movement/batch/dto/DestinationDto.java new file mode 100644 index 0000000..ff5804e --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/dto/DestinationDto.java @@ -0,0 +1,24 @@ +package com.snp.batch.jobs.movement.batch.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/movement/batch/dto/DestinationPositionDto.java b/src/main/java/com/snp/batch/jobs/movement/batch/dto/DestinationPositionDto.java new file mode 100644 index 0000000..d9f5d28 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/dto/DestinationPositionDto.java @@ -0,0 +1,17 @@ +package com.snp.batch.jobs.movement.batch.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/movement/batch/dto/PortCallsDto.java b/src/main/java/com/snp/batch/jobs/movement/batch/dto/PortCallsDto.java new file mode 100644 index 0000000..f67a702 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/dto/PortCallsDto.java @@ -0,0 +1,36 @@ +package com.snp.batch.jobs.movement.batch.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/movement/batch/dto/PortCallsPositionDto.java b/src/main/java/com/snp/batch/jobs/movement/batch/dto/PortCallsPositionDto.java new file mode 100644 index 0000000..fa1e531 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/dto/PortCallsPositionDto.java @@ -0,0 +1,17 @@ +package com.snp.batch.jobs.movement.batch.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/movement/batch/dto/ShipMovementApiResponse.java b/src/main/java/com/snp/batch/jobs/movement/batch/dto/ShipMovementApiResponse.java new file mode 100644 index 0000000..f1464ef --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/dto/ShipMovementApiResponse.java @@ -0,0 +1,12 @@ +package com.snp.batch.jobs.movement.batch.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/movement/batch/dto/StsOperationDto.java b/src/main/java/com/snp/batch/jobs/movement/batch/dto/StsOperationDto.java new file mode 100644 index 0000000..4318ec3 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/dto/StsOperationDto.java @@ -0,0 +1,34 @@ +package com.snp.batch.jobs.movement.batch.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/movement/batch/dto/StsOperationPositionDto.java b/src/main/java/com/snp/batch/jobs/movement/batch/dto/StsOperationPositionDto.java new file mode 100644 index 0000000..4410970 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/dto/StsOperationPositionDto.java @@ -0,0 +1,17 @@ +package com.snp.batch.jobs.movement.batch.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/movement/batch/dto/TerminalCallsDto.java b/src/main/java/com/snp/batch/jobs/movement/batch/dto/TerminalCallsDto.java new file mode 100644 index 0000000..12281e6 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/dto/TerminalCallsDto.java @@ -0,0 +1,43 @@ +package com.snp.batch.jobs.movement.batch.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/movement/batch/dto/TerminalCallsPositionDto.java b/src/main/java/com/snp/batch/jobs/movement/batch/dto/TerminalCallsPositionDto.java new file mode 100644 index 0000000..1179f09 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/dto/TerminalCallsPositionDto.java @@ -0,0 +1,17 @@ +package com.snp.batch.jobs.movement.batch.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/movement/batch/dto/TransitsDto.java b/src/main/java/com/snp/batch/jobs/movement/batch/dto/TransitsDto.java new file mode 100644 index 0000000..d3351b9 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/dto/TransitsDto.java @@ -0,0 +1,13 @@ +package com.snp.batch.jobs.movement.batch.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/movement/batch/entity/AnchorageCallsEntity.java b/src/main/java/com/snp/batch/jobs/movement/batch/entity/AnchorageCallsEntity.java new file mode 100644 index 0000000..7487582 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/entity/AnchorageCallsEntity.java @@ -0,0 +1,47 @@ +package com.snp.batch.jobs.movement.batch.entity; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.SequenceGenerator; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDateTime; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +public class AnchorageCallsEntity { + + 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/movement/batch/entity/BerthCallsEntity.java b/src/main/java/com/snp/batch/jobs/movement/batch/entity/BerthCallsEntity.java new file mode 100644 index 0000000..5e3e99a --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/entity/BerthCallsEntity.java @@ -0,0 +1,47 @@ +package com.snp.batch.jobs.movement.batch.entity; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.SequenceGenerator; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDateTime; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +public class BerthCallsEntity { + + 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/movement/batch/entity/CurrentlyAtEntity.java b/src/main/java/com/snp/batch/jobs/movement/batch/entity/CurrentlyAtEntity.java new file mode 100644 index 0000000..ffef239 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/entity/CurrentlyAtEntity.java @@ -0,0 +1,41 @@ +package com.snp.batch.jobs.movement.batch.entity; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.SequenceGenerator; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDateTime; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +public class CurrentlyAtEntity { + 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/movement/batch/entity/DarkActivityEntity.java b/src/main/java/com/snp/batch/jobs/movement/batch/entity/DarkActivityEntity.java new file mode 100644 index 0000000..7d2fa6f --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/entity/DarkActivityEntity.java @@ -0,0 +1,41 @@ +package com.snp.batch.jobs.movement.batch.entity; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDateTime; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +public class DarkActivityEntity { + + private Long id; + + private String movementType; + private String imolRorIHSNumber; + private LocalDateTime movementDate; + + 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 LocalDateTime eventStartDate; +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/entity/DestinationEntity.java b/src/main/java/com/snp/batch/jobs/movement/batch/entity/DestinationEntity.java new file mode 100644 index 0000000..f3523f6 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/entity/DestinationEntity.java @@ -0,0 +1,32 @@ +package com.snp.batch.jobs.movement.batch.entity; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDateTime; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +public class DestinationEntity { + 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/movement/batch/entity/PortCallsEntity.java b/src/main/java/com/snp/batch/jobs/movement/batch/entity/PortCallsEntity.java new file mode 100644 index 0000000..268f652 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/entity/PortCallsEntity.java @@ -0,0 +1,47 @@ +package com.snp.batch.jobs.movement.batch.entity; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.SequenceGenerator; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDateTime; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +public class PortCallsEntity { + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "ship_movement_id_seq") + @SequenceGenerator(name = "ship_movement_id_seq", sequenceName = "ship_movement_id_seq", allocationSize = 1) + 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/movement/batch/entity/StsOperationEntity.java b/src/main/java/com/snp/batch/jobs/movement/batch/entity/StsOperationEntity.java new file mode 100644 index 0000000..1902bca --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/entity/StsOperationEntity.java @@ -0,0 +1,45 @@ +package com.snp.batch.jobs.movement.batch.entity; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDateTime; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +public class StsOperationEntity { + + 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/movement/batch/entity/TerminalCallsEntity.java b/src/main/java/com/snp/batch/jobs/movement/batch/entity/TerminalCallsEntity.java new file mode 100644 index 0000000..b362060 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/entity/TerminalCallsEntity.java @@ -0,0 +1,48 @@ +package com.snp.batch.jobs.movement.batch.entity; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDateTime; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +public class TerminalCallsEntity { + + 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/movement/batch/entity/TransitsEntity.java b/src/main/java/com/snp/batch/jobs/movement/batch/entity/TransitsEntity.java new file mode 100644 index 0000000..d2875c8 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/entity/TransitsEntity.java @@ -0,0 +1,21 @@ +package com.snp.batch.jobs.movement.batch.entity; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDateTime; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +public class TransitsEntity { + 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/movement/batch/processor/AnchorageCallsProcessor.java b/src/main/java/com/snp/batch/jobs/movement/batch/processor/AnchorageCallsProcessor.java new file mode 100644 index 0000000..5f47127 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/processor/AnchorageCallsProcessor.java @@ -0,0 +1,68 @@ +package com.snp.batch.jobs.movement.batch.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.movement.batch.dto.AnchorageCallsDto; +import com.snp.batch.jobs.movement.batch.entity.AnchorageCallsEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +/** + * 선박 상세 정보 Processor + * ShipDetailDto → ShipDetailEntity 변환 + */ + +/** + * 선박 상세 정보 Processor (해시 비교 및 증분 데이터 추출) + * I: ShipDetailComparisonData (DB 해시 + API Map Data) + * O: ShipDetailUpdate (변경분) + */ +@Slf4j +@Component +public class AnchorageCallsProcessor extends BaseProcessor { + + private final ObjectMapper objectMapper; + + public AnchorageCallsProcessor(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + protected AnchorageCallsEntity processItem(AnchorageCallsDto dto) throws Exception { + log.debug("선박 상세 정보 처리 시작: 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로 매핑 + .build(); + + return entity; + } + +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/processor/BerthCallsProcessor.java b/src/main/java/com/snp/batch/jobs/movement/batch/processor/BerthCallsProcessor.java new file mode 100644 index 0000000..29812df --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/processor/BerthCallsProcessor.java @@ -0,0 +1,68 @@ +package com.snp.batch.jobs.movement.batch.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.movement.batch.dto.BerthCallsDto; +import com.snp.batch.jobs.movement.batch.entity.BerthCallsEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +/** + * 선박 상세 정보 Processor + * ShipDetailDto → ShipDetailEntity 변환 + */ + +/** + * 선박 상세 정보 Processor (해시 비교 및 증분 데이터 추출) + * I: ShipDetailComparisonData (DB 해시 + API Map Data) + * O: ShipDetailUpdate (변경분) + */ +@Slf4j +@Component +public class BerthCallsProcessor extends BaseProcessor { + + private final ObjectMapper objectMapper; + + public BerthCallsProcessor(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + protected BerthCallsEntity processItem(BerthCallsDto dto) throws Exception { + log.debug("선박 상세 정보 처리 시작: 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())) + .build(); + + return entity; + } + +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/processor/CurrentlyAtProcessor.java b/src/main/java/com/snp/batch/jobs/movement/batch/processor/CurrentlyAtProcessor.java new file mode 100644 index 0000000..3cd0396 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/processor/CurrentlyAtProcessor.java @@ -0,0 +1,71 @@ +package com.snp.batch.jobs.movement.batch.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.movement.batch.dto.CurrentlyAtDto; +import com.snp.batch.jobs.movement.batch.entity.CurrentlyAtEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +/** + * 선박 상세 정보 Processor + * ShipDetailDto → ShipDetailEntity 변환 + */ + +/** + * 선박 상세 정보 Processor (해시 비교 및 증분 데이터 추출) + * I: ShipDetailComparisonData (DB 해시 + API Map Data) + * O: ShipDetailUpdate (변경분) + */ +@Slf4j +@Component +public class CurrentlyAtProcessor extends BaseProcessor { + + private final ObjectMapper objectMapper; + + public CurrentlyAtProcessor(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + protected CurrentlyAtEntity processItem(CurrentlyAtDto dto) throws Exception { + log.debug("Currently 정보 처리 시작: 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로 매핑 + .build(); + + return entity; + } + +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/processor/DarkActivityProcessor.java b/src/main/java/com/snp/batch/jobs/movement/batch/processor/DarkActivityProcessor.java new file mode 100644 index 0000000..a456b6c --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/processor/DarkActivityProcessor.java @@ -0,0 +1,66 @@ +package com.snp.batch.jobs.movement.batch.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.movement.batch.dto.DarkActivityDto; +import com.snp.batch.jobs.movement.batch.entity.DarkActivityEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +/** + * 선박 상세 정보 Processor + * ShipDetailDto → ShipDetailEntity 변환 + */ + +/** + * 선박 상세 정보 Processor (해시 비교 및 증분 데이터 추출) + * I: ShipDetailComparisonData (DB 해시 + API Map Data) + * O: ShipDetailUpdate (변경분) + */ +@Slf4j +@Component +public class DarkActivityProcessor extends BaseProcessor { + + private final ObjectMapper objectMapper; + + public DarkActivityProcessor(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + protected DarkActivityEntity processItem(DarkActivityDto dto) throws Exception { + log.debug("선박 상세 정보 처리 시작: imoNumber={}, facilityName={}", + dto.getImolRorIHSNumber(), dto.getFacilityName()); + + JsonNode positionNode = null; + if (dto.getPosition() != null) { + // Position 객체를 JsonNode로 변환 + positionNode = objectMapper.valueToTree(dto.getPosition()); + } + + DarkActivityEntity entity = DarkActivityEntity.builder() + .movementType(dto.getMovementType()) + .imolRorIHSNumber(dto.getImolRorIHSNumber()) + .movementDate(LocalDateTime.parse(dto.getMovementDate())) + .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()) + .position(positionNode) // JsonNode로 매핑 + .eventStartDate(LocalDateTime.parse(dto.getEventStartDate())) + .build(); + + return entity; + } + +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/processor/DestinationProcessor.java b/src/main/java/com/snp/batch/jobs/movement/batch/processor/DestinationProcessor.java new file mode 100644 index 0000000..3550fb5 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/processor/DestinationProcessor.java @@ -0,0 +1,61 @@ +package com.snp.batch.jobs.movement.batch.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.movement.batch.dto.DestinationDto; +import com.snp.batch.jobs.movement.batch.entity.DestinationEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +/** + * 선박 상세 정보 Processor + * ShipDetailDto → ShipDetailEntity 변환 + */ + +/** + * 선박 상세 정보 Processor (해시 비교 및 증분 데이터 추출) + * I: ShipDetailComparisonData (DB 해시 + API Map Data) + * O: ShipDetailUpdate (변경분) + */ +@Slf4j +@Component +public class DestinationProcessor extends BaseProcessor { + + private final ObjectMapper objectMapper; + + public DestinationProcessor(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + protected DestinationEntity processItem(DestinationDto dto) throws Exception { + log.debug("선박 상세 정보 처리 시작: 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()) + .build(); + return entity; + } + +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/processor/PortCallsProcessor.java b/src/main/java/com/snp/batch/jobs/movement/batch/processor/PortCallsProcessor.java new file mode 100644 index 0000000..825c8fc --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/processor/PortCallsProcessor.java @@ -0,0 +1,72 @@ +package com.snp.batch.jobs.movement.batch.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.movement.batch.dto.PortCallsDto; +import com.snp.batch.jobs.movement.batch.entity.PortCallsEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +/** + * 선박 상세 정보 Processor + * ShipDetailDto → ShipDetailEntity 변환 + */ + +/** + * 선박 상세 정보 Processor (해시 비교 및 증분 데이터 추출) + * I: ShipDetailComparisonData (DB 해시 + API Map Data) + * O: ShipDetailUpdate (변경분) + */ +@Slf4j +@Component +public class PortCallsProcessor extends BaseProcessor { + + private final ObjectMapper objectMapper; + + public PortCallsProcessor(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + protected PortCallsEntity processItem(PortCallsDto dto) throws Exception { + log.debug("선박 상세 정보 처리 시작: 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로 매핑 + .schemaType("PORTCALL") // API 타입 구분 + .build(); + + return entity; + } + +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/processor/StsOperationProcessor.java b/src/main/java/com/snp/batch/jobs/movement/batch/processor/StsOperationProcessor.java new file mode 100644 index 0000000..7ecda55 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/processor/StsOperationProcessor.java @@ -0,0 +1,69 @@ +package com.snp.batch.jobs.movement.batch.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.movement.batch.dto.StsOperationDto; +import com.snp.batch.jobs.movement.batch.entity.StsOperationEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +/** + * 선박 상세 정보 Processor + * ShipDetailDto → ShipDetailEntity 변환 + */ + +/** + * 선박 상세 정보 Processor (해시 비교 및 증분 데이터 추출) + * I: ShipDetailComparisonData (DB 해시 + API Map Data) + * O: ShipDetailUpdate (변경분) + */ +@Slf4j +@Component +public class StsOperationProcessor extends BaseProcessor { + + private final ObjectMapper objectMapper; + + public StsOperationProcessor(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + protected StsOperationEntity processItem(StsOperationDto dto) throws Exception { + log.debug("선박 상세 정보 처리 시작: 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())) + .build(); + + return entity; + } + +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/processor/TerminalCallsProcessor.java b/src/main/java/com/snp/batch/jobs/movement/batch/processor/TerminalCallsProcessor.java new file mode 100644 index 0000000..4d5a1e6 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/processor/TerminalCallsProcessor.java @@ -0,0 +1,71 @@ +package com.snp.batch.jobs.movement.batch.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.movement.batch.dto.TerminalCallsDto; +import com.snp.batch.jobs.movement.batch.entity.TerminalCallsEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +/** + * 선박 상세 정보 Processor + * ShipDetailDto → ShipDetailEntity 변환 + */ + +/** + * 선박 상세 정보 Processor (해시 비교 및 증분 데이터 추출) + * I: ShipDetailComparisonData (DB 해시 + API Map Data) + * O: ShipDetailUpdate (변경분) + */ +@Slf4j +@Component +public class TerminalCallsProcessor extends BaseProcessor { + + private final ObjectMapper objectMapper; + + public TerminalCallsProcessor(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + protected TerminalCallsEntity processItem(TerminalCallsDto dto) throws Exception { + log.debug("선박 상세 정보 처리 시작: 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()) + .build(); + + return entity; + } + +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/processor/TransitsProcessor.java b/src/main/java/com/snp/batch/jobs/movement/batch/processor/TransitsProcessor.java new file mode 100644 index 0000000..813a15a --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/processor/TransitsProcessor.java @@ -0,0 +1,47 @@ +package com.snp.batch.jobs.movement.batch.processor; + +import com.snp.batch.common.batch.processor.BaseProcessor; +import com.snp.batch.jobs.movement.batch.dto.TransitsDto; +import com.snp.batch.jobs.movement.batch.entity.TransitsEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +/** + * 선박 상세 정보 Processor + * ShipDetailDto → ShipDetailEntity 변환 + */ + +/** + * 선박 상세 정보 Processor (해시 비교 및 증분 데이터 추출) + * I: ShipDetailComparisonData (DB 해시 + API Map Data) + * O: ShipDetailUpdate (변경분) + */ +@Slf4j +@Component +public class TransitsProcessor extends BaseProcessor { + +// private final ObjectMapper objectMapper; + +// public TransitsProcessor(ObjectMapper objectMapper) { +// this.objectMapper = objectMapper; +// } + + @Override + protected TransitsEntity processItem(TransitsDto dto) throws Exception { + log.debug("선박 상세 정보 처리 시작: 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()) + .build(); + return entity; + } + +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/reader/AnchorageCallsRangeReader.java b/src/main/java/com/snp/batch/jobs/movement/batch/reader/AnchorageCallsRangeReader.java new file mode 100644 index 0000000..bf0fd8d --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/reader/AnchorageCallsRangeReader.java @@ -0,0 +1,113 @@ +package com.snp.batch.jobs.movement.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.movement.batch.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/movement/batch/reader/AnchorageCallsReader.java b/src/main/java/com/snp/batch/jobs/movement/batch/reader/AnchorageCallsReader.java new file mode 100644 index 0000000..187e3f7 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/reader/AnchorageCallsReader.java @@ -0,0 +1,216 @@ +package com.snp.batch.jobs.movement.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.movement.batch.dto.AnchorageCallsDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * 선박 상세 정보 Reader (v2.0 - Chunk 기반) + * + * 기능: + * 1. ship_data 테이블에서 IMO 번호 전체 조회 (최초 1회) + * 2. IMO 번호를 100개씩 분할하여 배치 단위로 처리 + * 3. fetchNextBatch() 호출 시마다 100개씩 API 호출 + * 4. Spring Batch가 100건씩 Process → Write 수행 + * + * Chunk 처리 흐름: + * - beforeFetch() → IMO 전체 조회 (1회) + * - fetchNextBatch() → 100개 IMO로 API 호출 (1,718회) + * - read() → 1건씩 반환 (100번) + * - Processor/Writer → 100건 처리 + * - 반복... (1,718번의 Chunk) + * + * 기존 방식과의 차이: + * - 기존: 17만건 전체 메모리 로드 → Process → Write + * - 신규: 100건씩 로드 → Process → Write (Chunk 1,718회) + */ +@Slf4j +@StepScope +public class AnchorageCallsReader extends BaseApiReader { + + private final JdbcTemplate jdbcTemplate; + + // 배치 처리 상태 + private List allImoNumbers; + // DB 해시값을 저장할 맵 + private Map dbMasterHashes; + private int currentBatchIndex = 0; + private final int batchSize = 5; + + // @Value("#{jobParameters['startDate']}") +// private String startDate; + private String startDate = "2025-01-01"; + + // @Value("#{jobParameters['stopDate']}") +// private String stopDate; + private String stopDate = "2025-12-31"; + + public AnchorageCallsReader(WebClient webClient, JdbcTemplate jdbcTemplate ) { + super(webClient); + this.jdbcTemplate = jdbcTemplate; + enableChunkMode(); // ✨ Chunk 모드 활성화 + } + + @Override + protected String getReaderName() { + return "AnchorageCallsReader"; + } + + @Override + protected void resetCustomState() { + this.currentBatchIndex = 0; + this.allImoNumbers = null; + this.dbMasterHashes = null; + } + + @Override + protected String getApiPath() { + return "/Movements/AnchorageCalls"; + } + + @Override + protected String getApiBaseUrl() { + return "https://webservices.maritime.spglobal.com"; + } + + private static final String GET_ALL_IMO_QUERY = + "SELECT imo_number FROM ship_data ORDER BY id"; +// "SELECT imo_number FROM snp_data.ship_data where imo_number > (select max(imo) from snp_data.t_anchoragecall) ORDER BY imo_number"; + + private static final String FETCH_ALL_HASHES_QUERY = + "SELECT imo_number, ship_detail_hash FROM ship_detail_hash_json ORDER BY imo_number"; + + /** + * 최초 1회만 실행: ship_data 테이블에서 IMO 번호 전체 조회 + */ + @Override + protected void beforeFetch() { + // 전처리 과정 + // Step 1. IMO 전체 번호 조회 + log.info("[{}] ship_data 테이블에서 IMO 번호 조회 시작...", getReaderName()); + + allImoNumbers = jdbcTemplate.queryForList(GET_ALL_IMO_QUERY, String.class); + int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize); + + log.info("[{}] 총 {} 개의 IMO 번호 조회 완료", getReaderName(), allImoNumbers.size()); + log.info("[{}] {}개씩 배치로 분할하여 API 호출 예정", getReaderName(), batchSize); + log.info("[{}] 예상 배치 수: {} 개", getReaderName(), totalBatches); + + // API 통계 초기화 + updateApiCallStats(totalBatches, 0); + } + + /** + * ✨ Chunk 기반 핵심 메서드: 다음 100개 배치를 조회하여 반환 + * + * Spring Batch가 100건씩 read() 호출 완료 후 이 메서드 재호출 + * + * @return 다음 배치 100건 (더 이상 없으면 null) + */ + @Override + protected List fetchNextBatch() throws Exception { + + // 모든 배치 처리 완료 확인 + if (allImoNumbers == null || currentBatchIndex >= allImoNumbers.size()) { + return null; // Job 종료 + } + + // 현재 배치의 시작/끝 인덱스 계산 + int startIndex = currentBatchIndex; + int endIndex = Math.min(currentBatchIndex + batchSize, allImoNumbers.size()); + + // 현재 배치의 IMO 번호 추출 (100개) + 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()); + + try { + // IMO 번호를 쉼표로 연결 (예: "1000019,1000021,1000033,...") + String imoParam = String.join(",", currentBatch); + + // API 호출 + List response = callApiWithBatch(imoParam); + + // 다음 배치로 인덱스 이동 + currentBatchIndex = endIndex; + + + // 응답 처리 + if (response != null ) { + List anchorageCalls = response; + log.info("[{}] 배치 {}/{} 완료: {} 건 조회", + getReaderName(), currentBatchNumber, totalBatches, anchorageCalls.size()); + + // API 호출 통계 업데이트 + updateApiCallStats(totalBatches, currentBatchNumber); + + // API 과부하 방지 (다음 배치 전 0.5초 대기) + if (currentBatchIndex < allImoNumbers.size()) { + Thread.sleep(500); + } + + return anchorageCalls; + + } 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(); + } + } + + /** + * Query Parameter를 사용한 API 호출 + * + * @param lrno 쉼표로 연결된 IMO 번호 (예: "1000019,1000021,...") + * @return API 응답 + */ + private List callApiWithBatch(String lrno) { + String url = getApiPath() + "?startDate=" + startDate +"&stopDate="+stopDate+"&lrno=" + lrno; + + log.debug("[{}] API 호출: {}", getReaderName(), url); + + return webClient.get() + .uri(url) + .retrieve() + .bodyToFlux(AnchorageCallsDto.class) + .collectList() + .block(); + } + + @Override + protected void afterFetch(List data) { + if (data == null) { + int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize); + log.info("[{}] 전체 {} 개 배치 처리 완료", getReaderName(), totalBatches); + log.info("[{}] 총 {} 개의 IMO 번호에 대한 API 호출 종료", + getReaderName(), allImoNumbers.size()); + } + } + +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/reader/BerthCallsRangeReader.java b/src/main/java/com/snp/batch/jobs/movement/batch/reader/BerthCallsRangeReader.java new file mode 100644 index 0000000..a742932 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/reader/BerthCallsRangeReader.java @@ -0,0 +1,112 @@ +package com.snp.batch.jobs.movement.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.movement.batch.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/movement/batch/reader/BerthCallsReader.java b/src/main/java/com/snp/batch/jobs/movement/batch/reader/BerthCallsReader.java new file mode 100644 index 0000000..c626bc2 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/reader/BerthCallsReader.java @@ -0,0 +1,213 @@ +package com.snp.batch.jobs.movement.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.movement.batch.dto.BerthCallsDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * 선박 상세 정보 Reader (v2.0 - Chunk 기반) + * + * 기능: + * 1. ship_data 테이블에서 IMO 번호 전체 조회 (최초 1회) + * 2. IMO 번호를 100개씩 분할하여 배치 단위로 처리 + * 3. fetchNextBatch() 호출 시마다 100개씩 API 호출 + * 4. Spring Batch가 100건씩 Process → Write 수행 + * + * Chunk 처리 흐름: + * - beforeFetch() → IMO 전체 조회 (1회) + * - fetchNextBatch() → 100개 IMO로 API 호출 (1,718회) + * - read() → 1건씩 반환 (100번) + * - Processor/Writer → 100건 처리 + * - 반복... (1,718번의 Chunk) + * + * 기존 방식과의 차이: + * - 기존: 17만건 전체 메모리 로드 → Process → Write + * - 신규: 100건씩 로드 → Process → Write (Chunk 1,718회) + */ +@Slf4j +@StepScope +public class BerthCallsReader extends BaseApiReader { + + private final JdbcTemplate jdbcTemplate; + + // 배치 처리 상태 + private List allImoNumbers; + // DB 해시값을 저장할 맵 + private Map dbMasterHashes; + private int currentBatchIndex = 0; + private final int batchSize = 5; + + // @Value("#{jobParameters['startDate']}") +// private String startDate; + private String startDate = "2025-01-01"; + + // @Value("#{jobParameters['stopDate']}") +// private String stopDate; + private String stopDate = "2025-12-31"; + + public BerthCallsReader(WebClient webClient, JdbcTemplate jdbcTemplate ) { + super(webClient); + this.jdbcTemplate = jdbcTemplate; + enableChunkMode(); // ✨ Chunk 모드 활성화 + } + + @Override + protected String getReaderName() { + return "BerthCallsReader"; + } + + @Override + protected void resetCustomState() { + this.currentBatchIndex = 0; + this.allImoNumbers = null; + this.dbMasterHashes = null; + } + + @Override + protected String getApiPath() { + return "/Movements/BerthCalls"; + } + + @Override + protected String getApiBaseUrl() { + return "https://webservices.maritime.spglobal.com"; + } + + private static final String GET_ALL_IMO_QUERY = + "SELECT imo_number FROM ship_data ORDER BY id"; +// "SELECT imo_number FROM snp_data.ship_data where imo_number > (select max(imo) from snp_data.t_berthcall) ORDER BY imo_number"; + + /** + * 최초 1회만 실행: ship_data 테이블에서 IMO 번호 전체 조회 + */ + @Override + protected void beforeFetch() { + // 전처리 과정 + // Step 1. IMO 전체 번호 조회 + log.info("[{}] ship_data 테이블에서 IMO 번호 조회 시작...", getReaderName()); + + allImoNumbers = jdbcTemplate.queryForList(GET_ALL_IMO_QUERY, String.class); + int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize); + + log.info("[{}] 총 {} 개의 IMO 번호 조회 완료", getReaderName(), allImoNumbers.size()); + log.info("[{}] {}개씩 배치로 분할하여 API 호출 예정", getReaderName(), batchSize); + log.info("[{}] 예상 배치 수: {} 개", getReaderName(), totalBatches); + + // API 통계 초기화 + updateApiCallStats(totalBatches, 0); + } + + /** + * ✨ Chunk 기반 핵심 메서드: 다음 100개 배치를 조회하여 반환 + * + * Spring Batch가 100건씩 read() 호출 완료 후 이 메서드 재호출 + * + * @return 다음 배치 100건 (더 이상 없으면 null) + */ + @Override + protected List fetchNextBatch() throws Exception { + + // 모든 배치 처리 완료 확인 + if (allImoNumbers == null || currentBatchIndex >= allImoNumbers.size()) { + return null; // Job 종료 + } + + // 현재 배치의 시작/끝 인덱스 계산 + int startIndex = currentBatchIndex; + int endIndex = Math.min(currentBatchIndex + batchSize, allImoNumbers.size()); + + // 현재 배치의 IMO 번호 추출 (100개) + 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()); + + try { + // IMO 번호를 쉼표로 연결 (예: "1000019,1000021,1000033,...") + String imoParam = String.join(",", currentBatch); + + // API 호출 + List response = callApiWithBatch(imoParam); + + // 다음 배치로 인덱스 이동 + currentBatchIndex = endIndex; + + + // 응답 처리 + if (response != null ) { + List berthCalls = response; + log.info("[{}] 배치 {}/{} 완료: {} 건 조회", + getReaderName(), currentBatchNumber, totalBatches, berthCalls.size()); + + // API 호출 통계 업데이트 + updateApiCallStats(totalBatches, currentBatchNumber); + + // API 과부하 방지 (다음 배치 전 0.5초 대기) + if (currentBatchIndex < allImoNumbers.size()) { + Thread.sleep(500); + } + + return berthCalls; + + } 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(); + } + } + + /** + * Query Parameter를 사용한 API 호출 + * + * @param lrno 쉼표로 연결된 IMO 번호 (예: "1000019,1000021,...") + * @return API 응답 + */ + private List callApiWithBatch(String lrno) { + String url = getApiPath() + "?startDate=" + startDate +"&stopDate="+stopDate+"&lrno=" + lrno; + + log.debug("[{}] API 호출: {}", getReaderName(), url); + + return webClient.get() + .uri(url) + .retrieve() + .bodyToFlux(BerthCallsDto.class) + .collectList() + .block(); + } + + @Override + protected void afterFetch(List data) { + if (data == null) { + int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize); + log.info("[{}] 전체 {} 개 배치 처리 완료", getReaderName(), totalBatches); + log.info("[{}] 총 {} 개의 IMO 번호에 대한 API 호출 종료", + getReaderName(), allImoNumbers.size()); + } + } + +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/reader/CurrentlyAtRangeReader.java b/src/main/java/com/snp/batch/jobs/movement/batch/reader/CurrentlyAtRangeReader.java new file mode 100644 index 0000000..0827855 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/reader/CurrentlyAtRangeReader.java @@ -0,0 +1,111 @@ +package com.snp.batch.jobs.movement.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.movement.batch.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/movement/batch/reader/CurrentlyAtReader.java b/src/main/java/com/snp/batch/jobs/movement/batch/reader/CurrentlyAtReader.java new file mode 100644 index 0000000..668aa43 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/reader/CurrentlyAtReader.java @@ -0,0 +1,211 @@ +package com.snp.batch.jobs.movement.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.movement.batch.dto.CurrentlyAtDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.Collections; +import java.util.List; + +/** + * 선박 상세 정보 Reader (v2.0 - Chunk 기반) + *

+ * 기능: + * 1. ship_data 테이블에서 IMO 번호 전체 조회 (최초 1회) + * 2. IMO 번호를 100개씩 분할하여 배치 단위로 처리 + * 3. fetchNextBatch() 호출 시마다 100개씩 API 호출 + * 4. Spring Batch가 100건씩 Process → Write 수행 + *

+ * Chunk 처리 흐름: + * - beforeFetch() → IMO 전체 조회 (1회) + * - fetchNextBatch() → 100개 IMO로 API 호출 (1,718회) + * - read() → 1건씩 반환 (100번) + * - Processor/Writer → 100건 처리 + * - 반복... (1,718번의 Chunk) + *

+ * 기존 방식과의 차이: + * - 기존: 17만건 전체 메모리 로드 → Process → Write + * - 신규: 100건씩 로드 → Process → Write (Chunk 1,718회) + */ +@Slf4j +@StepScope +public class CurrentlyAtReader extends BaseApiReader { + + private final JdbcTemplate jdbcTemplate; + + // 배치 처리 상태 + private List allImoNumbers; + // DB 해시값을 저장할 맵 + private int currentBatchIndex = 0; + private final int batchSize = 10; + +// @Value("#{jobParameters['startDate']}") +// private String startDate; +// private String startDate = "2025-01-01"; + +// @Value("#{jobParameters['stopDate']}") +// private String stopDate; +// private String stopDate = "2024-12-31"; + + public CurrentlyAtReader(WebClient webClient, JdbcTemplate jdbcTemplate) { + super(webClient); + this.jdbcTemplate = jdbcTemplate; + enableChunkMode(); // ✨ Chunk 모드 활성화 + } + + @Override + protected String getReaderName() { + return "CurrentlyAtReader"; + } + + @Override + protected void resetCustomState() { + this.currentBatchIndex = 0; + this.allImoNumbers = null; + } + + @Override + protected String getApiPath() { + return "/Movements/CurrentlyAt"; + } + + @Override + protected String getApiBaseUrl() { + return "https://webservices.maritime.spglobal.com"; + } + + private static final String GET_ALL_IMO_QUERY = + "SELECT imo_number FROM ship_data ORDER BY id"; +// "SELECT imo_number FROM snp_data.ship_data where imo_number > (select max(imo) from snp_data.t_currentlyat) ORDER BY imo_number"; + + + /** + * 최초 1회만 실행: ship_data 테이블에서 IMO 번호 전체 조회 + */ + @Override + protected void beforeFetch() { + // 전처리 과정 + // Step 1. IMO 전체 번호 조회 + log.info("[{}] ship_data 테이블에서 IMO 번호 조회 시작...", getReaderName()); + + allImoNumbers = jdbcTemplate.queryForList(GET_ALL_IMO_QUERY, String.class); + int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize); + + log.info("[{}] 총 {} 개의 IMO 번호 조회 완료", getReaderName(), allImoNumbers.size()); + log.info("[{}] {}개씩 배치로 분할하여 API 호출 예정", getReaderName(), batchSize); + log.info("[{}] 예상 배치 수: {} 개", getReaderName(), totalBatches); + + // API 통계 초기화 + updateApiCallStats(totalBatches, 0); + } + + /** + * ✨ Chunk 기반 핵심 메서드: 다음 100개 배치를 조회하여 반환 + *

+ * Spring Batch가 100건씩 read() 호출 완료 후 이 메서드 재호출 + * + * @return 다음 배치 100건 (더 이상 없으면 null) + */ + @Override + protected List fetchNextBatch() throws Exception { + + // 모든 배치 처리 완료 확인 + if (allImoNumbers == null || currentBatchIndex >= allImoNumbers.size()) { + return null; // Job 종료 + } + + // 현재 배치의 시작/끝 인덱스 계산 + int startIndex = currentBatchIndex; + int endIndex = Math.min(currentBatchIndex + batchSize, allImoNumbers.size()); + + // 현재 배치의 IMO 번호 추출 (100개) + 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()); + + try { + // IMO 번호를 쉼표로 연결 (예: "1000019,1000021,1000033,...") + String imoParam = String.join(",", currentBatch); + + // API 호출 + List response = callApiWithBatch(imoParam); + + // 다음 배치로 인덱스 이동 + currentBatchIndex = endIndex; + + + // 응답 처리 + if (response != null) { + List portCalls = response; + log.info("[{}] 배치 {}/{} 완료: {} 건 조회", + getReaderName(), currentBatchNumber, totalBatches, portCalls.size()); + + // API 호출 통계 업데이트 + updateApiCallStats(totalBatches, currentBatchNumber); + + // API 과부하 방지 (다음 배치 전 0.5초 대기) + if (currentBatchIndex < allImoNumbers.size()) { + Thread.sleep(500); + } + + return portCalls; + + } 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(); + } + } + + /** + * Query Parameter를 사용한 API 호출 + * + * @param lrno 쉼표로 연결된 IMO 번호 (예: "1000019,1000021,...") + * @return API 응답 + */ + private List callApiWithBatch(String lrno) { + String url = getApiPath() + "?lrno=" + lrno; + + log.debug("[{}] API 호출: {}", getReaderName(), url); + + return webClient.get() + .uri(url) + .retrieve() + .bodyToFlux(CurrentlyAtDto.class) + .collectList() + .block(); + } + + @Override + protected void afterFetch(List data) { + if (data == null) { + int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize); + log.info("[{}] 전체 {} 개 배치 처리 완료", getReaderName(), totalBatches); + log.info("[{}] 총 {} 개의 IMO 번호에 대한 API 호출 종료", + getReaderName(), allImoNumbers.size()); + } + } + +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/reader/DarkActivityRangeReader.java b/src/main/java/com/snp/batch/jobs/movement/batch/reader/DarkActivityRangeReader.java new file mode 100644 index 0000000..020f381 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/reader/DarkActivityRangeReader.java @@ -0,0 +1,182 @@ +package com.snp.batch.jobs.movement.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.movement.batch.dto.DarkActivityDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.reactive.function.client.WebClient; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +/** + * 선박 상세 정보 Reader (v2.0 - Chunk 기반) + * + * 기능: + * 1. ship_data 테이블에서 IMO 번호 전체 조회 (최초 1회) + * 2. IMO 번호를 100개씩 분할하여 배치 단위로 처리 + * 3. fetchNextBatch() 호출 시마다 100개씩 API 호출 + * 4. Spring Batch가 100건씩 Process → Write 수행 + * + * Chunk 처리 흐름: + * - beforeFetch() → IMO 전체 조회 (1회) + * - fetchNextBatch() → 100개 IMO로 API 호출 (1,718회) + * - read() → 1건씩 반환 (100번) + * - Processor/Writer → 100건 처리 + * - 반복... (1,718번의 Chunk) + * + * 기존 방식과의 차이: + * - 기존: 17만건 전체 메모리 로드 → Process → Write + * - 신규: 100건씩 로드 → Process → Write (Chunk 1,718회) + */ +@Slf4j +@StepScope +public class DarkActivityRangeReader extends BaseApiReader { + + + private List allData; + // DB 해시값을 저장할 맵 + private int currentBatchIndex = 0; + private final int batchSize = 5000; + + // @Value("#{jobParameters['startDate']}") + private String startDate; +// private String startDate = "2025-01-01"; + + // @Value("#{jobParameters['stopDate']}") + private String stopDate; +// private String stopDate = "2025-12-31"; + + /*public DarkActivityRangeReader(WebClient webClient) { + super(webClient); + enableChunkMode(); // ✨ Chunk 모드 활성화 + }*/ + public DarkActivityRangeReader(WebClient webClient, + @Value("#{jobParameters['startDate']}") String startDate, + @Value("#{jobParameters['stopDate']}") String stopDate) { + super(webClient); + // 날짜가 없으면 전날 하루 기준 + if (startDate == null || startDate.isBlank() || stopDate == null || stopDate.isBlank()) { + LocalDate yesterday = LocalDate.now().minusDays(1); + this.startDate = yesterday.atStartOfDay().format(DateTimeFormatter.ISO_DATE_TIME) + "Z"; + this.stopDate = yesterday.plusDays(1).atStartOfDay().format(DateTimeFormatter.ISO_DATE_TIME) + "Z"; + } else { + this.startDate = startDate; + this.stopDate = stopDate; + } + + enableChunkMode(); // ✨ Chunk 모드 활성화 + } + + @Override + protected String getReaderName() { + return "DarkActivityReader"; + } + + @Override + protected void resetCustomState() { + this.currentBatchIndex = 0; + this.allData = null; + } + + @Override + protected String getApiPath() { + return "/Movements/DarkActivity"; + } + + @Override + protected String getApiBaseUrl() { + return "https://webservices.maritime.spglobal.com"; + } + + private static final String GET_ALL_IMO_QUERY = + "SELECT imo_number FROM ship_data ORDER BY id"; +// "SELECT imo_number FROM snp_data.ship_data where imo_number > (select max(imo) from snp_data.t_darkactivity) ORDER BY imo_number"; + + /** + * 최초 1회만 실행: ship_data 테이블에서 IMO 번호 전체 조회 + */ + @Override + protected void beforeFetch() { + log.info("[{}] 요청 날짜 범위: {} → {}", getReaderName(), startDate, stopDate); + } + + /** + * ✨ Chunk 기반 핵심 메서드: 다음 100개 배치를 조회하여 반환 + * + * Spring Batch가 100건씩 read() 호출 완료 후 이 메서드 재호출 + * + * @return 다음 배치 100건 (더 이상 없으면 null) + */ + @Override + protected List fetchNextBatch() throws Exception { + + // 모든 배치 처리 완료 확인 + if (allData == null ) { + log.info("[{}] 최초 API 조회 실행: {} ~ {}", getReaderName(), startDate, stopDate); + allData = callApiWithBatch(startDate, stopDate); + + 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 호출 + * + * @param startDate,stopDate + * @return API 응답 + */ + private List callApiWithBatch(String startDate, String stopDate){ + String url = getApiPath() + "?startDate=" + startDate +"&stopDate="+stopDate; +// +"&lrno=" + lrno; + + log.debug("[{}] API 호출: {}", getReaderName(), url); + + return webClient.get() + .uri(url) + .retrieve() + .bodyToFlux(DarkActivityDto.class) + .collectList() + .block(); + } + + @Override + protected void afterFetch(List data) { + if (data == null) { + int totalBatches = (int) Math.ceil((double) allData.size() / batchSize); + log.info("[{}] 전체 {} 개 배치 처리 완료", getReaderName(), totalBatches); + /* log.info("[{}] 총 {} 개의 IMO 번호에 대한 API 호출 종료", + getReaderName(), allData.size());*/ + } + } + +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/reader/DarkActivityReader.java b/src/main/java/com/snp/batch/jobs/movement/batch/reader/DarkActivityReader.java new file mode 100644 index 0000000..af2cab9 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/reader/DarkActivityReader.java @@ -0,0 +1,212 @@ +package com.snp.batch.jobs.movement.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.movement.batch.dto.DarkActivityDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * 선박 상세 정보 Reader (v2.0 - Chunk 기반) + * + * 기능: + * 1. ship_data 테이블에서 IMO 번호 전체 조회 (최초 1회) + * 2. IMO 번호를 100개씩 분할하여 배치 단위로 처리 + * 3. fetchNextBatch() 호출 시마다 100개씩 API 호출 + * 4. Spring Batch가 100건씩 Process → Write 수행 + * + * Chunk 처리 흐름: + * - beforeFetch() → IMO 전체 조회 (1회) + * - fetchNextBatch() → 100개 IMO로 API 호출 (1,718회) + * - read() → 1건씩 반환 (100번) + * - Processor/Writer → 100건 처리 + * - 반복... (1,718번의 Chunk) + * + * 기존 방식과의 차이: + * - 기존: 17만건 전체 메모리 로드 → Process → Write + * - 신규: 100건씩 로드 → Process → Write (Chunk 1,718회) + */ +@Slf4j +@StepScope +public class DarkActivityReader extends BaseApiReader { + + private final JdbcTemplate jdbcTemplate; + + // 배치 처리 상태 + private List allImoNumbers; + // DB 해시값을 저장할 맵 + private Map dbMasterHashes; + private int currentBatchIndex = 0; + private final int batchSize = 5; + + // @Value("#{jobParameters['startDate']}") +// private String startDate; + private String startDate = "2025-01-01"; + + // @Value("#{jobParameters['stopDate']}") +// private String stopDate; + private String stopDate = "2025-12-31"; + + public DarkActivityReader(WebClient webClient, JdbcTemplate jdbcTemplate ) { + super(webClient); + this.jdbcTemplate = jdbcTemplate; + enableChunkMode(); // ✨ Chunk 모드 활성화 + } + + @Override + protected String getReaderName() { + return "DarkActivityReader"; + } + + @Override + protected void resetCustomState() { + this.currentBatchIndex = 0; + this.allImoNumbers = null; + this.dbMasterHashes = null; + } + + @Override + protected String getApiPath() { + return "/Movements/DarkActivity"; + } + + @Override + protected String getApiBaseUrl() { + return "https://webservices.maritime.spglobal.com"; + } + + private static final String GET_ALL_IMO_QUERY = + "SELECT imo_number FROM ship_data ORDER BY id"; +// "SELECT imo_number FROM snp_data.ship_data where imo_number > (select max(imo) from snp_data.t_darkactivity) ORDER BY imo_number"; + + /** + * 최초 1회만 실행: ship_data 테이블에서 IMO 번호 전체 조회 + */ + @Override + protected void beforeFetch() { + // 전처리 과정 + // Step 1. IMO 전체 번호 조회 + log.info("[{}] ship_data 테이블에서 IMO 번호 조회 시작...", getReaderName()); + + allImoNumbers = jdbcTemplate.queryForList(GET_ALL_IMO_QUERY, String.class); + int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize); + + log.info("[{}] 총 {} 개의 IMO 번호 조회 완료", getReaderName(), allImoNumbers.size()); + log.info("[{}] {}개씩 배치로 분할하여 API 호출 예정", getReaderName(), batchSize); + log.info("[{}] 예상 배치 수: {} 개", getReaderName(), totalBatches); + + // API 통계 초기화 + updateApiCallStats(totalBatches, 0); + } + + /** + * ✨ Chunk 기반 핵심 메서드: 다음 100개 배치를 조회하여 반환 + * + * Spring Batch가 100건씩 read() 호출 완료 후 이 메서드 재호출 + * + * @return 다음 배치 100건 (더 이상 없으면 null) + */ + @Override + protected List fetchNextBatch() throws Exception { + + // 모든 배치 처리 완료 확인 + if (allImoNumbers == null || currentBatchIndex >= allImoNumbers.size()) { + return null; // Job 종료 + } + + // 현재 배치의 시작/끝 인덱스 계산 + int startIndex = currentBatchIndex; + int endIndex = Math.min(currentBatchIndex + batchSize, allImoNumbers.size()); + + // 현재 배치의 IMO 번호 추출 (100개) + 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()); + + try { + // IMO 번호를 쉼표로 연결 (예: "1000019,1000021,1000033,...") + String imoParam = String.join(",", currentBatch); + // API 호출 + List response = callApiWithBatch(imoParam); + + // 다음 배치로 인덱스 이동 + currentBatchIndex = endIndex; + + + // 응답 처리 + if (response != null ) { + List darkActivityList = response; + log.info("[{}] 배치 {}/{} 완료: {} 건 조회", + getReaderName(), currentBatchNumber, totalBatches, darkActivityList.size()); + + // API 호출 통계 업데이트 + updateApiCallStats(totalBatches, currentBatchNumber); + + // API 과부하 방지 (다음 배치 전 0.5초 대기) + if (currentBatchIndex < allImoNumbers.size()) { + Thread.sleep(500); + } + + return darkActivityList; + + } 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(); + } + } + + /** + * Query Parameter를 사용한 API 호출 + * + * @param lrno 쉼표로 연결된 IMO 번호 (예: "1000019,1000021,...") + * @return API 응답 + */ + private List callApiWithBatch(String lrno) { + String url = getApiPath() + "?startDate=" + startDate +"&stopDate="+stopDate+"&lrno=" + lrno; + + log.debug("[{}] API 호출: {}", getReaderName(), url); + + return webClient.get() + .uri(url) + .retrieve() + .bodyToFlux(DarkActivityDto.class) + .collectList() + .block(); + } + + @Override + protected void afterFetch(List data) { + if (data == null) { + int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize); + log.info("[{}] 전체 {} 개 배치 처리 완료", getReaderName(), totalBatches); + log.info("[{}] 총 {} 개의 IMO 번호에 대한 API 호출 종료", + getReaderName(), allImoNumbers.size()); + } + } + +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/reader/DestinationRangeReader.java b/src/main/java/com/snp/batch/jobs/movement/batch/reader/DestinationRangeReader.java new file mode 100644 index 0000000..2c43ec0 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/reader/DestinationRangeReader.java @@ -0,0 +1,109 @@ +package com.snp.batch.jobs.movement.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.movement.batch.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/movement/batch/reader/DestinationReader.java b/src/main/java/com/snp/batch/jobs/movement/batch/reader/DestinationReader.java new file mode 100644 index 0000000..3727b1c --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/reader/DestinationReader.java @@ -0,0 +1,210 @@ +package com.snp.batch.jobs.movement.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.movement.batch.dto.DestinationDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.Collections; +import java.util.List; + +/** + * 선박 상세 정보 Reader (v2.0 - Chunk 기반) + * + * 기능: + * 1. ship_data 테이블에서 IMO 번호 전체 조회 (최초 1회) + * 2. IMO 번호를 100개씩 분할하여 배치 단위로 처리 + * 3. fetchNextBatch() 호출 시마다 100개씩 API 호출 + * 4. Spring Batch가 100건씩 Process → Write 수행 + * + * Chunk 처리 흐름: + * - beforeFetch() → IMO 전체 조회 (1회) + * - fetchNextBatch() → 100개 IMO로 API 호출 (1,718회) + * - read() → 1건씩 반환 (100번) + * - Processor/Writer → 100건 처리 + * - 반복... (1,718번의 Chunk) + * + * 기존 방식과의 차이: + * - 기존: 17만건 전체 메모리 로드 → Process → Write + * - 신규: 100건씩 로드 → Process → Write (Chunk 1,718회) + */ +@Slf4j +@StepScope +public class DestinationReader extends BaseApiReader { + + private final JdbcTemplate jdbcTemplate; + + // 배치 처리 상태 + private List allImoNumbers; + // DB 해시값을 저장할 맵 + private int currentBatchIndex = 0; + private final int batchSize = 5; + + // @Value("#{jobParameters['startDate']}") +// private String startDate; + private String startDate = "2025-01-01"; + + // @Value("#{jobParameters['stopDate']}") +// private String stopDate; + private String stopDate = "2025-12-31"; + + public DestinationReader(WebClient webClient, JdbcTemplate jdbcTemplate ) { + super(webClient); + this.jdbcTemplate = jdbcTemplate; + enableChunkMode(); // ✨ Chunk 모드 활성화 + } + + @Override + protected String getReaderName() { + return "Destinations"; + } + + @Override + protected void resetCustomState() { + this.currentBatchIndex = 0; + this.allImoNumbers = null; + } + + @Override + protected String getApiPath() { + return "/Movements/Destinations"; + } + + @Override + protected String getApiBaseUrl() { + return "https://webservices.maritime.spglobal.com"; + } + + private static final String GET_ALL_IMO_QUERY = + "SELECT imo_number FROM ship_data ORDER BY id"; +// "SELECT imo_number FROM snp_data.ship_data where imo_number > (select max(imo) from snp_data.t_destination) ORDER BY imo_number"; + + /** + * 최초 1회만 실행: ship_data 테이블에서 IMO 번호 전체 조회 + */ + @Override + protected void beforeFetch() { + // 전처리 과정 + // Step 1. IMO 전체 번호 조회 + log.info("[{}] ship_data 테이블에서 IMO 번호 조회 시작...", getReaderName()); + + allImoNumbers = jdbcTemplate.queryForList(GET_ALL_IMO_QUERY, String.class); + int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize); + + log.info("[{}] 총 {} 개의 IMO 번호 조회 완료", getReaderName(), allImoNumbers.size()); + log.info("[{}] {}개씩 배치로 분할하여 API 호출 예정", getReaderName(), batchSize); + log.info("[{}] 예상 배치 수: {} 개", getReaderName(), totalBatches); + + // API 통계 초기화 + updateApiCallStats(totalBatches, 0); + } + + /** + * ✨ Chunk 기반 핵심 메서드: 다음 100개 배치를 조회하여 반환 + * + * Spring Batch가 100건씩 read() 호출 완료 후 이 메서드 재호출 + * + * @return 다음 배치 100건 (더 이상 없으면 null) + */ + @Override + protected List fetchNextBatch() throws Exception { + + // 모든 배치 처리 완료 확인 + if (allImoNumbers == null || currentBatchIndex >= allImoNumbers.size()) { + return null; // Job 종료 + } + + // 현재 배치의 시작/끝 인덱스 계산 + int startIndex = currentBatchIndex; + int endIndex = Math.min(currentBatchIndex + batchSize, allImoNumbers.size()); + + // 현재 배치의 IMO 번호 추출 (100개) + 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()); + + try { + // IMO 번호를 쉼표로 연결 (예: "1000019,1000021,1000033,...") + String imoParam = String.join(",", currentBatch); + + // API 호출 + List response = callApiWithBatch(imoParam); + + // 다음 배치로 인덱스 이동 + currentBatchIndex = endIndex; + + + // 응답 처리 + if (response != null ) { + List destinations = response; + log.info("[{}] 배치 {}/{} 완료: {} 건 조회", + getReaderName(), currentBatchNumber, totalBatches, destinations.size()); + + // API 호출 통계 업데이트 + updateApiCallStats(totalBatches, currentBatchNumber); + + // API 과부하 방지 (다음 배치 전 0.5초 대기) + if (currentBatchIndex < allImoNumbers.size()) { + Thread.sleep(500); + } + + return destinations; + + } 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(); + } + } + + /** + * Query Parameter를 사용한 API 호출 + * + * @param lrno 쉼표로 연결된 IMO 번호 (예: "1000019,1000021,...") + * @return API 응답 + */ + private List callApiWithBatch(String lrno) { + String url = getApiPath() + "?startDate=" + startDate +"&stopDate="+stopDate+"&lrno=" + lrno; + + log.debug("[{}] API 호출: {}", getReaderName(), url); + + return webClient.get() + .uri(url) + .retrieve() + .bodyToFlux(DestinationDto.class) + .collectList() + .block(); + } + + @Override + protected void afterFetch(List data) { + if (data == null) { + int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize); + log.info("[{}] 전체 {} 개 배치 처리 완료", getReaderName(), totalBatches); + log.info("[{}] 총 {} 개의 IMO 번호에 대한 API 호출 종료", + getReaderName(), allImoNumbers.size()); + } + } + +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/reader/PortCallsRangeReader.java b/src/main/java/com/snp/batch/jobs/movement/batch/reader/PortCallsRangeReader.java new file mode 100644 index 0000000..aad0ebf --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/reader/PortCallsRangeReader.java @@ -0,0 +1,113 @@ +package com.snp.batch.jobs.movement.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.movement.batch.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/movement/batch/reader/PortCallsReader.java b/src/main/java/com/snp/batch/jobs/movement/batch/reader/PortCallsReader.java new file mode 100644 index 0000000..65b80fd --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/reader/PortCallsReader.java @@ -0,0 +1,216 @@ +package com.snp.batch.jobs.movement.batch.reader; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.movement.batch.dto.PortCallsDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.*; + +/** + * 선박 상세 정보 Reader (v2.0 - Chunk 기반) + * + * 기능: + * 1. ship_data 테이블에서 IMO 번호 전체 조회 (최초 1회) + * 2. IMO 번호를 100개씩 분할하여 배치 단위로 처리 + * 3. fetchNextBatch() 호출 시마다 100개씩 API 호출 + * 4. Spring Batch가 100건씩 Process → Write 수행 + * + * Chunk 처리 흐름: + * - beforeFetch() → IMO 전체 조회 (1회) + * - fetchNextBatch() → 100개 IMO로 API 호출 (1,718회) + * - read() → 1건씩 반환 (100번) + * - Processor/Writer → 100건 처리 + * - 반복... (1,718번의 Chunk) + * + * 기존 방식과의 차이: + * - 기존: 17만건 전체 메모리 로드 → Process → Write + * - 신규: 100건씩 로드 → Process → Write (Chunk 1,718회) + */ +@Slf4j +@StepScope +public class PortCallsReader extends BaseApiReader { + + private final JdbcTemplate jdbcTemplate; + private final ObjectMapper objectMapper; + + // 배치 처리 상태 + private List allImoNumbers; + // DB 해시값을 저장할 맵 + private Map dbMasterHashes; + private int currentBatchIndex = 0; + private final int batchSize = 10; + + // @Value("#{jobParameters['startDate']}") +// private String startDate; + private String startDate = "2025-01-01"; + + // @Value("#{jobParameters['stopDate']}") +// private String stopDate; + private String stopDate = "2025-12-31"; + public void setStartDate(String startDate) {this.startDate = startDate;} + public void setStopDate(String stopDate){this.stopDate=stopDate;} + public PortCallsReader(WebClient webClient, JdbcTemplate jdbcTemplate, ObjectMapper objectMapper) { + super(webClient); + this.jdbcTemplate = jdbcTemplate; + this.objectMapper = objectMapper; + enableChunkMode(); // ✨ Chunk 모드 활성화 + } + + @Override + protected String getReaderName() { + return "ShipMovementReader"; + } + + @Override + protected void resetCustomState() { + this.currentBatchIndex = 0; + this.allImoNumbers = null; + this.dbMasterHashes = null; + } + + @Override + protected String getApiPath() { + return "/Movements/PortCalls"; + } + + @Override + protected String getApiBaseUrl() { + return "https://webservices.maritime.spglobal.com"; + } + + private static final String GET_ALL_IMO_QUERY = + "SELECT imo_number FROM ship_data ORDER BY id"; +// "SELECT imo_number FROM snp_data.ship_data where imo_number > (select max(imo) from snp_data.t_ship_stpov_info) ORDER BY imo_number"; + + /** + * 최초 1회만 실행: ship_data 테이블에서 IMO 번호 전체 조회 + */ + @Override + protected void beforeFetch() { + // 전처리 과정 + // Step 1. IMO 전체 번호 조회 + log.info("[{}] ship_data 테이블에서 IMO 번호 조회 시작...", getReaderName()); + + allImoNumbers = jdbcTemplate.queryForList(GET_ALL_IMO_QUERY, String.class); + int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize); + + log.info("[{}] 총 {} 개의 IMO 번호 조회 완료", getReaderName(), allImoNumbers.size()); + log.info("[{}] {}개씩 배치로 분할하여 API 호출 예정", getReaderName(), batchSize); + log.info("[{}] 예상 배치 수: {} 개", getReaderName(), totalBatches); + + // API 통계 초기화 + updateApiCallStats(totalBatches, 0); + } + + /** + * ✨ Chunk 기반 핵심 메서드: 다음 100개 배치를 조회하여 반환 + * + * Spring Batch가 100건씩 read() 호출 완료 후 이 메서드 재호출 + * + * @return 다음 배치 100건 (더 이상 없으면 null) + */ + @Override + protected List fetchNextBatch() throws Exception { + + // 모든 배치 처리 완료 확인 + if (allImoNumbers == null || currentBatchIndex >= allImoNumbers.size()) { + return null; // Job 종료 + } + + // 현재 배치의 시작/끝 인덱스 계산 + int startIndex = currentBatchIndex; + int endIndex = Math.min(currentBatchIndex + batchSize, allImoNumbers.size()); + + // 현재 배치의 IMO 번호 추출 (100개) + 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()); + + try { + // IMO 번호를 쉼표로 연결 (예: "1000019,1000021,1000033,...") + String imoParam = String.join(",", currentBatch); + + // API 호출 +// ShipMovementApiResponse response = callApiWithBatch(imoParam); + List response= callApiWithBatch(imoParam); + + // 다음 배치로 인덱스 이동 + currentBatchIndex = endIndex; + + + // 응답 처리 + if (response != null) { + List portCalls = response; + log.info("[{}] 배치 {}/{} 완료: {} 건 조회", + getReaderName(), currentBatchNumber, totalBatches, portCalls.size()); + + // API 호출 통계 업데이트 + updateApiCallStats(totalBatches, currentBatchNumber); + + // API 과부하 방지 (다음 배치 전 0.5초 대기) + if (currentBatchIndex < allImoNumbers.size()) { + Thread.sleep(500); + } + + return portCalls; + + } 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(); + } + } + + /** + * Query Parameter를 사용한 API 호출 + * + * @param lrno 쉼표로 연결된 IMO 번호 (예: "1000019,1000021,...") + * @return API 응답 + */ + private List callApiWithBatch(String lrno) { + String url = getApiPath() + "?startDate=" + startDate +"&stopDate="+stopDate+"&lrno=" + lrno; + + log.debug("[{}] API 호출: {}", getReaderName(), url); + + return webClient.get() + .uri(url) + .retrieve() + .bodyToFlux(PortCallsDto.class) + .collectList() + .block(); + } + + @Override + protected void afterFetch(List data) { + if (data == null) { + int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize); + log.info("[{}] 전체 {} 개 배치 처리 완료", getReaderName(), totalBatches); + log.info("[{}] 총 {} 개의 IMO 번호에 대한 API 호출 종료", + getReaderName(), allImoNumbers.size()); + } + } + +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/reader/StsOperationRangeReader.java b/src/main/java/com/snp/batch/jobs/movement/batch/reader/StsOperationRangeReader.java new file mode 100644 index 0000000..b3640bf --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/reader/StsOperationRangeReader.java @@ -0,0 +1,116 @@ +package com.snp.batch.jobs.movement.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.movement.batch.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/movement/batch/reader/StsOperationReader.java b/src/main/java/com/snp/batch/jobs/movement/batch/reader/StsOperationReader.java new file mode 100644 index 0000000..f844d3e --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/reader/StsOperationReader.java @@ -0,0 +1,213 @@ +package com.snp.batch.jobs.movement.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.movement.batch.dto.StsOperationDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * 선박 상세 정보 Reader (v2.0 - Chunk 기반) + * + * 기능: + * 1. ship_data 테이블에서 IMO 번호 전체 조회 (최초 1회) + * 2. IMO 번호를 100개씩 분할하여 배치 단위로 처리 + * 3. fetchNextBatch() 호출 시마다 100개씩 API 호출 + * 4. Spring Batch가 100건씩 Process → Write 수행 + * + * Chunk 처리 흐름: + * - beforeFetch() → IMO 전체 조회 (1회) + * - fetchNextBatch() → 100개 IMO로 API 호출 (1,718회) + * - read() → 1건씩 반환 (100번) + * - Processor/Writer → 100건 처리 + * - 반복... (1,718번의 Chunk) + * + * 기존 방식과의 차이: + * - 기존: 17만건 전체 메모리 로드 → Process → Write + * - 신규: 100건씩 로드 → Process → Write (Chunk 1,718회) + */ +@Slf4j +@StepScope +public class StsOperationReader extends BaseApiReader { + + private final JdbcTemplate jdbcTemplate; + + // 배치 처리 상태 + private List allImoNumbers; + // DB 해시값을 저장할 맵 + private Map dbMasterHashes; + private int currentBatchIndex = 0; + private final int batchSize = 5; + + // @Value("#{jobParameters['startDate']}") +// private String startDate; + private String startDate = "2025-01-01"; + + // @Value("#{jobParameters['stopDate']}") +// private String stopDate; + private String stopDate = "2025-12-31"; + + public StsOperationReader(WebClient webClient, JdbcTemplate jdbcTemplate ) { + super(webClient); + this.jdbcTemplate = jdbcTemplate; + enableChunkMode(); // ✨ Chunk 모드 활성화 + } + + @Override + protected String getReaderName() { + return "StsOperationReader"; + } + + @Override + protected void resetCustomState() { + this.currentBatchIndex = 0; + this.allImoNumbers = null; + this.dbMasterHashes = null; + } + + @Override + protected String getApiPath() { + return "/Movements/StsOperations"; + } + + @Override + protected String getApiBaseUrl() { + return "https://webservices.maritime.spglobal.com"; + } + + private static final String GET_ALL_IMO_QUERY = + "SELECT imo_number FROM ship_data ORDER BY id"; +// "SELECT imo_number FROM snp_data.ship_data where imo_number > (select max(imo) from snp_data.t_stsoperation) ORDER BY imo_number"; + + /** + * 최초 1회만 실행: ship_data 테이블에서 IMO 번호 전체 조회 + */ + @Override + protected void beforeFetch() { + // 전처리 과정 + // Step 1. IMO 전체 번호 조회 + log.info("[{}] ship_data 테이블에서 IMO 번호 조회 시작...", getReaderName()); + + allImoNumbers = jdbcTemplate.queryForList(GET_ALL_IMO_QUERY, String.class); + int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize); + + log.info("[{}] 총 {} 개의 IMO 번호 조회 완료", getReaderName(), allImoNumbers.size()); + log.info("[{}] {}개씩 배치로 분할하여 API 호출 예정", getReaderName(), batchSize); + log.info("[{}] 예상 배치 수: {} 개", getReaderName(), totalBatches); + + // API 통계 초기화 + updateApiCallStats(totalBatches, 0); + } + + /** + * ✨ Chunk 기반 핵심 메서드: 다음 100개 배치를 조회하여 반환 + * + * Spring Batch가 100건씩 read() 호출 완료 후 이 메서드 재호출 + * + * @return 다음 배치 100건 (더 이상 없으면 null) + */ + @Override + protected List fetchNextBatch() throws Exception { + + // 모든 배치 처리 완료 확인 + if (allImoNumbers == null || currentBatchIndex >= allImoNumbers.size()) { + return null; // Job 종료 + } + + // 현재 배치의 시작/끝 인덱스 계산 + int startIndex = currentBatchIndex; + int endIndex = Math.min(currentBatchIndex + batchSize, allImoNumbers.size()); + + // 현재 배치의 IMO 번호 추출 (100개) + 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()); + + try { + // IMO 번호를 쉼표로 연결 (예: "1000019,1000021,1000033,...") + String imoParam = String.join(",", currentBatch); + + // API 호출 + List response = callApiWithBatch(imoParam); + + // 다음 배치로 인덱스 이동 + currentBatchIndex = endIndex; + + + // 응답 처리 + if (response != null ) { + List responseList = response; + log.info("[{}] 배치 {}/{} 완료: {} 건 조회", + getReaderName(), currentBatchNumber, totalBatches, responseList.size()); + + // API 호출 통계 업데이트 + updateApiCallStats(totalBatches, currentBatchNumber); + + // API 과부하 방지 (다음 배치 전 0.5초 대기) + if (currentBatchIndex < allImoNumbers.size()) { + Thread.sleep(500); + } + + return responseList; + + } 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(); + } + } + + /** + * Query Parameter를 사용한 API 호출 + * + * @param lrno 쉼표로 연결된 IMO 번호 (예: "1000019,1000021,...") + * @return API 응답 + */ + private List callApiWithBatch(String lrno) { + String url = getApiPath() + "?startDate=" + startDate +"&stopDate="+stopDate+"&lrno=" + lrno; + + log.debug("[{}] API 호출: {}", getReaderName(), url); + + return webClient.get() + .uri(url) + .retrieve() + .bodyToFlux(StsOperationDto.class) + .collectList() + .block(); + } + + @Override + protected void afterFetch(List data) { + if (data == null) { + int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize); + log.info("[{}] 전체 {} 개 배치 처리 완료", getReaderName(), totalBatches); + log.info("[{}] 총 {} 개의 IMO 번호에 대한 API 호출 종료", + getReaderName(), allImoNumbers.size()); + } + } + +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/reader/TerminalCallsRangeReader.java b/src/main/java/com/snp/batch/jobs/movement/batch/reader/TerminalCallsRangeReader.java new file mode 100644 index 0000000..49e7704 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/reader/TerminalCallsRangeReader.java @@ -0,0 +1,110 @@ +package com.snp.batch.jobs.movement.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.movement.batch.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/movement/batch/reader/TerminalCallsReader.java b/src/main/java/com/snp/batch/jobs/movement/batch/reader/TerminalCallsReader.java new file mode 100644 index 0000000..65cfe48 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/reader/TerminalCallsReader.java @@ -0,0 +1,213 @@ +package com.snp.batch.jobs.movement.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.movement.batch.dto.TerminalCallsDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * 선박 상세 정보 Reader (v2.0 - Chunk 기반) + * + * 기능: + * 1. ship_data 테이블에서 IMO 번호 전체 조회 (최초 1회) + * 2. IMO 번호를 100개씩 분할하여 배치 단위로 처리 + * 3. fetchNextBatch() 호출 시마다 100개씩 API 호출 + * 4. Spring Batch가 100건씩 Process → Write 수행 + * + * Chunk 처리 흐름: + * - beforeFetch() → IMO 전체 조회 (1회) + * - fetchNextBatch() → 100개 IMO로 API 호출 (1,718회) + * - read() → 1건씩 반환 (100번) + * - Processor/Writer → 100건 처리 + * - 반복... (1,718번의 Chunk) + * + * 기존 방식과의 차이: + * - 기존: 17만건 전체 메모리 로드 → Process → Write + * - 신규: 100건씩 로드 → Process → Write (Chunk 1,718회) + */ +@Slf4j +@StepScope +public class TerminalCallsReader extends BaseApiReader { + + private final JdbcTemplate jdbcTemplate; + + // 배치 처리 상태 + private List allImoNumbers; + // DB 해시값을 저장할 맵 + private Map dbMasterHashes; + private int currentBatchIndex = 0; + private final int batchSize = 5; + + // @Value("#{jobParameters['startDate']}") +// private String startDate; + private String startDate = "2025-01-01"; + + // @Value("#{jobParameters['stopDate']}") +// private String stopDate; + private String stopDate = "2025-12-31"; + + public TerminalCallsReader(WebClient webClient, JdbcTemplate jdbcTemplate ) { + super(webClient); + this.jdbcTemplate = jdbcTemplate; + enableChunkMode(); // ✨ Chunk 모드 활성화 + } + + @Override + protected String getReaderName() { + return "TerminalCalls"; + } + + @Override + protected void resetCustomState() { + this.currentBatchIndex = 0; + this.allImoNumbers = null; + this.dbMasterHashes = null; + } + + @Override + protected String getApiPath() { + return "/Movements/TerminalCalls"; + } + + @Override + protected String getApiBaseUrl() { + return "https://webservices.maritime.spglobal.com"; + } + + private static final String GET_ALL_IMO_QUERY = + "SELECT imo_number FROM ship_data ORDER BY id"; +// "SELECT imo_number FROM snp_data.ship_data where imo_number > (select max(imo) from snp_data.t_terminalcall) ORDER BY imo_number"; + + /** + * 최초 1회만 실행: ship_data 테이블에서 IMO 번호 전체 조회 + */ + @Override + protected void beforeFetch() { + // 전처리 과정 + // Step 1. IMO 전체 번호 조회 + log.info("[{}] ship_data 테이블에서 IMO 번호 조회 시작...", getReaderName()); + + allImoNumbers = jdbcTemplate.queryForList(GET_ALL_IMO_QUERY, String.class); + int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize); + + log.info("[{}] 총 {} 개의 IMO 번호 조회 완료", getReaderName(), allImoNumbers.size()); + log.info("[{}] {}개씩 배치로 분할하여 API 호출 예정", getReaderName(), batchSize); + log.info("[{}] 예상 배치 수: {} 개", getReaderName(), totalBatches); + + // API 통계 초기화 + updateApiCallStats(totalBatches, 0); + } + + /** + * ✨ Chunk 기반 핵심 메서드: 다음 100개 배치를 조회하여 반환 + * + * Spring Batch가 100건씩 read() 호출 완료 후 이 메서드 재호출 + * + * @return 다음 배치 100건 (더 이상 없으면 null) + */ + @Override + protected List fetchNextBatch() throws Exception { + + // 모든 배치 처리 완료 확인 + if (allImoNumbers == null || currentBatchIndex >= allImoNumbers.size()) { + return null; // Job 종료 + } + + // 현재 배치의 시작/끝 인덱스 계산 + int startIndex = currentBatchIndex; + int endIndex = Math.min(currentBatchIndex + batchSize, allImoNumbers.size()); + + // 현재 배치의 IMO 번호 추출 (100개) + 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()); + + try { + // IMO 번호를 쉼표로 연결 (예: "1000019,1000021,1000033,...") + String imoParam = String.join(",", currentBatch); + + // API 호출 + List response = callApiWithBatch(imoParam); + + // 다음 배치로 인덱스 이동 + currentBatchIndex = endIndex; + + + // 응답 처리 + if (response != null ) { + List terminalCalls = response; + log.info("[{}] 배치 {}/{} 완료: {} 건 조회", + getReaderName(), currentBatchNumber, totalBatches, terminalCalls.size()); + + // API 호출 통계 업데이트 + updateApiCallStats(totalBatches, currentBatchNumber); + + // API 과부하 방지 (다음 배치 전 0.5초 대기) + if (currentBatchIndex < allImoNumbers.size()) { + Thread.sleep(500); + } + + return terminalCalls; + + } 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(); + } + } + + /** + * Query Parameter를 사용한 API 호출 + * + * @param lrno 쉼표로 연결된 IMO 번호 (예: "1000019,1000021,...") + * @return API 응답 + */ + private List callApiWithBatch(String lrno) { + String url = getApiPath() + "?startDate=" + startDate +"&stopDate="+stopDate+"&lrno=" + lrno; + + log.debug("[{}] API 호출: {}", getReaderName(), url); + + return webClient.get() + .uri(url) + .retrieve() + .bodyToFlux(TerminalCallsDto.class) + .collectList() + .block(); + } + + @Override + protected void afterFetch(List data) { + if (data == null) { + int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize); + log.info("[{}] 전체 {} 개 배치 처리 완료", getReaderName(), totalBatches); + log.info("[{}] 총 {} 개의 IMO 번호에 대한 API 호출 종료", + getReaderName(), allImoNumbers.size()); + } + } + +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/reader/TransitsRangeReader.java b/src/main/java/com/snp/batch/jobs/movement/batch/reader/TransitsRangeReader.java new file mode 100644 index 0000000..b2475be --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/reader/TransitsRangeReader.java @@ -0,0 +1,118 @@ +package com.snp.batch.jobs.movement.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.movement.batch.dto.AnchorageCallsDto; +import com.snp.batch.jobs.movement.batch.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/movement/batch/reader/TransitsReader.java b/src/main/java/com/snp/batch/jobs/movement/batch/reader/TransitsReader.java new file mode 100644 index 0000000..1923fee --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/reader/TransitsReader.java @@ -0,0 +1,210 @@ +package com.snp.batch.jobs.movement.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.movement.batch.dto.TransitsDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.Collections; +import java.util.List; + +/** + * 선박 상세 정보 Reader (v2.0 - Chunk 기반) + * + * 기능: + * 1. ship_data 테이블에서 IMO 번호 전체 조회 (최초 1회) + * 2. IMO 번호를 100개씩 분할하여 배치 단위로 처리 + * 3. fetchNextBatch() 호출 시마다 100개씩 API 호출 + * 4. Spring Batch가 100건씩 Process → Write 수행 + * + * Chunk 처리 흐름: + * - beforeFetch() → IMO 전체 조회 (1회) + * - fetchNextBatch() → 100개 IMO로 API 호출 (1,718회) + * - read() → 1건씩 반환 (100번) + * - Processor/Writer → 100건 처리 + * - 반복... (1,718번의 Chunk) + * + * 기존 방식과의 차이: + * - 기존: 17만건 전체 메모리 로드 → Process → Write + * - 신규: 100건씩 로드 → Process → Write (Chunk 1,718회) + */ +@Slf4j +@StepScope +public class TransitsReader extends BaseApiReader { + + private final JdbcTemplate jdbcTemplate; + + // 배치 처리 상태 + private List allImoNumbers; + // DB 해시값을 저장할 맵 + private int currentBatchIndex = 0; + private final int batchSize = 5; + + // @Value("#{jobParameters['startDate']}") +// private String startDate; + private String startDate = "2025-01-01"; + + // @Value("#{jobParameters['stopDate']}") +// private String stopDate; + private String stopDate = "2025-12-31"; + + public TransitsReader(WebClient webClient, JdbcTemplate jdbcTemplate ) { + super(webClient); + this.jdbcTemplate = jdbcTemplate; + enableChunkMode(); // ✨ Chunk 모드 활성화 + } + + @Override + protected String getReaderName() { + return "Transits"; + } + + @Override + protected void resetCustomState() { + this.currentBatchIndex = 0; + this.allImoNumbers = null; + } + + @Override + protected String getApiPath() { + return "/Movements/Transits"; + } + + @Override + protected String getApiBaseUrl() { + return "https://webservices.maritime.spglobal.com"; + } + + private static final String GET_ALL_IMO_QUERY = + "SELECT imo_number FROM ship_data ORDER BY id"; +// "SELECT imo_number FROM snp_data.ship_data where imo_number > (select max(imo) from snp_data.t_transit) ORDER BY imo_number"; + + /** + * 최초 1회만 실행: ship_data 테이블에서 IMO 번호 전체 조회 + */ + @Override + protected void beforeFetch() { + // 전처리 과정 + // Step 1. IMO 전체 번호 조회 + log.info("[{}] ship_data 테이블에서 IMO 번호 조회 시작...", getReaderName()); + + allImoNumbers = jdbcTemplate.queryForList(GET_ALL_IMO_QUERY, String.class); + int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize); + + log.info("[{}] 총 {} 개의 IMO 번호 조회 완료", getReaderName(), allImoNumbers.size()); + log.info("[{}] {}개씩 배치로 분할하여 API 호출 예정", getReaderName(), batchSize); + log.info("[{}] 예상 배치 수: {} 개", getReaderName(), totalBatches); + + // API 통계 초기화 + updateApiCallStats(totalBatches, 0); + } + + /** + * ✨ Chunk 기반 핵심 메서드: 다음 100개 배치를 조회하여 반환 + * + * Spring Batch가 100건씩 read() 호출 완료 후 이 메서드 재호출 + * + * @return 다음 배치 100건 (더 이상 없으면 null) + */ + @Override + protected List fetchNextBatch() throws Exception { + + // 모든 배치 처리 완료 확인 + if (allImoNumbers == null || currentBatchIndex >= allImoNumbers.size()) { + return null; // Job 종료 + } + + // 현재 배치의 시작/끝 인덱스 계산 + int startIndex = currentBatchIndex; + int endIndex = Math.min(currentBatchIndex + batchSize, allImoNumbers.size()); + + // 현재 배치의 IMO 번호 추출 (100개) + 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()); + + try { + // IMO 번호를 쉼표로 연결 (예: "1000019,1000021,1000033,...") + String imoParam = String.join(",", currentBatch); + + // API 호출 + List response = callApiWithBatch(imoParam); + + // 다음 배치로 인덱스 이동 + currentBatchIndex = endIndex; + + + // 응답 처리 + if (response != null ) { + List transits = response; + log.info("[{}] 배치 {}/{} 완료: {} 건 조회", + getReaderName(), currentBatchNumber, totalBatches, transits.size()); + + // API 호출 통계 업데이트 + updateApiCallStats(totalBatches, currentBatchNumber); + + // API 과부하 방지 (다음 배치 전 0.5초 대기) + if (currentBatchIndex < allImoNumbers.size()) { + Thread.sleep(500); + } + + return transits; + + } 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(); + } + } + + /** + * Query Parameter를 사용한 API 호출 + * + * @param lrno 쉼표로 연결된 IMO 번호 (예: "1000019,1000021,...") + * @return API 응답 + */ + private List callApiWithBatch(String lrno) { + String url = getApiPath() + "?startDate=" + startDate +"&stopDate="+stopDate+"&lrno=" + lrno; + + log.debug("[{}] API 호출: {}", getReaderName(), url); + + return webClient.get() + .uri(url) + .retrieve() + .bodyToFlux(TransitsDto.class) + .collectList() + .block(); + } + + @Override + protected void afterFetch(List data) { + if (data == null) { + int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize); + log.info("[{}] 전체 {} 개 배치 처리 완료", getReaderName(), totalBatches); + log.info("[{}] 총 {} 개의 IMO 번호에 대한 API 호출 종료", + getReaderName(), allImoNumbers.size()); + } + } + +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/repository/AnchorageCallsRepository.java b/src/main/java/com/snp/batch/jobs/movement/batch/repository/AnchorageCallsRepository.java new file mode 100644 index 0000000..7101a51 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/repository/AnchorageCallsRepository.java @@ -0,0 +1,13 @@ +package com.snp.batch.jobs.movement.batch.repository; + +import com.snp.batch.jobs.movement.batch.entity.AnchorageCallsEntity; + +import java.util.List; + +/** + * 선박 상세 정보 Repository 인터페이스 + */ + +public interface AnchorageCallsRepository { + void saveAll(List entities); +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/repository/AnchorageCallsRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/movement/batch/repository/AnchorageCallsRepositoryImpl.java new file mode 100644 index 0000000..01d7380 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/repository/AnchorageCallsRepositoryImpl.java @@ -0,0 +1,202 @@ +package com.snp.batch.jobs.movement.batch.repository; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.movement.batch.entity.AnchorageCallsEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.List; + +/** + * 선박 상세 정보 Repository 구현체 + * BaseJdbcRepository를 상속하여 JDBC 기반 CRUD 구현 + */ +@Slf4j +@Repository("anchorageCallsRepository") +public class AnchorageCallsRepositoryImpl extends BaseJdbcRepository + implements AnchorageCallsRepository { + + public AnchorageCallsRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + @Override + protected String getTableName() { +// return "snp_data.t_anchoragecall"; + return "new_snp.t_anchoragecall"; + } + + @Override + protected String getEntityName() { + return "AnchorageCalls"; + } + + @Override + protected String extractId(AnchorageCallsEntity entity) { + return entity.getImolRorIHSNumber(); + } + + @Override + public String getInsertSql() { + /*return """ + INSERT INTO snp_data.t_anchoragecall(*/ + return """ + INSERT INTO new_snp.t_anchoragecall( + imo, + mvmn_type, + mvmn_dt, + stpov_id, + fclty_id, + fclty_nm, + fclty_type, + lwrnk_fclty_id, + lwrnk_fclty_nm, + lwrnk_fclty_type, + ntn_cd, + ntn_nm, + draft, + lat, + lon, + dstn, + iso2_ntn_cd, + lcinfo + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (imo,mvmn_type, mvmn_dt) + DO UPDATE SET + mvmn_type = EXCLUDED.mvmn_type, + mvmn_dt = EXCLUDED.mvmn_dt, + stpov_id = EXCLUDED.stpov_id, + fclty_id = EXCLUDED.fclty_id, + fclty_nm = EXCLUDED.fclty_nm, + fclty_type = EXCLUDED.fclty_type, + lwrnk_fclty_id = EXCLUDED.lwrnk_fclty_id, + lwrnk_fclty_nm = EXCLUDED.lwrnk_fclty_nm, + lwrnk_fclty_type = EXCLUDED.lwrnk_fclty_type, + ntn_cd = EXCLUDED.ntn_cd, + ntn_nm = EXCLUDED.ntn_nm, + draft = EXCLUDED.draft, + lat = EXCLUDED.lat, + lon = EXCLUDED.lon, + dstn = EXCLUDED.dstn, + iso2_ntn_cd = EXCLUDED.iso2_ntn_cd, + lcinfo = EXCLUDED.lcinfo + """; + } + + @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.setString(i++, e.getSchemaType()); + + } + + 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; + +// log.info("ShipMovement 저장 시작 = {}건", entities.size()); + batchInsert(entities); + + } + + + /** + * ShipDetailEntity RowMapper + */ + private static class AnchorageCallsRowMapper implements RowMapper { + @Override + public AnchorageCallsEntity mapRow(ResultSet rs, int rowNum) throws SQLException { + AnchorageCallsEntity entity = AnchorageCallsEntity.builder() + .id(rs.getLong("id")) + .imolRorIHSNumber(rs.getString("imolRorIHSNumber")) + .portCallId(rs.getObject("portCallId", Integer.class)) + .facilityId(rs.getObject("facilityId", Integer.class)) + .facilityName(rs.getString("facilityName")) + .facilityType(rs.getString("facilityType")) + .subFacilityId(rs.getObject("subFacilityId", Integer.class)) + .subFacilityName(rs.getString("subFacilityName")) + .subFacilityType(rs.getString("subFacilityType")) + .countryCode(rs.getString("countryCode")) + .countryName(rs.getString("countryName")) + .draught(rs.getObject("draught", Double.class)) + .latitude(rs.getObject("latitude", Double.class)) + .longitude(rs.getObject("longitude", Double.class)) + .destination(rs.getString("destination")) + .iso2(rs.getString("iso2")) + .position(parseJson(rs.getString("position"))) + .build(); + + Timestamp movementDate = rs.getTimestamp("movementDate"); + if (movementDate != null) { + entity.setMovementDate(movementDate.toLocalDateTime()); + } + + return entity; + } + + private JsonNode parseJson(String json) { + try { + if (json == null) return null; + return new ObjectMapper().readTree(json); + } catch (Exception e) { + throw new RuntimeException("JSON 파싱 오류: " + json); + } + } + } +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/repository/BerthCallsRepository.java b/src/main/java/com/snp/batch/jobs/movement/batch/repository/BerthCallsRepository.java new file mode 100644 index 0000000..b501ac6 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/repository/BerthCallsRepository.java @@ -0,0 +1,13 @@ +package com.snp.batch.jobs.movement.batch.repository; + +import com.snp.batch.jobs.movement.batch.entity.BerthCallsEntity; + +import java.util.List; + +/** + * 선박 상세 정보 Repository 인터페이스 + */ + +public interface BerthCallsRepository { + void saveAll(List entities); +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/repository/BerthCallsRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/movement/batch/repository/BerthCallsRepositoryImpl.java new file mode 100644 index 0000000..5a4642a --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/repository/BerthCallsRepositoryImpl.java @@ -0,0 +1,193 @@ +package com.snp.batch.jobs.movement.batch.repository; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.movement.batch.entity.BerthCallsEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.List; + +/** + * 선박 상세 정보 Repository 구현체 + * BaseJdbcRepository를 상속하여 JDBC 기반 CRUD 구현 + */ +@Slf4j +@Repository("BerthCallsRepository") +public class BerthCallsRepositoryImpl extends BaseJdbcRepository + implements BerthCallsRepository { + + public BerthCallsRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + @Override + protected String getTableName() { +// return "snp_data.t_berthcall"; + return "new_snp.t_berthcall"; + } + + @Override + protected String getEntityName() { + return "BerthCalls"; + } + + @Override + protected String extractId(BerthCallsEntity entity) { + return entity.getImolRorIHSNumber(); + } + + @Override + public String getInsertSql() { + /*return """ + INSERT INTO snp_data.t_berthcall(*/ + return """ + INSERT INTO new_snp.t_berthcall( + imo, + mvmn_type, + mvmn_dt, + fclty_id, + fclty_nm, + fclty_type, + up_fclty_id, + up_fclty_nm, + up_fclty_type, + ntn_cd, + ntn_nm, + draft, + lat, + lon, + prnt_call_id, + iso2_ntn_cd, + evt_start_dt, + lcinfo + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (imo, mvmn_type, mvmn_dt) + DO UPDATE SET + mvmn_type = EXCLUDED.mvmn_type, + mvmn_dt = EXCLUDED.mvmn_dt, + fclty_id = EXCLUDED.fclty_id, + fclty_nm = EXCLUDED.fclty_nm, + fclty_type = EXCLUDED.fclty_type, + up_fclty_id = EXCLUDED.up_fclty_id, + up_fclty_nm = EXCLUDED.up_fclty_nm, + up_fclty_type = EXCLUDED.up_fclty_type, + ntn_cd = EXCLUDED.ntn_cd, + ntn_nm = EXCLUDED.ntn_nm, + draft = EXCLUDED.draft, + lat = EXCLUDED.lat, + lon = EXCLUDED.lon, + prnt_call_id = EXCLUDED.prnt_call_id, + iso2_ntn_cd = EXCLUDED.iso2_ntn_cd, + evt_start_dt = EXCLUDED.evt_start_dt, + lcinfo = EXCLUDED.lcinfo + """; + } + + @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); + } + } + + 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; + +// log.info("BerthCalls 저장 시작 = {}건", entities.size()); + batchInsert(entities); + + } + + + /** + * ShipDetailEntity RowMapper + */ + private static class BerthCallsRowMapper implements RowMapper { + @Override + public BerthCallsEntity mapRow(ResultSet rs, int rowNum) throws SQLException { + BerthCallsEntity entity = BerthCallsEntity.builder() + .id(rs.getLong("id")) + .imolRorIHSNumber(rs.getString("imolRorIHSNumber")) + .facilityId(rs.getObject("facilityId", Integer.class)) + .facilityName(rs.getString("facilityName")) + .facilityType(rs.getString("facilityType")) + .countryCode(rs.getString("countryCode")) + .countryName(rs.getString("countryName")) + .draught(rs.getObject("draught", Double.class)) + .latitude(rs.getObject("latitude", Double.class)) + .longitude(rs.getObject("longitude", Double.class)) + .position(parseJson(rs.getString("position"))) + .build(); + + Timestamp movementDate = rs.getTimestamp("movementDate"); + if (movementDate != null) { + entity.setMovementDate(movementDate.toLocalDateTime()); + } + + return entity; + } + + private JsonNode parseJson(String json) { + try { + if (json == null) return null; + return new ObjectMapper().readTree(json); + } catch (Exception e) { + throw new RuntimeException("JSON 파싱 오류: " + json); + } + } + } +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/repository/CurrentlyAtRepository.java b/src/main/java/com/snp/batch/jobs/movement/batch/repository/CurrentlyAtRepository.java new file mode 100644 index 0000000..5f105c0 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/repository/CurrentlyAtRepository.java @@ -0,0 +1,13 @@ +package com.snp.batch.jobs.movement.batch.repository; + +import com.snp.batch.jobs.movement.batch.entity.CurrentlyAtEntity; + +import java.util.List; + +/** + * 선박 상세 정보 Repository 인터페이스 + */ + +public interface CurrentlyAtRepository { + void saveAll(List entities); +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/repository/CurrentlyAtRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/movement/batch/repository/CurrentlyAtRepositoryImpl.java new file mode 100644 index 0000000..5409872 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/repository/CurrentlyAtRepositoryImpl.java @@ -0,0 +1,164 @@ +package com.snp.batch.jobs.movement.batch.repository; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.movement.batch.entity.CurrentlyAtEntity; +import lombok.extern.slf4j.Slf4j; +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; + +/** + * 선박 상세 정보 Repository 구현체 + * BaseJdbcRepository를 상속하여 JDBC 기반 CRUD 구현 + */ +@Slf4j +@Repository("CurrentlyAtRepository") +public class CurrentlyAtRepositoryImpl extends BaseJdbcRepository + implements CurrentlyAtRepository { + + public CurrentlyAtRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + @Override + protected String getTableName() { +// return "snp_data.t_currentlyat"; + return "new_snp.t_currentlyat"; + } + + @Override + protected String getEntityName() { + return "CurrentlyAt"; + } + + @Override + protected String extractId(CurrentlyAtEntity entity) { + return entity.getImolRorIHSNumber(); + } + + @Override + public String getInsertSql() { + /*return """ + INSERT INTO snp_data.t_currentlyat(*/ + return """ + INSERT INTO new_snp.t_currentlyat( + imo, + mvmn_type, + mvmn_dt, + stpov_id, + fclty_id, + fclty_nm, + fclty_type, + lwrnk_fclty_id, + lwrnk_fclty_nm, + lwrnk_fclty_type, + up_fclty_id, + up_fclty_nm, + up_fclty_type, + ntn_cd, + ntn_nm, + draft, + lat, + lon, + dstn, + iso2_ntn_cd, + lcinfo + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (imo, mvmn_type, mvmn_dt) + DO UPDATE SET + mvmn_type = EXCLUDED.mvmn_type, + mvmn_dt = EXCLUDED.mvmn_dt, + stpov_id = EXCLUDED.stpov_id, + fclty_id = EXCLUDED.fclty_id, + fclty_nm = EXCLUDED.fclty_nm, + fclty_type = EXCLUDED.fclty_type, + lwrnk_fclty_id = EXCLUDED.lwrnk_fclty_id, + lwrnk_fclty_nm = EXCLUDED.lwrnk_fclty_nm, + lwrnk_fclty_type = EXCLUDED.lwrnk_fclty_type, + up_fclty_id = EXCLUDED.up_fclty_id, + up_fclty_nm = EXCLUDED.up_fclty_nm, + up_fclty_type = EXCLUDED.up_fclty_type, + ntn_cd = EXCLUDED.ntn_cd, + ntn_nm = EXCLUDED.ntn_nm, + draft = EXCLUDED.draft, + lat = EXCLUDED.lat, + lon = EXCLUDED.lon, + dstn = EXCLUDED.dstn, + iso2_ntn_cd = EXCLUDED.iso2_ntn_cd, + lcinfo = EXCLUDED.lcinfo + """; + } + + @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.setString(i++, e.getSchemaType()); + + } + + 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; + +// log.info("CurrentltAt 저장 시작 = {}건", entities.size()); + batchInsert(entities); + + } + +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/repository/DarkActivityRepository.java b/src/main/java/com/snp/batch/jobs/movement/batch/repository/DarkActivityRepository.java new file mode 100644 index 0000000..d8fe068 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/repository/DarkActivityRepository.java @@ -0,0 +1,13 @@ +package com.snp.batch.jobs.movement.batch.repository; + +import com.snp.batch.jobs.movement.batch.entity.DarkActivityEntity; + +import java.util.List; + +/** + * 선박 상세 정보 Repository 인터페이스 + */ + +public interface DarkActivityRepository { + void saveAll(List entities); +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/repository/DarkActivityRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/movement/batch/repository/DarkActivityRepositoryImpl.java new file mode 100644 index 0000000..f255d60 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/repository/DarkActivityRepositoryImpl.java @@ -0,0 +1,187 @@ +package com.snp.batch.jobs.movement.batch.repository; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.movement.batch.entity.DarkActivityEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.List; + +/** + * 선박 상세 정보 Repository 구현체 + * BaseJdbcRepository를 상속하여 JDBC 기반 CRUD 구현 + */ +@Slf4j +@Repository("") +public class DarkActivityRepositoryImpl extends BaseJdbcRepository + implements DarkActivityRepository { + + public DarkActivityRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + @Override + protected String getTableName() { + return "new_snp.t_darkactivity"; +// return "snp_data.t_darkactivity"; + } + + @Override + protected String getEntityName() { + return "DarkActivity"; + } + + @Override + protected String extractId(DarkActivityEntity entity) { + return entity.getImolRorIHSNumber(); + } + + @Override + public String getInsertSql() { +// return """ +// INSERT INTO snp_data.t_darkactivity( + return """ + INSERT INTO new_snp.t_darkactivity( + imo, + mvmn_type, + mvmn_dt, + fclty_id, + fclty_nm, + fclty_type, + lwrnk_fclty_id, + lwrnk_fclty_nm, + lwrnk_fclty_type, + ntn_cd, + ntn_nm, + draft, + lat, + lon, + evt_start_dt, + lcinfo + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (imo, mvmn_type, mvmn_dt, fclty_id) + DO UPDATE SET + mvmn_type = EXCLUDED.mvmn_type, + mvmn_dt = EXCLUDED.mvmn_dt, + fclty_id = EXCLUDED.fclty_id, + fclty_nm = EXCLUDED.fclty_nm, + fclty_type = EXCLUDED.fclty_type, + lwrnk_fclty_id = EXCLUDED.lwrnk_fclty_id, + lwrnk_fclty_nm = EXCLUDED.lwrnk_fclty_nm, + lwrnk_fclty_type = EXCLUDED.lwrnk_fclty_type, + ntn_cd = EXCLUDED.ntn_cd, + ntn_nm = EXCLUDED.ntn_nm, + draft = EXCLUDED.draft, + lat = EXCLUDED.lat, + lon = EXCLUDED.lon, + evt_start_dt = EXCLUDED.evt_start_dt, + lcinfo = EXCLUDED.lcinfo + """; + } + + @Override + protected String getUpdateSql() { + return null; + } + + @Override + protected void setInsertParameters(PreparedStatement ps, DarkActivityEntity 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.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.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); + } + } + + 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, DarkActivityEntity entity) throws Exception { + + } + + @Override + protected RowMapper getRowMapper() { + return null; + } + + @Override + public void saveAll(List entities) { + if (entities == null || entities.isEmpty()) return; + + log.info("DarkActivity 저장 시작 = {}건", entities.size()); + batchInsert(entities); + + } + + + /** + * ShipDetailEntity RowMapper + */ + private static class DarkActivityRowMapper implements RowMapper { + @Override + public DarkActivityEntity mapRow(ResultSet rs, int rowNum) throws SQLException { + DarkActivityEntity entity = DarkActivityEntity.builder() + .id(rs.getLong("id")) + .imolRorIHSNumber(rs.getString("imolRorIHSNumber")) + .facilityId(rs.getObject("facilityId", Integer.class)) + .facilityName(rs.getString("facilityName")) + .facilityType(rs.getString("facilityType")) + .countryCode(rs.getString("countryCode")) + .countryName(rs.getString("countryName")) + .draught(rs.getObject("draught", Double.class)) + .latitude(rs.getObject("latitude", Double.class)) + .longitude(rs.getObject("longitude", Double.class)) + .position(parseJson(rs.getString("position"))) + .build(); + + Timestamp movementDate = rs.getTimestamp("movementDate"); + if (movementDate != null) { + entity.setMovementDate(movementDate.toLocalDateTime()); + } + + return entity; + } + + private JsonNode parseJson(String json) { + try { + if (json == null) return null; + return new ObjectMapper().readTree(json); + } catch (Exception e) { + throw new RuntimeException("JSON 파싱 오류: " + json); + } + } + } +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/repository/DestinationRepository.java b/src/main/java/com/snp/batch/jobs/movement/batch/repository/DestinationRepository.java new file mode 100644 index 0000000..0bc014c --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/repository/DestinationRepository.java @@ -0,0 +1,13 @@ +package com.snp.batch.jobs.movement.batch.repository; + +import com.snp.batch.jobs.movement.batch.entity.DestinationEntity; + +import java.util.List; + +/** + * 선박 상세 정보 Repository 인터페이스 + */ + +public interface DestinationRepository { + void saveAll(List entities); +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/repository/DestinationRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/movement/batch/repository/DestinationRepositoryImpl.java new file mode 100644 index 0000000..3d3257e --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/repository/DestinationRepositoryImpl.java @@ -0,0 +1,133 @@ +package com.snp.batch.jobs.movement.batch.repository; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.movement.batch.entity.DestinationEntity; +import lombok.extern.slf4j.Slf4j; +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; + +/** + * 선박 상세 정보 Repository 구현체 + * BaseJdbcRepository를 상속하여 JDBC 기반 CRUD 구현 + */ +@Slf4j +@Repository("DestinationRepository") +public class DestinationRepositoryImpl extends BaseJdbcRepository + implements DestinationRepository { + + public DestinationRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + @Override + protected String getTableName() { +// return "snp_data.t_destination"; + return "new_snp.t_destination"; + } + + @Override + protected String getEntityName() { + return "DestinationsRange"; + } + + @Override + protected String extractId(DestinationEntity entity) { + return entity.getImolRorIHSNumber(); + } + + @Override + public String getInsertSql() { + /*return """ + INSERT INTO snp_data.t_destination(*/ + return """ + INSERT INTO new_snp.t_destination( + imo, + mvmn_type, + mvmn_dt, + fclty_id, + fclty_nm, + fclty_type, + ntn_cd, + ntn_nm, + lat, + lon, + iso2_ntn_cd, + lcinfo + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (imo) + DO UPDATE SET + mvmn_type = EXCLUDED.mvmn_type, + mvmn_dt = EXCLUDED.mvmn_dt, + fclty_id = EXCLUDED.fclty_id, + fclty_nm = EXCLUDED.fclty_nm, + fclty_type = EXCLUDED.fclty_type, + ntn_cd = EXCLUDED.ntn_cd, + ntn_nm = EXCLUDED.ntn_nm, + lat = EXCLUDED.lat, + lon = EXCLUDED.lon, + iso2_ntn_cd = EXCLUDED.iso2_ntn_cd, + lcinfo = EXCLUDED.lcinfo + """; + } + + @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); + } + } + + 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/movement/batch/repository/PortCallsRepository.java b/src/main/java/com/snp/batch/jobs/movement/batch/repository/PortCallsRepository.java new file mode 100644 index 0000000..bd65bb1 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/repository/PortCallsRepository.java @@ -0,0 +1,16 @@ +package com.snp.batch.jobs.movement.batch.repository; + +import com.snp.batch.jobs.movement.batch.entity.PortCallsEntity; + +import java.util.List; + +/** + * 선박 상세 정보 Repository 인터페이스 + */ + +public interface PortCallsRepository { + + void saveAll(List entities); + + boolean existsByPortCallId(Integer portCallId); +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/repository/PortCallsRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/movement/batch/repository/PortCallsRepositoryImpl.java new file mode 100644 index 0000000..81b720b --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/repository/PortCallsRepositoryImpl.java @@ -0,0 +1,254 @@ +package com.snp.batch.jobs.movement.batch.repository; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.movement.batch.entity.PortCallsEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.List; + +/** + * 선박 상세 정보 Repository 구현체 + * BaseJdbcRepository를 상속하여 JDBC 기반 CRUD 구현 + */ +@Slf4j +@Repository("ShipMovementRepository") +public class PortCallsRepositoryImpl extends BaseJdbcRepository + implements PortCallsRepository { + + public PortCallsRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + @Override + protected String getTableName() { +// return "snp_data.t_ship_stpov_info"; + return "new_snp.t_ship_stpov_info"; + } + + @Override + protected String getEntityName() { + return "ShipMovement"; + } + + @Override + protected String extractId(PortCallsEntity entity) { + return entity.getImolRorIHSNumber(); + } + + @Override + public String getInsertSql() { +// return """ +// INSERT INTO snp_data.t_ship_stpov_info( + return """ + INSERT INTO new_snp.t_ship_stpov_info( + imo, + mvmn_type, + mvmn_dt, + stpov_id, + fclty_id, + fclty_nm, + fclty_type, + lwrnk_fclty_id, + lwrnk_fclty_nm, + lwrnk_fclty_type, + up_fclty_id, + up_fclty_nm, + up_fclty_type, + ntn_cd, + ntn_nm, + draft, + lat, + lon, + dstn, + iso2_ntn_cd, + lcinfo + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (imo, mvmn_type, mvmn_dt) + DO UPDATE SET + mvmn_type = EXCLUDED.mvmn_type, + mvmn_dt = EXCLUDED.mvmn_dt, + stpov_id = EXCLUDED.stpov_id, + fclty_id = EXCLUDED.fclty_id, + fclty_nm = EXCLUDED.fclty_nm, + fclty_type = EXCLUDED.fclty_type, + lwrnk_fclty_id = EXCLUDED.lwrnk_fclty_id, + lwrnk_fclty_nm = EXCLUDED.lwrnk_fclty_nm, + lwrnk_fclty_type = EXCLUDED.lwrnk_fclty_type, + up_fclty_id = EXCLUDED.up_fclty_id, + up_fclty_nm = EXCLUDED.up_fclty_nm, + up_fclty_type = EXCLUDED.up_fclty_type, + ntn_cd = EXCLUDED.ntn_cd, + draft = EXCLUDED.draft, + lat = EXCLUDED.lat, + lon = EXCLUDED.lon, + dstn = EXCLUDED.dstn, + iso2_ntn_cd = EXCLUDED.iso2_ntn_cd, + lcinfo = EXCLUDED.lcinfo + """; + } + + @Override + protected String getUpdateSql() { + return """ + UPDATE snp_data.t_ship_stpov_info + SET vesselid = ?, + maritimemobileserviceidentitymmsinumber = ?, + shipname = ?, + callsign = ?, + flagname = ?, + portofregistry = ?, + classificationsociety = ?, + shiptypelevel5 = ?, + shiptypelevel5subtype = ?, + yearofbuild = ?, + shipbuilder = ?, + lengthoverallloa = ?, + breadthmoulded = ?, + "depth" = ?, + draught = ?, + grosstonnage = ?, + deadweight = ?, + teu = ?, + speedservice = ?, + mainenginetype = ?, + status = ?, + operator = ?, + flagcode = ?, + shiptypelevel2 = ? + WHERE ihslrorimoshipno = ? + """; + } + + @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 +// stpov_type는 'PORTCALL'로 하드코딩되었으므로 세팅 안함 + 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.setString(i++, e.getSchemaType()); + + } + + 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 new ShipMovementRowMapper(); + } + + @Override + public void saveAll(List entities) { + if (entities == null || entities.isEmpty()) return; + +// log.info("ShipMovement 저장 시작 = {}건", entities.size()); + batchInsert(entities); + + } + + @Override + public boolean existsByPortCallId(Integer portCallId) { + String sql = """ + SELECT COUNT(1) + FROM ship_movement + WHERE portCallId = ? + """; + + Integer count = jdbcTemplate.queryForObject(sql, Integer.class, portCallId); + return count != null && count > 0; + } + + + /** + * ShipDetailEntity RowMapper + */ + private static class ShipMovementRowMapper implements RowMapper { + @Override + public PortCallsEntity mapRow(ResultSet rs, int rowNum) throws SQLException { + PortCallsEntity entity = PortCallsEntity.builder() + .id(rs.getLong("id")) + .imolRorIHSNumber(rs.getString("imolRorIHSNumber")) + .portCallId(rs.getObject("portCallId", Integer.class)) + .facilityId(rs.getObject("facilityId", Integer.class)) + .facilityName(rs.getString("facilityName")) + .facilityType(rs.getString("facilityType")) + .subFacilityId(rs.getObject("subFacilityId", Integer.class)) + .subFacilityName(rs.getString("subFacilityName")) + .subFacilityType(rs.getString("subFacilityType")) + .parentFacilityId(rs.getObject("parentFacilityId", Integer.class)) + .parentFacilityName(rs.getString("parentFacilityName")) + .parentFacilityType(rs.getString("parentFacilityType")) + .countryCode(rs.getString("countryCode")) + .countryName(rs.getString("countryName")) + .draught(rs.getObject("draught", Double.class)) + .latitude(rs.getObject("latitude", Double.class)) + .longitude(rs.getObject("longitude", Double.class)) + .destination(rs.getString("destination")) + .iso2(rs.getString("iso2")) + .position(parseJson(rs.getString("position"))) + .schemaType(rs.getString("schemaType")) + .build(); + + Timestamp movementDate = rs.getTimestamp("movementDate"); + if (movementDate != null) { + entity.setMovementDate(movementDate.toLocalDateTime()); + } + + return entity; + } + + private JsonNode parseJson(String json) { + try { + if (json == null) return null; + return new com.fasterxml.jackson.databind.ObjectMapper().readTree(json); + } catch (Exception e) { + throw new RuntimeException("JSON 파싱 오류: " + json); + } + } + } +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/repository/StsOperationRepository.java b/src/main/java/com/snp/batch/jobs/movement/batch/repository/StsOperationRepository.java new file mode 100644 index 0000000..c9abd34 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/repository/StsOperationRepository.java @@ -0,0 +1,12 @@ +package com.snp.batch.jobs.movement.batch.repository; + +import com.snp.batch.jobs.movement.batch.entity.StsOperationEntity; +import java.util.List; + +/** + * 선박 상세 정보 Repository 인터페이스 + */ + +public interface StsOperationRepository { + void saveAll(List entities); +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/repository/StsOperationRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/movement/batch/repository/StsOperationRepositoryImpl.java new file mode 100644 index 0000000..09f6325 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/repository/StsOperationRepositoryImpl.java @@ -0,0 +1,162 @@ +package com.snp.batch.jobs.movement.batch.repository; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.movement.batch.entity.StsOperationEntity; +import lombok.extern.slf4j.Slf4j; +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; + +/** + * 선박 상세 정보 Repository 구현체 + * BaseJdbcRepository를 상속하여 JDBC 기반 CRUD 구현 + */ +@Slf4j +@Repository("StsOperationRepository") +public class StsOperationRepositoryImpl extends BaseJdbcRepository + implements StsOperationRepository { + + public StsOperationRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + @Override + protected String getTableName() { +// return "snp_data.t_stsoperation"; + return "new_snp.t_stsoperation"; + } + + @Override + protected String getEntityName() { + return "StsOperation"; + } + + @Override + protected String extractId(StsOperationEntity entity) { + return entity.getImolRorIHSNumber(); + } + + @Override + public String getInsertSql() { +// return """ +// INSERT INTO snp_data.t_stsoperation( + return """ + INSERT INTO new_snp.t_stsoperation( + imo, + mvmn_type, + mvmn_dt, + fclty_id, + fclty_nm, + fclty_type, + up_fclty_id, + up_fclty_nm, + up_fclty_type, + draft, + lat, + lon, + prnt_call_id, + ntn_cd, + ntn_nm, + sts_location, + sts_type, + evt_start_dt, + lcinfo + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (imo, mvmn_type, mvmn_dt, fclty_id) + DO UPDATE SET + mvmn_type = EXCLUDED.mvmn_type, + mvmn_dt = EXCLUDED.mvmn_dt, + fclty_id = EXCLUDED.fclty_id, + fclty_nm = EXCLUDED.fclty_nm, + fclty_type = EXCLUDED.fclty_type, + up_fclty_id = EXCLUDED.up_fclty_id, + up_fclty_nm = EXCLUDED.up_fclty_nm, + up_fclty_type = EXCLUDED.up_fclty_type, + draft = EXCLUDED.draft, + lat = EXCLUDED.lat, + lon = EXCLUDED.lon, + prnt_call_id = EXCLUDED.prnt_call_id, + ntn_cd = EXCLUDED.ntn_cd, + ntn_nm = EXCLUDED.ntn_nm, + sts_location = EXCLUDED.sts_location, + sts_type = EXCLUDED.sts_type, + evt_start_dt = EXCLUDED.evt_start_dt, + lcinfo = EXCLUDED.lcinfo + """; + } + + @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); + } + } + + 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/movement/batch/repository/TerminalCallsRepository.java b/src/main/java/com/snp/batch/jobs/movement/batch/repository/TerminalCallsRepository.java new file mode 100644 index 0000000..71ee017 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/repository/TerminalCallsRepository.java @@ -0,0 +1,13 @@ +package com.snp.batch.jobs.movement.batch.repository; + +import com.snp.batch.jobs.movement.batch.entity.TerminalCallsEntity; + +import java.util.List; + +/** + * 선박 상세 정보 Repository 인터페이스 + */ + +public interface TerminalCallsRepository { + void saveAll(List entities); +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/repository/TerminalCallsRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/movement/batch/repository/TerminalCallsRepositoryImpl.java new file mode 100644 index 0000000..51a68af --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/repository/TerminalCallsRepositoryImpl.java @@ -0,0 +1,161 @@ +package com.snp.batch.jobs.movement.batch.repository; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.movement.batch.entity.TerminalCallsEntity; +import lombok.extern.slf4j.Slf4j; +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; + +/** + * 선박 상세 정보 Repository 구현체 + * BaseJdbcRepository를 상속하여 JDBC 기반 CRUD 구현 + */ +@Slf4j +@Repository("TerminalCallsRepository") +public class TerminalCallsRepositoryImpl extends BaseJdbcRepository + implements TerminalCallsRepository { + + public TerminalCallsRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + @Override + protected String getTableName() { +// return "snp_data.t_terminalcall"; + return "new_snp.t_terminalcall"; + } + + @Override + protected String getEntityName() { + return "TerminallCalls"; + } + + @Override + protected String extractId(TerminalCallsEntity entity) { + return entity.getImolRorIHSNumber(); + } + + @Override + public String getInsertSql() { +// return """ +// INSERT INTO snp_data.t_terminalcall( + return """ + INSERT INTO new_snp.t_terminalcall( + imo, + mvmn_type, + mvmn_dt, + fclty_id, + fclty_nm, + fclty_type, + up_fclty_id, + up_fclty_nm, + up_fclty_type, + ntn_cd, + ntn_nm, + draft, + lat, + lon, + prnt_call_id, + iso2_ntn_cd, + evt_start_dt, + lcinfo, + sub_fclty_id, + sub_fclty_nm, + sub_fclty_type + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (imo, mvmn_type, mvmn_dt) + DO UPDATE SET + mvmn_type = EXCLUDED.mvmn_type, + mvmn_dt = EXCLUDED.mvmn_dt, + fclty_id = EXCLUDED.fclty_id, + fclty_nm = EXCLUDED.fclty_nm, + fclty_type = EXCLUDED.fclty_type, + up_fclty_id = EXCLUDED.up_fclty_id, + up_fclty_nm = EXCLUDED.up_fclty_nm, + up_fclty_type = EXCLUDED.up_fclty_type, + ntn_cd = EXCLUDED.ntn_cd, + ntn_nm = EXCLUDED.ntn_nm, + draft = EXCLUDED.draft, + lat = EXCLUDED.lat, + lon = EXCLUDED.lon, + prnt_call_id = EXCLUDED.prnt_call_id, + iso2_ntn_cd = EXCLUDED.iso2_ntn_cd, + evt_start_dt = EXCLUDED.evt_start_dt, + lcinfo = EXCLUDED.lcinfo, + sub_fclty_id = EXCLUDED.sub_fclty_id, + sub_fclty_nm = EXCLUDED.sub_fclty_nm, + sub_fclty_type = EXCLUDED.sub_fclty_type + """; + } + + @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()); + } + + 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; + +// log.info("TerminallCalls 저장 시작 = {}건", entities.size()); + batchInsert(entities); + + } + +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/repository/TransitsRepository.java b/src/main/java/com/snp/batch/jobs/movement/batch/repository/TransitsRepository.java new file mode 100644 index 0000000..235f3ef --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/repository/TransitsRepository.java @@ -0,0 +1,13 @@ +package com.snp.batch.jobs.movement.batch.repository; + +import com.snp.batch.jobs.movement.batch.entity.TransitsEntity; + +import java.util.List; + +/** + * 선박 상세 정보 Repository 인터페이스 + */ + +public interface TransitsRepository { + void saveAll(List entities); +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/repository/TransitsRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/movement/batch/repository/TransitsRepositoryImpl.java new file mode 100644 index 0000000..5e9a738 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/repository/TransitsRepositoryImpl.java @@ -0,0 +1,111 @@ +package com.snp.batch.jobs.movement.batch.repository; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.movement.batch.entity.TransitsEntity; +import lombok.extern.slf4j.Slf4j; +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; + +/** + * 선박 상세 정보 Repository 구현체 + * BaseJdbcRepository를 상속하여 JDBC 기반 CRUD 구현 + */ +@Slf4j +@Repository("TransitsRepository") +public class TransitsRepositoryImpl extends BaseJdbcRepository + implements TransitsRepository { + + public TransitsRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + @Override + protected String getTableName() { +// return "snp_data.t_transit"; + return "new_snp.t_transit"; + } + + @Override + protected String getEntityName() { + return "Transit"; + } + + @Override + protected String extractId(TransitsEntity entity) { + return entity.getImolRorIHSNumber(); + } + + @Override + public String getInsertSql() { +// return """ +// INSERT INTO snp_data.t_transit( + return """ + INSERT INTO new_snp.t_transit( + imo, + mvmn_type, + mvmn_dt, + fclty_nm, + fclty_type, + draft + ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (imo, mvmn_type, mvmn_dt) + DO UPDATE SET + mvmn_type = EXCLUDED.mvmn_type, + mvmn_dt = EXCLUDED.mvmn_dt, + fclty_nm = EXCLUDED.fclty_nm, + fclty_type = EXCLUDED.fclty_type, + draft = EXCLUDED.draft + """; + } + + @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 + } + + 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/movement/batch/writer/AnchorageCallsWriter.java b/src/main/java/com/snp/batch/jobs/movement/batch/writer/AnchorageCallsWriter.java new file mode 100644 index 0000000..b3600ab --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/writer/AnchorageCallsWriter.java @@ -0,0 +1,35 @@ +package com.snp.batch.jobs.movement.batch.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.movement.batch.repository.AnchorageCallsRepository; +import com.snp.batch.jobs.movement.batch.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/movement/batch/writer/BerthCallsWriter.java b/src/main/java/com/snp/batch/jobs/movement/batch/writer/BerthCallsWriter.java new file mode 100644 index 0000000..9efb091 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/writer/BerthCallsWriter.java @@ -0,0 +1,35 @@ +package com.snp.batch.jobs.movement.batch.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.movement.batch.repository.BerthCallsRepository; +import com.snp.batch.jobs.movement.batch.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/movement/batch/writer/CurrentlyAtWriter.java b/src/main/java/com/snp/batch/jobs/movement/batch/writer/CurrentlyAtWriter.java new file mode 100644 index 0000000..fbd95fe --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/writer/CurrentlyAtWriter.java @@ -0,0 +1,36 @@ +package com.snp.batch.jobs.movement.batch.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.movement.batch.repository.CurrentlyAtRepository; +import com.snp.batch.jobs.movement.batch.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/movement/batch/writer/DarkActivityWriter.java b/src/main/java/com/snp/batch/jobs/movement/batch/writer/DarkActivityWriter.java new file mode 100644 index 0000000..62d52b7 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/writer/DarkActivityWriter.java @@ -0,0 +1,35 @@ +package com.snp.batch.jobs.movement.batch.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.movement.batch.repository.DarkActivityRepository; +import com.snp.batch.jobs.movement.batch.entity.DarkActivityEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 선박 상세 정보 Writer + */ +@Slf4j +@Component +public class DarkActivityWriter extends BaseWriter { + + private final DarkActivityRepository darkActivityRepository; + + + public DarkActivityWriter(DarkActivityRepository darkActivityRepository) { + super("DarkActivity"); + this.darkActivityRepository = darkActivityRepository; + } + + @Override + protected void writeItems(List items) throws Exception { + + if (items.isEmpty()) { return; } + + darkActivityRepository.saveAll(items); + log.info("DarkActivity 데이터 저장: {} 건", items.size()); + } + +} diff --git a/src/main/java/com/snp/batch/jobs/movement/batch/writer/DestinationWriter.java b/src/main/java/com/snp/batch/jobs/movement/batch/writer/DestinationWriter.java new file mode 100644 index 0000000..a7bf3b5 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/writer/DestinationWriter.java @@ -0,0 +1,35 @@ +package com.snp.batch.jobs.movement.batch.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.movement.batch.repository.DestinationRepository; +import com.snp.batch.jobs.movement.batch.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/movement/batch/writer/PortCallsWriter.java b/src/main/java/com/snp/batch/jobs/movement/batch/writer/PortCallsWriter.java new file mode 100644 index 0000000..8ec07cf --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/writer/PortCallsWriter.java @@ -0,0 +1,36 @@ +package com.snp.batch.jobs.movement.batch.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.movement.batch.entity.PortCallsEntity; +import com.snp.batch.jobs.movement.batch.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/movement/batch/writer/StsOperationWriter.java b/src/main/java/com/snp/batch/jobs/movement/batch/writer/StsOperationWriter.java new file mode 100644 index 0000000..3decdc1 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/writer/StsOperationWriter.java @@ -0,0 +1,36 @@ +package com.snp.batch.jobs.movement.batch.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.movement.batch.repository.StsOperationRepository; +import com.snp.batch.jobs.movement.batch.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/movement/batch/writer/TerminalCallsWriter.java b/src/main/java/com/snp/batch/jobs/movement/batch/writer/TerminalCallsWriter.java new file mode 100644 index 0000000..70c779b --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/writer/TerminalCallsWriter.java @@ -0,0 +1,35 @@ +package com.snp.batch.jobs.movement.batch.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.movement.batch.repository.TerminalCallsRepository; +import com.snp.batch.jobs.movement.batch.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/movement/batch/writer/TransitsWriter.java b/src/main/java/com/snp/batch/jobs/movement/batch/writer/TransitsWriter.java new file mode 100644 index 0000000..bcf3f49 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/movement/batch/writer/TransitsWriter.java @@ -0,0 +1,35 @@ +package com.snp.batch.jobs.movement.batch.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.movement.batch.repository.TransitsRepository; +import com.snp.batch.jobs.movement.batch.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/pscInspection/batch/config/PscInspectionJobConfig.java b/src/main/java/com/snp/batch/jobs/pscInspection/batch/config/PscInspectionJobConfig.java new file mode 100644 index 0000000..95af555 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/pscInspection/batch/config/PscInspectionJobConfig.java @@ -0,0 +1,149 @@ +package com.snp.batch.jobs.pscInspection.batch.config; + +import com.snp.batch.common.batch.config.BaseMultiStepJobConfig; +import com.snp.batch.jobs.pscInspection.batch.dto.PscInspectionDto; +import com.snp.batch.jobs.pscInspection.batch.entity.PscInspectionEntity; +import com.snp.batch.jobs.pscInspection.batch.processor.PscInspectionProcessor; +import com.snp.batch.jobs.pscInspection.batch.reader.PscApiReader; +import com.snp.batch.jobs.pscInspection.batch.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.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; + +@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; + protected String getApiKey() {return "PSC_IMPORT_API";} + protected String getBatchUpdateSql() { + return String.format("UPDATE SNP_DATA.BATCH_LAST_EXECUTION SET LAST_SUCCESS_DATE = NOW(), UPDATED_AT = NOW() WHERE API_KEY = '%s'", getApiKey());} + + 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(pscLastExecutionUpdateStep()) + .build(); + } + + @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; + } + + @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 (contribution, chunkContext) -> { + log.info(">>>>> 모든 스텝 성공: BATCH_LAST_EXECUTION 업데이트 시작"); + + jdbcTemplate.execute(getBatchUpdateSql()); + + log.info(">>>>> BATCH_LAST_EXECUTION 업데이트 완료"); + return RepeatStatus.FINISHED; + }; + } + @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/pscInspection/batch/dto/PscAllCertificateDto.java b/src/main/java/com/snp/batch/jobs/pscInspection/batch/dto/PscAllCertificateDto.java new file mode 100644 index 0000000..42540d6 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/pscInspection/batch/dto/PscAllCertificateDto.java @@ -0,0 +1,75 @@ +package com.snp.batch.jobs.pscInspection.batch.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/pscInspection/batch/dto/PscApiResponseDto.java b/src/main/java/com/snp/batch/jobs/pscInspection/batch/dto/PscApiResponseDto.java new file mode 100644 index 0000000..1d3af2a --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/pscInspection/batch/dto/PscApiResponseDto.java @@ -0,0 +1,18 @@ +package com.snp.batch.jobs.pscInspection.batch.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/pscInspection/batch/dto/PscApsStatusDto.java b/src/main/java/com/snp/batch/jobs/pscInspection/batch/dto/PscApsStatusDto.java new file mode 100644 index 0000000..8e7f20f --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/pscInspection/batch/dto/PscApsStatusDto.java @@ -0,0 +1,31 @@ +package com.snp.batch.jobs.pscInspection.batch.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/pscInspection/batch/dto/PscCertificateDto.java b/src/main/java/com/snp/batch/jobs/pscInspection/batch/dto/PscCertificateDto.java new file mode 100644 index 0000000..7a248b4 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/pscInspection/batch/dto/PscCertificateDto.java @@ -0,0 +1,67 @@ +package com.snp.batch.jobs.pscInspection.batch.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class PscCertificateDto { + @JsonProperty("Type_Id") + private String typeId; + + @JsonProperty("DataSetVersion") + private PscDataSetVersionDto dataSetVersion; + + @JsonProperty("Certificate_ID") + private String certificateId; + + @JsonProperty("Certificate_Title") + private String certificateTitle; + + @JsonProperty("Certificate_Title_Code") + private String certificateTitleCode; + + @JsonProperty("Class_SOC_Of_Issuer") + private String classSocOfIssuer; + + @JsonProperty("Expiry_Date") + private String expiryDate; // ISO 날짜 문자열 그대로 받음 + + @JsonProperty("Inspection_ID") + private String inspectionId; + + @JsonProperty("Issue_Date") + private String issueDate; + + @JsonProperty("Issuing_Authority") + private String issuingAuthority; + + @JsonProperty("Issuing_Authority_Code") + private String issuingAuthorityCode; + + @JsonProperty("Last_Survey_Date") + private String lastSurveyDate; + + @JsonProperty("Latest_Survey_Place") + private String latestSurveyPlace; + + @JsonProperty("Latest_Survey_Place_Code") + private String latestSurveyPlaceCode; + + @JsonProperty("Lrno") + private String lrno; + + @JsonProperty("Other_Issuing_Authority") + private String otherIssuingAuthority; + + @JsonProperty("Other_Survey_Authority") + private String otherSurveyAuthority; + + @JsonProperty("Survey_Authority") + private String surveyAuthority; + + @JsonProperty("Survey_Authority_Code") + private String surveyAuthorityCode; + + @JsonProperty("Survey_Authority_Type") + private String surveyAuthorityType; +} diff --git a/src/main/java/com/snp/batch/jobs/pscInspection/batch/dto/PscDataSetVersionDto.java b/src/main/java/com/snp/batch/jobs/pscInspection/batch/dto/PscDataSetVersionDto.java new file mode 100644 index 0000000..41fe574 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/pscInspection/batch/dto/PscDataSetVersionDto.java @@ -0,0 +1,10 @@ +package com.snp.batch.jobs.pscInspection.batch.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/pscInspection/batch/dto/PscDefectDto.java b/src/main/java/com/snp/batch/jobs/pscInspection/batch/dto/PscDefectDto.java new file mode 100644 index 0000000..3557833 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/pscInspection/batch/dto/PscDefectDto.java @@ -0,0 +1,91 @@ +package com.snp.batch.jobs.pscInspection.batch.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/pscInspection/batch/dto/PscInspectionDto.java b/src/main/java/com/snp/batch/jobs/pscInspection/batch/dto/PscInspectionDto.java new file mode 100644 index 0000000..6d8ef89 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/pscInspection/batch/dto/PscInspectionDto.java @@ -0,0 +1,122 @@ +package com.snp.batch.jobs.pscInspection.batch.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("PSCCertificates") + private List pscCertificates; + + @JsonProperty("PSCAllCertificates") + private List pscAllCertificates; +} diff --git a/src/main/java/com/snp/batch/jobs/pscInspection/batch/entity/PscAllCertificateEntity.java b/src/main/java/com/snp/batch/jobs/pscInspection/batch/entity/PscAllCertificateEntity.java new file mode 100644 index 0000000..f750520 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/pscInspection/batch/entity/PscAllCertificateEntity.java @@ -0,0 +1,48 @@ +package com.snp.batch.jobs.pscInspection.batch.entity; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDateTime; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +public class PscAllCertificateEntity { + + 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/pscInspection/batch/entity/PscCertificateEntity.java b/src/main/java/com/snp/batch/jobs/pscInspection/batch/entity/PscCertificateEntity.java new file mode 100644 index 0000000..d360916 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/pscInspection/batch/entity/PscCertificateEntity.java @@ -0,0 +1,45 @@ +package com.snp.batch.jobs.pscInspection.batch.entity; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDateTime; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +public class PscCertificateEntity { + + private String certificateId; + + private String typeId; + private String dataSetVersion; + + private String certificateTitle; + private String certificateTitleCode; + + private String classSocOfIssuer; + + private LocalDateTime expiryDate; + private String inspectionId; + private LocalDateTime issueDate; + + private String issuingAuthority; + private String issuingAuthorityCode; + + private LocalDateTime lastSurveyDate; + private String latestSurveyPlace; + private String latestSurveyPlaceCode; + + private String lrno; + + private String otherIssuingAuthority; + private String otherSurveyAuthority; + + private String surveyAuthority; + private String surveyAuthorityCode; + private String surveyAuthorityType; +} diff --git a/src/main/java/com/snp/batch/jobs/pscInspection/batch/entity/PscDefectEntity.java b/src/main/java/com/snp/batch/jobs/pscInspection/batch/entity/PscDefectEntity.java new file mode 100644 index 0000000..e84278e --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/pscInspection/batch/entity/PscDefectEntity.java @@ -0,0 +1,53 @@ +package com.snp.batch.jobs.pscInspection.batch.entity; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +public class PscDefectEntity { + + 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/pscInspection/batch/entity/PscInspectionEntity.java b/src/main/java/com/snp/batch/jobs/pscInspection/batch/entity/PscInspectionEntity.java new file mode 100644 index 0000000..040f3ae --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/pscInspection/batch/entity/PscInspectionEntity.java @@ -0,0 +1,64 @@ +package com.snp.batch.jobs.pscInspection.batch.entity; + +import com.snp.batch.jobs.pscInspection.batch.dto.PscAllCertificateDto; +import com.snp.batch.jobs.pscInspection.batch.dto.PscCertificateDto; +import com.snp.batch.jobs.pscInspection.batch.dto.PscDefectDto; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +public class PscInspectionEntity { + + 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 certificates; + private List allCertificates; +} diff --git a/src/main/java/com/snp/batch/jobs/pscInspection/batch/processor/PscInspectionProcessor.java b/src/main/java/com/snp/batch/jobs/pscInspection/batch/processor/PscInspectionProcessor.java new file mode 100644 index 0000000..a47362d --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/pscInspection/batch/processor/PscInspectionProcessor.java @@ -0,0 +1,268 @@ +package com.snp.batch.jobs.pscInspection.batch.processor; + +import com.snp.batch.common.batch.processor.BaseProcessor; +import com.snp.batch.jobs.pscInspection.batch.dto.*; +import com.snp.batch.jobs.pscInspection.batch.entity.PscAllCertificateEntity; +import com.snp.batch.jobs.pscInspection.batch.entity.PscCertificateEntity; +import com.snp.batch.jobs.pscInspection.batch.entity.PscDefectEntity; +import com.snp.batch.jobs.pscInspection.batch.entity.PscInspectionEntity; +import lombok.extern.slf4j.Slf4j; +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.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import static jakarta.xml.bind.DatatypeConverter.parseDateTime; + +@Slf4j +@Component +public class PscInspectionProcessor extends BaseProcessor { + + @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())); + + // 리스트 null-safe + entity.setDefects(item.getPscDefects() == null ? List.of() : convertDefectDtos(item.getPscDefects())); + entity.setCertificates(item.getPscCertificates() == null ? List.of() : convertCertificateDtos(item.getPscCertificates())); + 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()) + .build()) + .collect(Collectors.toList()); + } + private List convertCertificateDtos(List dtos) { + if (dtos == null || dtos.isEmpty()) return List.of(); + + return dtos.stream() + .map(dto -> PscCertificateEntity.builder() + .certificateId(dto.getCertificateId()) + .typeId(dto.getTypeId()) + .dataSetVersion(dto.getDataSetVersion() != null ? dto.getDataSetVersion().getDataSetVersion() : null) + .certificateTitle(dto.getCertificateTitle()) + .certificateTitleCode(dto.getCertificateTitleCode()) + .classSocOfIssuer(dto.getClassSocOfIssuer()) + .issueDate(dto.getIssueDate() != null ? parseFlexible(dto.getIssueDate()) : null) + .expiryDate(dto.getExpiryDate() != null ? parseFlexible(dto.getExpiryDate()) : null) + .inspectionId(dto.getInspectionId()) + .issuingAuthority(dto.getIssuingAuthority()) + .issuingAuthorityCode(dto.getIssuingAuthorityCode()) + .lastSurveyDate(dto.getLastSurveyDate() != null ? parseFlexible(dto.getLastSurveyDate()) : null) + .latestSurveyPlace(dto.getLatestSurveyPlace()) + .latestSurveyPlaceCode(dto.getLatestSurveyPlaceCode()) + .lrno(dto.getLrno()) + .otherIssuingAuthority(dto.getOtherIssuingAuthority()) + .otherSurveyAuthority(dto.getOtherSurveyAuthority()) + .surveyAuthority(dto.getSurveyAuthority()) + .surveyAuthorityCode(dto.getSurveyAuthorityCode()) + .surveyAuthorityType(dto.getSurveyAuthorityType()) + .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()) + .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/pscInspection/batch/reader/PscApiReader.java b/src/main/java/com/snp/batch/jobs/pscInspection/batch/reader/PscApiReader.java new file mode 100644 index 0000000..6d00c24 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/pscInspection/batch/reader/PscApiReader.java @@ -0,0 +1,139 @@ +package com.snp.batch.jobs.pscInspection.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.pscInspection.batch.dto.PscApiResponseDto; +import com.snp.batch.jobs.pscInspection.batch.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/pscInspection/batch/repository/PscAllCertificateRepository.java b/src/main/java/com/snp/batch/jobs/pscInspection/batch/repository/PscAllCertificateRepository.java new file mode 100644 index 0000000..5d4586a --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/pscInspection/batch/repository/PscAllCertificateRepository.java @@ -0,0 +1,9 @@ +package com.snp.batch.jobs.pscInspection.batch.repository; + +import com.snp.batch.jobs.pscInspection.batch.entity.PscAllCertificateEntity; + +import java.util.List; + +public interface PscAllCertificateRepository { + void saveAllCertificates(List certificates); +} diff --git a/src/main/java/com/snp/batch/jobs/pscInspection/batch/repository/PscAllCertificateRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/pscInspection/batch/repository/PscAllCertificateRepositoryImpl.java new file mode 100644 index 0000000..75547db --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/pscInspection/batch/repository/PscAllCertificateRepositoryImpl.java @@ -0,0 +1,139 @@ +package com.snp.batch.jobs.pscInspection.batch.repository; + +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.pscInspection.batch.entity.PscAllCertificateEntity; +import com.snp.batch.jobs.pscInspection.batch.entity.PscCertificateEntity; +import lombok.extern.slf4j.Slf4j; +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 +public class PscAllCertificateRepositoryImpl extends BaseJdbcRepository + implements PscAllCertificateRepository { + public PscAllCertificateRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + protected String getTableName() { + return "new_snp.psc_all_certificate"; + } + + @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 new_snp.psc_all_certificate( + certificate_id, + data_set_version, + inspection_id, + lrno, + certificate_title_code, + certificate_title, + issuing_authority_code, + issuing_authority, + other_issuing_authority, + issue_date, + expiry_date, + last_survey_date, + survey_authority_code, + survey_authority, + other_survey_authority, + latest_survey_place, + latest_survey_place_code, + survey_authority_type, + inspection_date, + inspected_by + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ) + ON CONFLICT (certificate_id) + DO UPDATE SET + data_set_version = EXCLUDED.data_set_version, + inspection_id = EXCLUDED.inspection_id, + lrno = EXCLUDED.lrno, + certificate_title_code = EXCLUDED.certificate_title_code, + certificate_title = EXCLUDED.certificate_title, + issuing_authority_code = EXCLUDED.issuing_authority_code, + issuing_authority = EXCLUDED.issuing_authority, + other_issuing_authority = EXCLUDED.other_issuing_authority, + issue_date = EXCLUDED.issue_date, + expiry_date = EXCLUDED.expiry_date, + last_survey_date = EXCLUDED.last_survey_date, + survey_authority_code = EXCLUDED.survey_authority_code, + survey_authority = EXCLUDED.survey_authority, + other_survey_authority = EXCLUDED.other_survey_authority, + latest_survey_place = EXCLUDED.latest_survey_place, + latest_survey_place_code = EXCLUDED.latest_survey_place_code, + survey_authority_type = EXCLUDED.survey_authority_type, + inspection_date = EXCLUDED.inspection_date, + inspected_by = EXCLUDED.inspected_by + """; + + } + + @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()); + } + + @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/pscInspection/batch/repository/PscCertificateRepository.java b/src/main/java/com/snp/batch/jobs/pscInspection/batch/repository/PscCertificateRepository.java new file mode 100644 index 0000000..97041e1 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/pscInspection/batch/repository/PscCertificateRepository.java @@ -0,0 +1,10 @@ +package com.snp.batch.jobs.pscInspection.batch.repository; + +import com.snp.batch.jobs.pscInspection.batch.entity.PscCertificateEntity; +import com.snp.batch.jobs.pscInspection.batch.entity.PscDefectEntity; + +import java.util.List; + +public interface PscCertificateRepository { + void saveCertificates(List certificates); +} diff --git a/src/main/java/com/snp/batch/jobs/pscInspection/batch/repository/PscCertificateRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/pscInspection/batch/repository/PscCertificateRepositoryImpl.java new file mode 100644 index 0000000..3edf86c --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/pscInspection/batch/repository/PscCertificateRepositoryImpl.java @@ -0,0 +1,139 @@ +package com.snp.batch.jobs.pscInspection.batch.repository; + +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.pscInspection.batch.entity.PscCertificateEntity; +import com.snp.batch.jobs.pscInspection.batch.entity.PscDefectEntity; +import lombok.extern.slf4j.Slf4j; +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 +public class PscCertificateRepositoryImpl extends BaseJdbcRepository + implements PscCertificateRepository { + public PscCertificateRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + protected String getTableName() { + return "new_snp.psc_certificate"; + } + + @Override + protected RowMapper getRowMapper() { + return null; + } + + @Override + protected String getEntityName() { + return "PscCertificate"; + } + + @Override + protected String extractId(PscCertificateEntity entity) { + return entity.getCertificateId(); + } + + @Override + public String getInsertSql() { + return """ + INSERT INTO new_snp.psc_certificate( + certificate_id, + type_id, + data_set_version, + certificate_title, + certificate_title_code, + class_soc_of_issuer, + expiry_date, + inspection_id, + issue_date, + issuing_authority, + issuing_authority_code, + last_survey_date, + latest_survey_place, + latest_survey_place_code, + lrno, + other_issuing_authority, + other_survey_authority, + survey_authority, + survey_authority_code, + survey_authority_type + ) VALUES ( + ?,?,?,?,?,?,?,?,?,?, + ?,?,?,?,?,?,?,?,?,? + ) + ON CONFLICT (certificate_id) + DO UPDATE SET + type_id = EXCLUDED.type_id, + data_set_version = EXCLUDED.data_set_version, + certificate_title = EXCLUDED.certificate_title, + certificate_title_code = EXCLUDED.certificate_title_code, + class_soc_of_issuer = EXCLUDED.class_soc_of_issuer, + expiry_date = EXCLUDED.expiry_date, + inspection_id = EXCLUDED.inspection_id, + issue_date = EXCLUDED.issue_date, + issuing_authority = EXCLUDED.issuing_authority, + issuing_authority_code = EXCLUDED.issuing_authority_code, + last_survey_date = EXCLUDED.last_survey_date, + latest_survey_place = EXCLUDED.latest_survey_place, + latest_survey_place_code = EXCLUDED.latest_survey_place_code, + lrno = EXCLUDED.lrno, + other_issuing_authority = EXCLUDED.other_issuing_authority, + other_survey_authority = EXCLUDED.other_survey_authority, + survey_authority = EXCLUDED.survey_authority, + survey_authority_code = EXCLUDED.survey_authority_code, + survey_authority_type = EXCLUDED.survey_authority_type + """; + + } + + @Override + protected String getUpdateSql() { + return null; + } + + @Override + protected void setInsertParameters(PreparedStatement ps, PscCertificateEntity e) throws Exception { + int i = 1; + + ps.setString(i++, e.getCertificateId()); + ps.setString(i++, e.getTypeId()); + ps.setString(i++, e.getDataSetVersion()); + ps.setString(i++, e.getCertificateTitle()); + ps.setString(i++, e.getCertificateTitleCode()); + ps.setString(i++, e.getClassSocOfIssuer()); + ps.setTimestamp(i++, e.getExpiryDate() != null ? Timestamp.valueOf(e.getExpiryDate()) : null); + ps.setString(i++, e.getInspectionId()); + ps.setTimestamp(i++, e.getIssueDate() != null ? Timestamp.valueOf(e.getIssueDate()) : null); + ps.setString(i++, e.getIssuingAuthority()); + ps.setString(i++, e.getIssuingAuthorityCode()); + ps.setTimestamp(i++, e.getLastSurveyDate() != null ? Timestamp.valueOf(e.getLastSurveyDate()) : null); + ps.setString(i++, e.getLatestSurveyPlace()); + ps.setString(i++, e.getLatestSurveyPlaceCode()); + ps.setString(i++, e.getLrno()); + ps.setString(i++, e.getOtherIssuingAuthority()); + ps.setString(i++, e.getOtherSurveyAuthority()); + ps.setString(i++, e.getSurveyAuthority()); + ps.setString(i++, e.getSurveyAuthorityCode()); + ps.setString(i++, e.getSurveyAuthorityType()); + } + + @Override + protected void setUpdateParameters(PreparedStatement ps, PscCertificateEntity entity) throws Exception { + + } + + @Override + public void saveCertificates(List entities) { + if (entities == null || entities.isEmpty()) return; +// log.info("PSC Certificate 저장 시작 = {}건", entities.size()); + batchInsert(entities); + } + +} diff --git a/src/main/java/com/snp/batch/jobs/pscInspection/batch/repository/PscDefectRepository.java b/src/main/java/com/snp/batch/jobs/pscInspection/batch/repository/PscDefectRepository.java new file mode 100644 index 0000000..35d9029 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/pscInspection/batch/repository/PscDefectRepository.java @@ -0,0 +1,10 @@ +package com.snp.batch.jobs.pscInspection.batch.repository; + +import com.snp.batch.jobs.pscInspection.batch.entity.PscDefectEntity; +import com.snp.batch.jobs.pscInspection.batch.entity.PscInspectionEntity; + +import java.util.List; + +public interface PscDefectRepository { + void saveDefects(List defects); +} diff --git a/src/main/java/com/snp/batch/jobs/pscInspection/batch/repository/PscDefectRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/pscInspection/batch/repository/PscDefectRepositoryImpl.java new file mode 100644 index 0000000..30c7f04 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/pscInspection/batch/repository/PscDefectRepositoryImpl.java @@ -0,0 +1,153 @@ +package com.snp.batch.jobs.pscInspection.batch.repository; + +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.pscInspection.batch.entity.PscDefectEntity; +import com.snp.batch.jobs.pscInspection.batch.entity.PscInspectionEntity; +import lombok.extern.slf4j.Slf4j; +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 { + public PscDefectRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + protected String getTableName() { + return "new_snp.psc_detail"; + } + + @Override + protected RowMapper getRowMapper() { + return null; + } + + @Override + protected String getEntityName() { + return "PscInspection"; + } + + @Override + protected String extractId(PscDefectEntity entity) { + return entity.getInspectionId(); + } + + @Override + public String getInsertSql() { + return """ + INSERT INTO new_snp.psc_defect( + defect_id, + inspection_id, + data_set_version, + action_1, + action_2, + action_3, + action_code_1, + action_code_2, + action_code_3, + class_is_responsible, + defect_code, + defect_text, + defective_item_code, + detention_reason_deficiency, + main_defect_code, + main_defect_text, + nature_of_defect_code, + nature_of_defect_decode, + other_action, + other_recognised_org_resp, + recognised_org_resp, + recognised_org_resp_code, + recognised_org_resp_yn, + is_accidental_damage + ) VALUES ( + ?,?,?,?,?,?,?,?,?,?, + ?,?,?,?,?,?,?,?,?,?, + ?,?,?,? + ) + ON CONFLICT (defect_id) + DO UPDATE SET + inspection_id = EXCLUDED.inspection_id, + data_set_version = EXCLUDED.data_set_version, + action_1 = EXCLUDED.action_1, + action_2 = EXCLUDED.action_2, + action_3 = EXCLUDED.action_3, + action_code_1 = EXCLUDED.action_code_1, + action_code_2 = EXCLUDED.action_code_2, + action_code_3 = EXCLUDED.action_code_3, + class_is_responsible = EXCLUDED.class_is_responsible, + defect_code = EXCLUDED.defect_code, + defect_text = EXCLUDED.defect_text, + defective_item_code = EXCLUDED.defective_item_code, + detention_reason_deficiency = EXCLUDED.detention_reason_deficiency, + main_defect_code = EXCLUDED.main_defect_code, + main_defect_text = EXCLUDED.main_defect_text, + nature_of_defect_code = EXCLUDED.nature_of_defect_code, + nature_of_defect_decode = EXCLUDED.nature_of_defect_decode, + other_action = EXCLUDED.other_action, + other_recognised_org_resp = EXCLUDED.other_recognised_org_resp, + recognised_org_resp = EXCLUDED.recognised_org_resp, + recognised_org_resp_code = EXCLUDED.recognised_org_resp_code, + recognised_org_resp_yn = EXCLUDED.recognised_org_resp_yn, + is_accidental_damage = EXCLUDED.is_accidental_damage + """; + + } + + @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()); + } + + @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/pscInspection/batch/repository/PscInspectionRepository.java b/src/main/java/com/snp/batch/jobs/pscInspection/batch/repository/PscInspectionRepository.java new file mode 100644 index 0000000..201f6c7 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/pscInspection/batch/repository/PscInspectionRepository.java @@ -0,0 +1,9 @@ +package com.snp.batch.jobs.pscInspection.batch.repository; + +import com.snp.batch.jobs.pscInspection.batch.entity.PscInspectionEntity; + +import java.util.List; + +public interface PscInspectionRepository { + void saveAll(List entities); +} diff --git a/src/main/java/com/snp/batch/jobs/pscInspection/batch/repository/PscInspectionRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/pscInspection/batch/repository/PscInspectionRepositoryImpl.java new file mode 100644 index 0000000..a75fee4 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/pscInspection/batch/repository/PscInspectionRepositoryImpl.java @@ -0,0 +1,173 @@ +package com.snp.batch.jobs.pscInspection.batch.repository; + +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.pscInspection.batch.entity.PscInspectionEntity; +import lombok.extern.slf4j.Slf4j; +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{ + public PscInspectionRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + protected String getTableName() { + return "new_snp.psc_detail"; + } + + @Override + protected String getEntityName() { + return "PscInspection"; + } + + @Override + protected String extractId(PscInspectionEntity entity) { + return entity.getInspectionId(); + } + + @Override + public String getInsertSql() { + return """ + INSERT INTO new_snp.psc_detail( + inspection_id, + data_set_version, + authorisation, + call_sign, + class, + charterer, + country, + inspection_date, + release_date, + ship_detained, + dead_weight, + expanded_inspection, + flag, + follow_up_inspection, + gross_tonnage, + inspection_port_decode, + last_updated, + ihslr_or_imo_ship_no, + manager, + number_of_days_detained, + number_of_defects, + number_of_part_days_detained, + other_inspection_type, + owner, + ship_name, + ship_type_code, + ship_type_decode, + source, + unlocode, + year_of_build + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ) + ON CONFLICT (inspection_id) + DO UPDATE SET + data_set_version = EXCLUDED.data_set_version, + authorisation = EXCLUDED.authorisation, + call_sign = EXCLUDED.call_sign, + class = EXCLUDED.class, + charterer = EXCLUDED.charterer, + country = EXCLUDED.country, + inspection_date = EXCLUDED.inspection_date, + release_date = EXCLUDED.release_date, + ship_detained = EXCLUDED.ship_detained, + dead_weight = EXCLUDED.dead_weight, + expanded_inspection = EXCLUDED.expanded_inspection, + flag = EXCLUDED.flag, + follow_up_inspection = EXCLUDED.follow_up_inspection, + gross_tonnage = EXCLUDED.gross_tonnage, + inspection_port_decode = EXCLUDED.inspection_port_decode, + last_updated = EXCLUDED.last_updated, + ihslr_or_imo_ship_no = EXCLUDED.ihslr_or_imo_ship_no, + manager = EXCLUDED.manager, + number_of_days_detained = EXCLUDED.number_of_days_detained, + number_of_defects = EXCLUDED.number_of_defects, + number_of_part_days_detained = EXCLUDED.number_of_part_days_detained, + other_inspection_type = EXCLUDED.other_inspection_type, + owner = EXCLUDED.owner, + ship_name = EXCLUDED.ship_name, + ship_type_code = EXCLUDED.ship_type_code, + ship_type_decode = EXCLUDED.ship_type_decode, + source = EXCLUDED.source, + unlocode = EXCLUDED.unlocode, + year_of_build = EXCLUDED.year_of_build + """; + + } + + @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()); + } + + @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/pscInspection/batch/writer/PscInspectionWriter.java b/src/main/java/com/snp/batch/jobs/pscInspection/batch/writer/PscInspectionWriter.java new file mode 100644 index 0000000..725d167 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/pscInspection/batch/writer/PscInspectionWriter.java @@ -0,0 +1,55 @@ +package com.snp.batch.jobs.pscInspection.batch.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.pscInspection.batch.entity.PscInspectionEntity; +import com.snp.batch.jobs.pscInspection.batch.repository.PscAllCertificateRepository; +import com.snp.batch.jobs.pscInspection.batch.repository.PscCertificateRepository; +import com.snp.batch.jobs.pscInspection.batch.repository.PscDefectRepository; +import com.snp.batch.jobs.pscInspection.batch.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 PscCertificateRepository pscCertificateRepository; + private final PscAllCertificateRepository pscAllCertificateRepository; + + public PscInspectionWriter(PscInspectionRepository pscInspectionRepository, + PscDefectRepository pscDefectRepository, + PscCertificateRepository pscCertificateRepository, + PscAllCertificateRepository pscAllCertificateRepository) { + super("PscInspection"); + this.pscInspectionRepository = pscInspectionRepository; + this.pscDefectRepository = pscDefectRepository; + this.pscCertificateRepository = pscCertificateRepository; + 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()); + pscCertificateRepository.saveCertificates(entity.getCertificates()); + pscAllCertificateRepository.saveAllCertificates(entity.getAllCertificates()); + + // 효율적으로 로그 + int defectCount = entity.getDefects() != null ? entity.getDefects().size() : 0; + int certificateCount = entity.getCertificates() != null ? entity.getCertificates().size() : 0; + int allCertificateCount = entity.getAllCertificates() != null ? entity.getAllCertificates().size() : 0; + + log.info("Inspection ID: {}, Defects: {}, Certificates: {}, AllCertificates: {}", + entity.getInspectionId(), defectCount, certificateCount, allCertificateCount); + } + } +} diff --git a/src/main/java/com/snp/batch/jobs/risk/batch/config/RiskImportJobConfig.java b/src/main/java/com/snp/batch/jobs/risk/batch/config/RiskImportJobConfig.java new file mode 100644 index 0000000..4e04f9d --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/risk/batch/config/RiskImportJobConfig.java @@ -0,0 +1,84 @@ +package com.snp.batch.jobs.risk.batch.config; + +import com.snp.batch.common.batch.config.BaseJobConfig; +import com.snp.batch.jobs.risk.batch.dto.RiskDto; +import com.snp.batch.jobs.risk.batch.entity.RiskEntity; +import com.snp.batch.jobs.risk.batch.processor.RiskDataProcessor; +import com.snp.batch.jobs.risk.batch.reader.RiskDataReader; +import com.snp.batch.jobs.risk.batch.writer.RiskDataWriter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +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.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 RiskImportJobConfig extends BaseJobConfig { + private final JdbcTemplate jdbcTemplate; + private final WebClient maritimeServiceApiWebClient; + + private final RiskDataProcessor riskDataProcessor; + + private final RiskDataWriter riskDataWriter; + + @Override + protected int getChunkSize() { + return 5000; // API에서 5000개씩 가져오므로 chunk도 5000으로 설정 + } + public RiskImportJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + RiskDataProcessor riskDataProcessor, + RiskDataWriter riskDataWriter, + JdbcTemplate jdbcTemplate, + @Qualifier("maritimeServiceApiWebClient")WebClient maritimeServiceApiWebClient) { + super(jobRepository, transactionManager); + this.jdbcTemplate = jdbcTemplate; + this.maritimeServiceApiWebClient = maritimeServiceApiWebClient; + this.riskDataProcessor = riskDataProcessor; + this.riskDataWriter = riskDataWriter; + } + + @Override + protected String getJobName() { + return "RiskImportJob"; + } + + @Override + protected String getStepName() { + return "RiskImportStep"; + } + + @Override + protected ItemReader createReader() { + return new RiskDataReader(maritimeServiceApiWebClient, jdbcTemplate); + } + + @Override + protected ItemProcessor createProcessor() { + return riskDataProcessor; + } + + @Override + protected ItemWriter createWriter() { return riskDataWriter; } + + @Bean(name = "RiskImportJob") + public Job riskImportJob() { + return job(); + } + + @Bean(name = "RiskImportStep") + public Step riskImportStep() { + return step(); + } + +} diff --git a/src/main/java/com/snp/batch/jobs/risk/batch/config/RiskImportRangeJobConfig.java b/src/main/java/com/snp/batch/jobs/risk/batch/config/RiskImportRangeJobConfig.java new file mode 100644 index 0000000..9ca38a2 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/risk/batch/config/RiskImportRangeJobConfig.java @@ -0,0 +1,145 @@ +package com.snp.batch.jobs.risk.batch.config; + +import com.snp.batch.common.batch.config.BaseMultiStepJobConfig; +import com.snp.batch.jobs.risk.batch.dto.RiskDto; +import com.snp.batch.jobs.risk.batch.entity.RiskEntity; +import com.snp.batch.jobs.risk.batch.processor.RiskDataProcessor; +import com.snp.batch.jobs.risk.batch.reader.RiskDataRangeReader; +import com.snp.batch.jobs.risk.batch.writer.RiskDataWriter; +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.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; + +@Slf4j +@Configuration +public class RiskImportRangeJobConfig extends BaseMultiStepJobConfig { + private final WebClient maritimeServiceApiWebClient; + private final RiskDataProcessor riskDataProcessor; + private final RiskDataWriter 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; + + protected String getApiKey() {return "RISK_IMPORT_API";} + protected String getBatchUpdateSql() { + return String.format("UPDATE SNP_DATA.BATCH_LAST_EXECUTION SET LAST_SUCCESS_DATE = NOW(), UPDATED_AT = NOW() WHERE API_KEY = '%s'", getApiKey());} + + + @Override + protected int getChunkSize() { + return 5000; + } + public RiskImportRangeJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + RiskDataProcessor riskDataProcessor, + RiskDataWriter 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()) // 1단계: API 데이터 적재 + .next(riskLastExecutionUpdateStep()) // 2단계: 모두 완료 시, BATCH_LAST_EXECUTION 마지막 성공일자 업데이트 + .build(); + } + + @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; + } + + @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 (contribution, chunkContext) -> { + log.info(">>>>> 모든 스텝 성공: BATCH_LAST_EXECUTION 업데이트 시작"); + + jdbcTemplate.execute(getBatchUpdateSql()); + + log.info(">>>>> BATCH_LAST_EXECUTION 업데이트 완료"); + return RepeatStatus.FINISHED; + }; + } + @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/risk/batch/dto/RiskDto.java b/src/main/java/com/snp/batch/jobs/risk/batch/dto/RiskDto.java new file mode 100644 index 0000000..6797e1c --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/risk/batch/dto/RiskDto.java @@ -0,0 +1,277 @@ +package com.snp.batch.jobs.risk.batch.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/risk/batch/dto/RiskResponse.java b/src/main/java/com/snp/batch/jobs/risk/batch/dto/RiskResponse.java new file mode 100644 index 0000000..1ab23e9 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/risk/batch/dto/RiskResponse.java @@ -0,0 +1,16 @@ +package com.snp.batch.jobs.risk.batch.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/risk/batch/entity/RiskEntity.java b/src/main/java/com/snp/batch/jobs/risk/batch/entity/RiskEntity.java new file mode 100644 index 0000000..e5ccc1c --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/risk/batch/entity/RiskEntity.java @@ -0,0 +1,189 @@ +package com.snp.batch.jobs.risk.batch.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/risk/batch/processor/RiskDataProcessor.java b/src/main/java/com/snp/batch/jobs/risk/batch/processor/RiskDataProcessor.java new file mode 100644 index 0000000..3f96d9b --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/risk/batch/processor/RiskDataProcessor.java @@ -0,0 +1,122 @@ +package com.snp.batch.jobs.risk.batch.processor; + +import com.snp.batch.common.batch.processor.BaseProcessor; +import com.snp.batch.jobs.risk.batch.dto.RiskDto; +import com.snp.batch.jobs.risk.batch.entity.RiskEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class RiskDataProcessor extends BaseProcessor { + @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()) + .build(); + + log.debug("Risk 데이터 처리 완료: imoNumber={}", dto.getLrno()); + + return entity; + } +} diff --git a/src/main/java/com/snp/batch/jobs/risk/batch/reader/RiskDataRangeReader.java b/src/main/java/com/snp/batch/jobs/risk/batch/reader/RiskDataRangeReader.java new file mode 100644 index 0000000..6d039b7 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/risk/batch/reader/RiskDataRangeReader.java @@ -0,0 +1,117 @@ +package com.snp.batch.jobs.risk.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.risk.batch.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/UpdatedRiskList"; + } + 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/risk/batch/reader/RiskDataReader.java b/src/main/java/com/snp/batch/jobs/risk/batch/reader/RiskDataReader.java new file mode 100644 index 0000000..efe4e56 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/risk/batch/reader/RiskDataReader.java @@ -0,0 +1,150 @@ +package com.snp.batch.jobs.risk.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.risk.batch.dto.RiskDto; +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.Collections; +import java.util.List; + +@Slf4j +public class RiskDataReader extends BaseApiReader { + + //TODO : + // 1. Core20 IMO_NUMBER 전체 조회 + // 2. IMO번호에 대한 마지막 AIS 신호 요청 (1회 최대 5000개 : Chunk 단위로 반복) + // 3. Response Data -> Core20에 업데이트 (Chunk 단위로 반복) + + private final JdbcTemplate jdbcTemplate; + + private List allImoNumbers; + private int currentBatchIndex = 0; + private final int batchSize = 100; + + public RiskDataReader(WebClient webClient, JdbcTemplate jdbcTemplate) { + super(webClient); + this.jdbcTemplate = jdbcTemplate; + enableChunkMode(); // ✨ Chunk 모드 활성화 + } + + @Override + protected String getReaderName() { + return "riskDataReader"; + } + + @Override + protected void resetCustomState() { + this.currentBatchIndex = 0; + this.allImoNumbers = null; + } + + @Override + protected String getApiPath() { + return "/RiskAndCompliance/RisksByImos"; + } + +// private String getTargetTable(){ +// return "snp_data.core20"; +// } + private String getTargetTable(){ + return "snp_data.ship_data"; + } + private String GET_CORE_IMO_LIST = +// "SELECT ihslrorimoshipno FROM " + getTargetTable() + " ORDER BY ihslrorimoshipno"; + "select imo_number as ihslrorimoshipno from snp_data.ship_data order by imo_number"; + @Override + protected void beforeFetch(){ + log.info("[{}] Core20 테이블에서 IMO 번호 조회 시작...", getReaderName()); + + allImoNumbers = jdbcTemplate.queryForList(GET_CORE_IMO_LIST, String.class); + + int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize); + + log.info("[{}] 총 {} 개의 IMO 번호 조회 완료", getReaderName(), allImoNumbers.size()); + log.info("[{}] {}개씩 배치로 분할하여 API 호출 예정", getReaderName(), batchSize); + log.info("[{}] 예상 배치 수: {} 개", getReaderName(), totalBatches); + + updateApiCallStats(totalBatches, 0); + } + + @Override + protected List fetchNextBatch() throws Exception { + // 모든 배치 처리 완료 확인 + if (allImoNumbers == null || currentBatchIndex >= allImoNumbers.size()) { + return null; // Job 종료 + } + + // 현재 배치의 시작/끝 인덱스 계산 + int startIndex = currentBatchIndex; + int endIndex = Math.min(currentBatchIndex + batchSize, allImoNumbers.size()); + + // 현재 배치의 IMO 번호 추출 (100개) + 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()); + + try { + // IMO 번호를 쉼표로 연결 (예: "1000019,1000021,1000033,...") + String imoParam = String.join(",", currentBatch); + + // API 호출 + List response = callAisApiWithBatch(imoParam); + + // 다음 배치로 인덱스 이동 + currentBatchIndex = endIndex; + + // 응답 처리 + if (response != null) { +// List targets = response; + log.info("[{}] 배치 {}/{} 완료: {} 건 조회", + getReaderName(), currentBatchNumber, totalBatches, response.size()); + + // API 호출 통계 업데이트 + updateApiCallStats(totalBatches, currentBatchNumber); + + // API 과부하 방지 (다음 배치 전 0.5초 대기) + if (currentBatchIndex < allImoNumbers.size()) { + Thread.sleep(500); + } + + return response; + } 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(); + } + } + + private List callAisApiWithBatch(String imoNumbers) { + String url = getApiPath() + "?imos=" + imoNumbers; + log.debug("[{}] API 호출: {}", getReaderName(), url); + return webClient.get() + .uri(url) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() {}) + .block(); + } + +} diff --git a/src/main/java/com/snp/batch/jobs/risk/batch/repository/RiskRepository.java b/src/main/java/com/snp/batch/jobs/risk/batch/repository/RiskRepository.java new file mode 100644 index 0000000..aea972b --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/risk/batch/repository/RiskRepository.java @@ -0,0 +1,10 @@ +package com.snp.batch.jobs.risk.batch.repository; + +import com.snp.batch.jobs.risk.batch.entity.RiskEntity; + +import java.util.List; + +public interface RiskRepository { + void saveRiskAll(List items); + void saveRiskHistoryAll(List items); +} diff --git a/src/main/java/com/snp/batch/jobs/risk/batch/repository/RiskRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/risk/batch/repository/RiskRepositoryImpl.java new file mode 100644 index 0000000..3818507 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/risk/batch/repository/RiskRepositoryImpl.java @@ -0,0 +1,207 @@ +package com.snp.batch.jobs.risk.batch.repository; + +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.risk.batch.entity.RiskEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.util.List; + +@Slf4j +@Repository("riskRepository") +public class RiskRepositoryImpl extends BaseJdbcRepository implements RiskRepository { + + public RiskRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + protected String getTableName() { + return null; + } + + @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 null; + } + + protected String getUpdateSql(String targetTable, String targetIndex) { + return """ + INSERT INTO new_snp.%s ( + lrno, lastupdated, + riskdatamaintained, dayssincelastseenonais, daysunderais, imocorrectonais, sailingundername, + anomalousmessagesfrommmsi, mostrecentdarkactivity, portcalls, portrisk, stsoperations, + driftinghighseas, riskevents, flagchanges, flagparismouperformance, flagtokyomoupeformance, + flaguscgmouperformance, uscgqualship21, timesincepscinspection, pscinspections, pscdefects, + pscdetentions, currentsmccertificate, docchanges, currentclass, classstatuschanges, + pandicoverage, namechanges, gbochanges, ageofship, iuufishingviolation, + draughtchanges, mostrecentsanctionedportcall, singleshipoperation, fleetsafety, fleetpsc, + specialsurveyoverdue, ownerunknown, russianportcall, russianownerregistration, russiansts + ) + VALUES ( + ?, ?::timestamptz, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ? + ) + ON CONFLICT (%s) + DO UPDATE SET + riskdatamaintained = EXCLUDED.riskdatamaintained, + dayssincelastseenonais = EXCLUDED.dayssincelastseenonais, + daysunderais = EXCLUDED.daysunderais, + imocorrectonais = EXCLUDED.imocorrectonais, + sailingundername = EXCLUDED.sailingundername, + anomalousmessagesfrommmsi = EXCLUDED.anomalousmessagesfrommmsi, + mostrecentdarkactivity = EXCLUDED.mostrecentdarkactivity, + portcalls = EXCLUDED.portcalls, + portrisk = EXCLUDED.portrisk, + stsoperations = EXCLUDED.stsoperations, + driftinghighseas = EXCLUDED.driftinghighseas, + riskevents = EXCLUDED.riskevents, + flagchanges = EXCLUDED.flagchanges, + flagparismouperformance = EXCLUDED.flagparismouperformance, + flagtokyomoupeformance = EXCLUDED.flagtokyomoupeformance, + flaguscgmouperformance = EXCLUDED.flaguscgmouperformance, + uscgqualship21 = EXCLUDED.uscgqualship21, + timesincepscinspection = EXCLUDED.timesincepscinspection, + pscinspections = EXCLUDED.pscinspections, + pscdefects = EXCLUDED.pscdefects, + pscdetentions = EXCLUDED.pscdetentions, + currentsmccertificate = EXCLUDED.currentsmccertificate, + docchanges = EXCLUDED.docchanges, + currentclass = EXCLUDED.currentclass, + classstatuschanges = EXCLUDED.classstatuschanges, + pandicoverage = EXCLUDED.pandicoverage, + namechanges = EXCLUDED.namechanges, + gbochanges = EXCLUDED.gbochanges, + ageofship = EXCLUDED.ageofship, + iuufishingviolation = EXCLUDED.iuufishingviolation, + draughtchanges = EXCLUDED.draughtchanges, + mostrecentsanctionedportcall = EXCLUDED.mostrecentsanctionedportcall, + singleshipoperation = EXCLUDED.singleshipoperation, + fleetsafety = EXCLUDED.fleetsafety, + fleetpsc = EXCLUDED.fleetpsc, + specialsurveyoverdue = EXCLUDED.specialsurveyoverdue, + ownerunknown = EXCLUDED.ownerunknown, + russianportcall = EXCLUDED.russianportcall, + russianownerregistration = EXCLUDED.russianownerregistration, + russiansts = EXCLUDED.russiansts + """.formatted(targetTable, targetIndex); + } + + @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); + } + + @Override + protected String getEntityName() { + return "RiskEntity"; + } + + @Override + public void saveRiskAll(List items) { + if (items == null || items.isEmpty()) { + return; + } + jdbcTemplate.batchUpdate(getUpdateSql("risk", "lrno"), 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 saveRiskHistoryAll(List items) { + if (items == null || items.isEmpty()) { + return; + } + jdbcTemplate.batchUpdate(getUpdateSql("risk_history", "lrno, lastupdated"), 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/risk/batch/writer/RiskDataWriter.java b/src/main/java/com/snp/batch/jobs/risk/batch/writer/RiskDataWriter.java new file mode 100644 index 0000000..05ba743 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/risk/batch/writer/RiskDataWriter.java @@ -0,0 +1,24 @@ +package com.snp.batch.jobs.risk.batch.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.risk.batch.entity.RiskEntity; +import com.snp.batch.jobs.risk.batch.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/sample/batch/config/OrderDataImportJobConfig.java b/src/main/java/com/snp/batch/jobs/sample/batch/config/OrderDataImportJobConfig.java deleted file mode 100644 index 890c527..0000000 --- a/src/main/java/com/snp/batch/jobs/sample/batch/config/OrderDataImportJobConfig.java +++ /dev/null @@ -1,153 +0,0 @@ -package com.snp.batch.jobs.sample.batch.config; - -import com.snp.batch.common.batch.config.BaseJobConfig; -import com.snp.batch.jobs.sample.batch.dto.OrderDto; -import com.snp.batch.jobs.sample.batch.processor.OrderDataProcessor; -import com.snp.batch.jobs.sample.batch.writer.OrderItemWriter; -import com.snp.batch.jobs.sample.batch.writer.OrderWriter; -import lombok.extern.slf4j.Slf4j; -import org.springframework.batch.core.Job; -import org.springframework.batch.core.Step; -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.batch.item.support.CompositeItemWriter; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.transaction.PlatformTransactionManager; - -import java.util.Arrays; - -/** - * 주문 데이터 Import Job Config (복잡한 JSON 처리 예제) - * - * 특징: - * - CompositeWriter 사용 - * - 하나의 데이터 (OrderDto)를 여러 테이블에 저장 - * - OrderWriter: orders 테이블에 저장 - * - OrderItemWriter: order_items 테이블에 저장 - * - * 데이터 흐름: - * OrderDataReader - * ↓ (OrderDto) - * OrderDataProcessor - * ↓ (OrderWrapper) - * CompositeWriter { - * OrderWriter - * OrderItemWriter - * } - * - * 주의: - * - 이 JobConfig는 예제용입니다 - * - 실제 사용 시 OrderDataReader 구현 필요 - * - OrderRepository, OrderItemRepository 구현 필요 - */ -@Slf4j -@Configuration -public class OrderDataImportJobConfig extends BaseJobConfig { - - private final OrderDataProcessor orderDataProcessor; - private final OrderWriter orderWriter; - private final OrderItemWriter orderItemWriter; - - public OrderDataImportJobConfig( - JobRepository jobRepository, - PlatformTransactionManager transactionManager, - OrderDataProcessor orderDataProcessor, - OrderWriter orderWriter, - OrderItemWriter orderItemWriter) { - super(jobRepository, transactionManager); - this.orderDataProcessor = orderDataProcessor; - this.orderWriter = orderWriter; - this.orderItemWriter = orderItemWriter; - } - - @Override - protected String getJobName() { - return "orderDataImportJob"; - } - - @Override - protected ItemReader createReader() { - // 실제 구현 시 OrderDataReader 생성 - // 예제이므로 null 반환 (Job 등록 안 함) - return null; - } - - @Override - protected ItemProcessor createProcessor() { - return orderDataProcessor; - } - - /** - * CompositeWriter 생성 - * OrderWriter와 OrderItemWriter를 조합 - */ - @Override - protected ItemWriter createWriter() { - CompositeItemWriter compositeWriter = - new CompositeItemWriter<>(); - - // 여러 Writer를 순서대로 실행 - compositeWriter.setDelegates(Arrays.asList( - orderWriter, // 1. 주문 저장 - orderItemWriter // 2. 주문 상품 저장 - )); - - return compositeWriter; - } - - @Override - protected int getChunkSize() { - return 10; - } - - /** - * Job Bean 등록 (주석 처리) - * 실제 사용 시 주석 해제하고 OrderDataReader 구현 필요 - */ - // @Bean(name = "orderDataImportJob") - public Job orderDataImportJob() { - return job(); - } - - /** - * Step Bean 등록 (주석 처리) - */ - // @Bean(name = "orderDataImportStep") - public Step orderDataImportStep() { - return step(); - } -} - - -/** - * ======================================== - * CompositeWriter 사용 가이드 - * ======================================== - * - * 1. 언제 사용하는가? - * - 하나의 데이터를 여러 테이블에 저장해야 할 때 - * - 중첩된 JSON을 분해하여 관계형 DB에 저장할 때 - * - 1:N 관계 데이터 저장 시 - * - * 2. 작동 방식: - * - Processor가 여러 Entity를 Wrapper에 담아 반환 - * - CompositeWriter가 각 Writer를 순서대로 실행 - * - 모든 Writer는 동일한 Wrapper를 받음 - * - 각 Writer는 필요한 Entity만 추출하여 저장 - * - * 3. 트랜잭션: - * - 모든 Writer는 동일한 트랜잭션 내에서 실행 - * - 하나라도 실패하면 전체 롤백 - * - * 4. 주의사항: - * - Writer 실행 순서 중요 (부모 → 자식) - * - 외래 키 제약 조건 고려 - * - 성능: Chunk 크기 조정 필요 - * - * 5. 대안: - * - 간단한 경우: 단일 Writer에서 여러 Repository 호출 - * - 복잡한 경우: Tasklet 사용 - */ diff --git a/src/main/java/com/snp/batch/jobs/sample/batch/config/ProductDataImportJobConfig.java b/src/main/java/com/snp/batch/jobs/sample/batch/config/ProductDataImportJobConfig.java deleted file mode 100644 index d108fc0..0000000 --- a/src/main/java/com/snp/batch/jobs/sample/batch/config/ProductDataImportJobConfig.java +++ /dev/null @@ -1,101 +0,0 @@ -package com.snp.batch.jobs.sample.batch.config; - -import com.snp.batch.common.batch.config.BaseJobConfig; -import com.snp.batch.jobs.sample.batch.dto.ProductDto; -import com.snp.batch.jobs.sample.batch.entity.ProductEntity; -import com.snp.batch.jobs.sample.batch.reader.ProductDataReader; -import com.snp.batch.jobs.sample.batch.processor.ProductDataProcessor; -import com.snp.batch.jobs.sample.batch.writer.ProductDataWriter; -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.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.transaction.PlatformTransactionManager; - -/** - * 제품 데이터 Import Job 설정 - * BaseJobConfig를 상속하여 구현 - * - * 샘플 데이터 배치 Job: - * - Mock API에서 10개의 샘플 제품 데이터 생성 - * - 다양한 데이터 타입 (String, BigDecimal, Integer, Boolean, Double, LocalDate, Float, Long, TEXT) 포함 - * - 필터링 테스트 (비활성 제품 제외) - * - PostgreSQL에 저장 - */ -@Slf4j -@Configuration -public class ProductDataImportJobConfig extends BaseJobConfig { - - private final ProductDataReader productDataReader; - private final ProductDataProcessor productDataProcessor; - private final ProductDataWriter productDataWriter; - - /** - * 생성자 주입 - */ - public ProductDataImportJobConfig( - JobRepository jobRepository, - PlatformTransactionManager transactionManager, - ProductDataReader productDataReader, - ProductDataProcessor productDataProcessor, - ProductDataWriter productDataWriter) { - super(jobRepository, transactionManager); - this.productDataReader = productDataReader; - this.productDataProcessor = productDataProcessor; - this.productDataWriter = productDataWriter; - } - - @Override - protected String getJobName() { - return "sampleProductImportJob"; - } - - @Override - protected String getStepName() { - return "sampleProductImportStep"; - } - - @Override - protected ItemReader createReader() { - return productDataReader; - } - - @Override - protected ItemProcessor createProcessor() { - return productDataProcessor; - } - - @Override - protected ItemWriter createWriter() { - return productDataWriter; - } - - @Override - protected int getChunkSize() { - // 샘플 데이터는 10개이므로 작은 Chunk 크기 사용 - return 5; - } - - /** - * Job Bean 등록 - */ - @Bean(name = "sampleProductImportJob") - public Job sampleProductImportJob() { - return job(); - } - - /** - * Step Bean 등록 - */ - @Bean(name = "sampleProductImportStep") - public Step sampleProductImportStep() { - return step(); - } -} diff --git a/src/main/java/com/snp/batch/jobs/sample/batch/dto/OrderDto.java b/src/main/java/com/snp/batch/jobs/sample/batch/dto/OrderDto.java deleted file mode 100644 index b49915f..0000000 --- a/src/main/java/com/snp/batch/jobs/sample/batch/dto/OrderDto.java +++ /dev/null @@ -1,97 +0,0 @@ -package com.snp.batch.jobs.sample.batch.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.List; - -/** - * 주문 DTO (복잡한 JSON 예제용) - * - * API 응답 예제: - * { - * "orderId": "ORD-001", - * "customerName": "홍길동", - * "orderDate": "2025-10-16T10:30:00", - * "totalAmount": 150000, - * "items": [ - * { - * "productId": "PROD-001", - * "productName": "노트북", - * "quantity": 1, - * "price": 100000 - * }, - * { - * "productId": "PROD-002", - * "productName": "마우스", - * "quantity": 2, - * "price": 25000 - * } - * ] - * } - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class OrderDto { - - /** - * 주문 ID - */ - private String orderId; - - /** - * 고객 이름 - */ - private String customerName; - - /** - * 주문 일시 - */ - private LocalDateTime orderDate; - - /** - * 총 주문 금액 - */ - private BigDecimal totalAmount; - - /** - * 주문 상품 목록 (중첩 데이터) - */ - private List items; - - /** - * 주문 상품 DTO (내부 클래스) - */ - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class OrderItemDto { - - /** - * 상품 ID - */ - private String productId; - - /** - * 상품명 - */ - private String productName; - - /** - * 수량 - */ - private Integer quantity; - - /** - * 가격 - */ - private BigDecimal price; - } -} diff --git a/src/main/java/com/snp/batch/jobs/sample/batch/dto/ProductApiResponse.java b/src/main/java/com/snp/batch/jobs/sample/batch/dto/ProductApiResponse.java deleted file mode 100644 index 4c1d24a..0000000 --- a/src/main/java/com/snp/batch/jobs/sample/batch/dto/ProductApiResponse.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.snp.batch.jobs.sample.batch.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; - -import java.util.List; - -/** - * 제품 API 응답 래퍼 - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@JsonIgnoreProperties(ignoreUnknown = true) -public class ProductApiResponse { - - /** - * 성공 여부 - */ - @JsonProperty("success") - private Boolean success; - - /** - * 총 개수 - */ - @JsonProperty("total_count") - private Integer totalCount; - - /** - * 제품 목록 - */ - @JsonProperty("products") - private List products; - - /** - * 메시지 - */ - @JsonProperty("message") - private String message; -} diff --git a/src/main/java/com/snp/batch/jobs/sample/batch/dto/ProductDto.java b/src/main/java/com/snp/batch/jobs/sample/batch/dto/ProductDto.java deleted file mode 100644 index d944bd1..0000000 --- a/src/main/java/com/snp/batch/jobs/sample/batch/dto/ProductDto.java +++ /dev/null @@ -1,95 +0,0 @@ -package com.snp.batch.jobs.sample.batch.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; - -import java.math.BigDecimal; -import java.time.LocalDate; - -/** - * 제품 DTO (샘플 데이터) - * 다양한 데이터 타입 포함 - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@JsonIgnoreProperties(ignoreUnknown = true) -public class ProductDto { - - /** - * 제품 ID (String) - */ - @JsonProperty("product_id") - private String productId; - - /** - * 제품명 (String) - */ - @JsonProperty("product_name") - private String productName; - - /** - * 카테고리 (String) - */ - @JsonProperty("category") - private String category; - - /** - * 가격 (BigDecimal) - */ - @JsonProperty("price") - private BigDecimal price; - - /** - * 재고 수량 (Integer) - */ - @JsonProperty("stock_quantity") - private Integer stockQuantity; - - /** - * 활성 여부 (Boolean) - */ - @JsonProperty("is_active") - private Boolean isActive; - - /** - * 평점 (Double) - */ - @JsonProperty("rating") - private Double rating; - - /** - * 제조일자 (LocalDate) - */ - @JsonProperty("manufacture_date") - private LocalDate manufactureDate; - - /** - * 무게 (kg) (Float) - */ - @JsonProperty("weight") - private Float weight; - - /** - * 판매 횟수 (Long) - */ - @JsonProperty("sales_count") - private Long salesCount; - - /** - * 설명 (Text) - */ - @JsonProperty("description") - private String description; - - /** - * 태그 (JSON Array → String으로 저장) - */ - @JsonProperty("tags") - private String tags; -} diff --git a/src/main/java/com/snp/batch/jobs/sample/batch/entity/OrderEntity.java b/src/main/java/com/snp/batch/jobs/sample/batch/entity/OrderEntity.java deleted file mode 100644 index a566db9..0000000 --- a/src/main/java/com/snp/batch/jobs/sample/batch/entity/OrderEntity.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.snp.batch.jobs.sample.batch.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; - -/** - * 주문 Entity (복잡한 JSON 예제용) - * BaseEntity를 상속하여 감사 필드 포함 - * - * JPA 어노테이션 사용 금지 (JDBC 전용) - * 컬럼 매핑은 주석으로 명시 - */ -@Data -@SuperBuilder -@NoArgsConstructor -@AllArgsConstructor -@EqualsAndHashCode(callSuper = true) -public class OrderEntity extends BaseEntity { - - /** - * 기본 키 (자동 생성) - * 컬럼: id (BIGSERIAL) - */ - private Long id; - - /** - * 주문 ID (비즈니스 키) - * 컬럼: order_id (VARCHAR(50), UNIQUE, NOT NULL) - */ - private String orderId; - - /** - * 고객 이름 - * 컬럼: customer_name (VARCHAR(100)) - */ - private String customerName; - - /** - * 주문 일시 - * 컬럼: order_date (TIMESTAMP) - */ - private LocalDateTime orderDate; - - /** - * 총 주문 금액 - * 컬럼: total_amount (DECIMAL(10, 2)) - */ - private BigDecimal totalAmount; - - // createdAt, updatedAt, createdBy, updatedBy는 BaseEntity에서 상속 -} diff --git a/src/main/java/com/snp/batch/jobs/sample/batch/entity/OrderItemEntity.java b/src/main/java/com/snp/batch/jobs/sample/batch/entity/OrderItemEntity.java deleted file mode 100644 index 18dd382..0000000 --- a/src/main/java/com/snp/batch/jobs/sample/batch/entity/OrderItemEntity.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.snp.batch.jobs.sample.batch.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; - -/** - * 주문 상품 Entity (복잡한 JSON 예제용) - * BaseEntity를 상속하여 감사 필드 포함 - * - * JPA 어노테이션 사용 금지 (JDBC 전용) - * 컬럼 매핑은 주석으로 명시 - */ -@Data -@SuperBuilder -@NoArgsConstructor -@AllArgsConstructor -@EqualsAndHashCode(callSuper = true) -public class OrderItemEntity extends BaseEntity { - - /** - * 기본 키 (자동 생성) - * 컬럼: id (BIGSERIAL) - */ - private Long id; - - /** - * 주문 ID (외래 키) - * 컬럼: order_id (VARCHAR(50), NOT NULL) - */ - private String orderId; - - /** - * 상품 ID - * 컬럼: product_id (VARCHAR(50)) - */ - private String productId; - - /** - * 상품명 - * 컬럼: product_name (VARCHAR(200)) - */ - private String productName; - - /** - * 수량 - * 컬럼: quantity (INTEGER) - */ - private Integer quantity; - - /** - * 가격 - * 컬럼: price (DECIMAL(10, 2)) - */ - private BigDecimal price; - - // createdAt, updatedAt, createdBy, updatedBy는 BaseEntity에서 상속 -} diff --git a/src/main/java/com/snp/batch/jobs/sample/batch/entity/ProductEntity.java b/src/main/java/com/snp/batch/jobs/sample/batch/entity/ProductEntity.java deleted file mode 100644 index e101992..0000000 --- a/src/main/java/com/snp/batch/jobs/sample/batch/entity/ProductEntity.java +++ /dev/null @@ -1,103 +0,0 @@ -package com.snp.batch.jobs.sample.batch.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.LocalDate; - -/** - * 제품 엔티티 (샘플 데이터) - JDBC 전용 - * 다양한 데이터 타입 포함 - * - * 테이블: sample_products - */ -@Data -@SuperBuilder -@NoArgsConstructor -@AllArgsConstructor -@EqualsAndHashCode(callSuper = true) -public class ProductEntity extends BaseEntity { - - /** - * 기본 키 (자동 생성) - * 컬럼: id (BIGSERIAL) - */ - private Long id; - - /** - * 제품 ID (비즈니스 키) - * 컬럼: product_id (VARCHAR(50), UNIQUE, NOT NULL) - */ - private String productId; - - /** - * 제품명 - * 컬럼: product_name (VARCHAR(200), NOT NULL) - */ - private String productName; - - /** - * 카테고리 - * 컬럼: category (VARCHAR(100)) - */ - private String category; - - /** - * 가격 - * 컬럼: price (DECIMAL(10,2)) - */ - private BigDecimal price; - - /** - * 재고 수량 - * 컬럼: stock_quantity (INTEGER) - */ - private Integer stockQuantity; - - /** - * 활성 여부 - * 컬럼: is_active (BOOLEAN) - */ - private Boolean isActive; - - /** - * 평점 - * 컬럼: rating (DOUBLE PRECISION) - */ - private Double rating; - - /** - * 제조일자 - * 컬럼: manufacture_date (DATE) - */ - private LocalDate manufactureDate; - - /** - * 무게 (kg) - * 컬럼: weight (REAL/FLOAT) - */ - private Float weight; - - /** - * 판매 횟수 - * 컬럼: sales_count (BIGINT) - */ - private Long salesCount; - - /** - * 설명 - * 컬럼: description (TEXT) - */ - private String description; - - /** - * 태그 (JSON 문자열) - * 컬럼: tags (VARCHAR(500)) - */ - private String tags; -} diff --git a/src/main/java/com/snp/batch/jobs/sample/batch/processor/OrderDataProcessor.java b/src/main/java/com/snp/batch/jobs/sample/batch/processor/OrderDataProcessor.java deleted file mode 100644 index be24730..0000000 --- a/src/main/java/com/snp/batch/jobs/sample/batch/processor/OrderDataProcessor.java +++ /dev/null @@ -1,103 +0,0 @@ -package com.snp.batch.jobs.sample.batch.processor; - -import com.snp.batch.common.batch.processor.BaseProcessor; -import com.snp.batch.jobs.sample.batch.dto.OrderDto; -import com.snp.batch.jobs.sample.batch.entity.OrderEntity; -import com.snp.batch.jobs.sample.batch.entity.OrderItemEntity; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import java.util.ArrayList; -import java.util.List; - -/** - * 주문 데이터 Processor (복잡한 JSON 처리 예제) - * - * 처리 방식: - * 1. 중첩된 JSON (OrderDto)를 받아서 - * 2. OrderEntity (부모)와 OrderItemEntity 리스트 (자식)로 분해 - * 3. OrderWrapper에 담아서 반환 - * 4. CompositeWriter가 각각 다른 테이블에 저장 - * - * 데이터 흐름: - * OrderDto (1개) - * ↓ - * OrderDataProcessor - * ↓ - * OrderWrapper { - * OrderEntity (1개) - * List (N개) - * } - * ↓ - * CompositeWriter { - * OrderWriter → orders 테이블 - * OrderItemWriter → order_items 테이블 - * } - */ -@Slf4j -@Component -public class OrderDataProcessor extends BaseProcessor { - - /** - * OrderDto를 OrderEntity와 OrderItemEntity 리스트로 분해 - */ - @Override - protected OrderWrapper processItem(OrderDto dto) throws Exception { - log.debug("주문 데이터 처리 시작: orderId={}", dto.getOrderId()); - - // 1. OrderEntity 생성 (부모 데이터) - OrderEntity orderEntity = OrderEntity.builder() - .orderId(dto.getOrderId()) - .customerName(dto.getCustomerName()) - .orderDate(dto.getOrderDate()) - .totalAmount(dto.getTotalAmount()) - .build(); - - // 2. OrderItemEntity 리스트 생성 (자식 데이터) - List orderItems = new ArrayList<>(); - - if (dto.getItems() != null && !dto.getItems().isEmpty()) { - for (OrderDto.OrderItemDto itemDto : dto.getItems()) { - OrderItemEntity itemEntity = OrderItemEntity.builder() - .orderId(dto.getOrderId()) // 부모 orderId 연결 - .productId(itemDto.getProductId()) - .productName(itemDto.getProductName()) - .quantity(itemDto.getQuantity()) - .price(itemDto.getPrice()) - .build(); - - orderItems.add(itemEntity); - } - } - - log.debug("주문 데이터 처리 완료: orderId={}, items={}", - dto.getOrderId(), orderItems.size()); - - // 3. Wrapper에 담아서 반환 - return new OrderWrapper(orderEntity, orderItems); - } - - /** - * OrderWrapper 클래스 - * OrderEntity와 OrderItemEntity 리스트를 함께 담는 컨테이너 - * - * CompositeWriter가 이 Wrapper를 받아서 각각 다른 Writer로 전달 - */ - public static class OrderWrapper { - private final OrderEntity order; - private final List items; - - public OrderWrapper(OrderEntity order, List items) { - this.order = order; - this.items = items; - } - - public OrderEntity getOrder() { - return order; - } - - public List getItems() { - return items; - } - } -} diff --git a/src/main/java/com/snp/batch/jobs/sample/batch/processor/ProductDataProcessor.java b/src/main/java/com/snp/batch/jobs/sample/batch/processor/ProductDataProcessor.java deleted file mode 100644 index 7e05d53..0000000 --- a/src/main/java/com/snp/batch/jobs/sample/batch/processor/ProductDataProcessor.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.snp.batch.jobs.sample.batch.processor; - -import com.snp.batch.common.batch.processor.BaseProcessor; -import com.snp.batch.jobs.sample.batch.dto.ProductDto; -import com.snp.batch.jobs.sample.batch.entity.ProductEntity; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -/** - * 제품 데이터 Processor - * BaseProcessor를 상속하여 구현 - */ -@Slf4j -@Component -public class ProductDataProcessor extends BaseProcessor { - - @Override - protected ProductEntity processItem(ProductDto dto) throws Exception { - // 필터링 조건: productId가 있고, 활성화된 제품만 처리 - if (dto.getProductId() == null || dto.getProductId().isEmpty()) { - log.warn("제품 ID가 없어 필터링됨: {}", dto); - return null; - } - - if (dto.getIsActive() == null || !dto.getIsActive()) { - log.info("비활성 제품 필터링: {} ({})", dto.getProductId(), dto.getProductName()); - return null; - } - - // DTO → Entity 변환 - return ProductEntity.builder() - .productId(dto.getProductId()) - .productName(dto.getProductName()) - .category(dto.getCategory()) - .price(dto.getPrice()) - .stockQuantity(dto.getStockQuantity()) - .isActive(dto.getIsActive()) - .rating(dto.getRating()) - .manufactureDate(dto.getManufactureDate()) - .weight(dto.getWeight()) - .salesCount(dto.getSalesCount()) - .description(dto.getDescription()) - .tags(dto.getTags()) - .build(); - } -} diff --git a/src/main/java/com/snp/batch/jobs/sample/batch/reader/ProductApiReader.java b/src/main/java/com/snp/batch/jobs/sample/batch/reader/ProductApiReader.java deleted file mode 100644 index 10536fe..0000000 --- a/src/main/java/com/snp/batch/jobs/sample/batch/reader/ProductApiReader.java +++ /dev/null @@ -1,287 +0,0 @@ -package com.snp.batch.jobs.sample.batch.reader; - -import com.snp.batch.common.batch.reader.BaseApiReader; -import com.snp.batch.jobs.sample.batch.dto.ProductApiResponse; -import com.snp.batch.jobs.sample.batch.dto.ProductDto; -import lombok.extern.slf4j.Slf4j; -import org.springframework.web.reactive.function.client.WebClient; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * 제품 데이터 API Reader (실전 예제) - * BaseApiReader v2.0을 사용한 실제 API 연동 예제 - * - * 주요 기능: - * - GET/POST 요청 예제 - * - Query Parameter 처리 - * - Request Body 처리 - * - Header 설정 - * - 복잡한 JSON 응답 파싱 - * - * 사용법: - * JobConfig에서 이 Reader를 사용하려면: - * 1. @Component 또는 @Bean으로 등록 - * 2. WebClient Bean 주입 - * 3. ProductApiReader 생성 시 WebClient 전달 - * 4. application.yml에 API 설정 추가 - * - * 참고: - * - 이 클래스는 예제용으로 @Component가 제거되어 있습니다 - * - 실제 사용 시 JobConfig에서 @Bean으로 등록하세요 - */ -@Slf4j -// @Component - 예제용이므로 주석 처리 (실제 사용 시 활성화) -public class ProductApiReader extends BaseApiReader { - - /** - * WebClient 주입 생성자 - * - * @param webClient Spring WebClient 인스턴스 - */ - public ProductApiReader(WebClient webClient) { - super(webClient); - } - - // ======================================== - // 필수 구현 메서드 - // ======================================== - - @Override - protected String getReaderName() { - return "ProductApiReader"; - } - - @Override - protected List fetchDataFromApi() { - try { - // callApi() 헬퍼 메서드 사용 (GET/POST 자동 처리) - ProductApiResponse response = callApi(); - - // 응답에서 데이터 추출 - return extractDataFromResponse(response); - - } catch (Exception e) { - // 에러 처리 (빈 리스트 반환 또는 예외 던지기) - return handleApiError(e); - } - } - - // ======================================== - // HTTP 요청 설정 (예제: GET 요청) - // ======================================== - - /** - * HTTP Method 설정 - * - * GET 예제: - * return "GET"; - * - * POST 예제로 변경하려면: - * return "POST"; - */ - @Override - protected String getHttpMethod() { - return "GET"; // GET 요청 예제 - } - - /** - * API 엔드포인트 경로 - * - * 예제: - * - "/api/v1/products" - * - "/api/v1/products/search" - */ - @Override - protected String getApiPath() { - return "/api/v1/products"; - } - - /** - * Query Parameter 설정 - * - * GET 요청 시 사용되는 파라미터 - * - * 예제: - * ?status=active&category=전자제품&page=1&size=100 - */ - @Override - protected Map getQueryParams() { - Map params = new HashMap<>(); - params.put("status", "active"); // 활성 제품만 - params.put("category", "전자제품"); // 카테고리 필터 - params.put("page", 1); // 페이지 번호 - params.put("size", 100); // 페이지 크기 - return params; - } - - /** - * HTTP Header 설정 - * - * 인증 토큰, API Key 등 추가 - */ - @Override - protected Map getHeaders() { - Map headers = new HashMap<>(); - // 예제: API Key 인증 - // headers.put("X-API-Key", "your-api-key-here"); - // 예제: Bearer 토큰 인증 - // headers.put("Authorization", "Bearer " + getAccessToken()); - return headers; - } - - /** - * API 응답 타입 지정 - */ - @Override - protected Class getResponseType() { - return ProductApiResponse.class; - } - - /** - * API 응답에서 데이터 리스트 추출 - * - * 복잡한 JSON 구조 처리: - * { - * "success": true, - * "data": { - * "products": [...], - * "totalCount": 100 - * } - * } - */ - @Override - protected List extractDataFromResponse(Object response) { - if (response instanceof ProductApiResponse) { - ProductApiResponse apiResponse = (ProductApiResponse) response; - return apiResponse.getProducts(); - } - return super.extractDataFromResponse(response); - } - - // ======================================== - // 라이프사이클 훅 (선택적 오버라이드) - // ======================================== - - @Override - protected void beforeFetch() { - log.info("[{}] 제품 API 호출 준비 중...", getReaderName()); - log.info("- Method: {}", getHttpMethod()); - log.info("- Path: {}", getApiPath()); - log.info("- Query Params: {}", getQueryParams()); - } - - @Override - protected void afterFetch(List data) { - log.info("[{}] API 호출 성공: {}건 조회", getReaderName(), getDataSize(data)); - - // 데이터 검증 - if (isEmpty(data)) { - log.warn("[{}] 조회된 데이터가 없습니다!", getReaderName()); - } - } - - @Override - protected List handleApiError(Exception e) { - log.error("[{}] 제품 API 호출 실패", getReaderName(), e); - - // 선택 1: 빈 리스트 반환 (Job 실패 방지) - // return new ArrayList<>(); - - // 선택 2: 예외 던지기 (Job 실패 처리) - throw new RuntimeException("제품 데이터 조회 실패", e); - } -} - - -/** - * ======================================== - * POST 요청 예제 (주석 참고) - * ======================================== - * - * POST 요청으로 변경하려면: - * - * 1. getHttpMethod() 변경: - * @Override - * protected String getHttpMethod() { - * return "POST"; - * } - * - * 2. getRequestBody() 추가: - * @Override - * protected Object getRequestBody() { - * return ProductSearchRequest.builder() - * .startDate("2025-01-01") - * .endDate("2025-12-31") - * .categories(Arrays.asList("전자제품", "가구")) - * .minPrice(10000) - * .maxPrice(1000000) - * .build(); - * } - * - * 3. Request DTO 생성: - * @Data - * @Builder - * public class ProductSearchRequest { - * private String startDate; - * private String endDate; - * private List categories; - * private Integer minPrice; - * private Integer maxPrice; - * } - * - * 4. Query Parameter와 혼용 가능: - * - Query Parameter: URL에 추가되는 파라미터 - * - Request Body: POST Body에 포함되는 데이터 - * - * ======================================== - * Path Variable 예제 (주석 참고) - * ======================================== - * - * Path Variable 사용하려면: - * - * 1. getApiPath() 변경: - * @Override - * protected String getApiPath() { - * return "/api/v1/products/{productId}/details"; - * } - * - * 2. getPathVariables() 추가: - * @Override - * protected Map getPathVariables() { - * Map pathVars = new HashMap<>(); - * pathVars.put("productId", "PROD-001"); - * return pathVars; - * } - * - * 결과 URL: /api/v1/products/PROD-001/details - * - * ======================================== - * 다중 depth JSON 응답 예제 - * ======================================== - * - * 복잡한 JSON 구조: - * { - * "status": "success", - * "result": { - * "data": { - * "items": [ - * { "productId": "PROD-001", "name": "..." } - * ], - * "pagination": { - * "page": 1, - * "totalPages": 10 - * } - * } - * } - * } - * - * extractDataFromResponse() 구현: - * @Override - * protected List extractDataFromResponse(Object response) { - * ComplexApiResponse apiResponse = (ComplexApiResponse) response; - * return apiResponse.getResult().getData().getItems(); - * } - */ diff --git a/src/main/java/com/snp/batch/jobs/sample/batch/reader/ProductDataReader.java b/src/main/java/com/snp/batch/jobs/sample/batch/reader/ProductDataReader.java deleted file mode 100644 index 47dff0c..0000000 --- a/src/main/java/com/snp/batch/jobs/sample/batch/reader/ProductDataReader.java +++ /dev/null @@ -1,247 +0,0 @@ -package com.snp.batch.jobs.sample.batch.reader; - -import com.snp.batch.common.batch.reader.BaseApiReader; -import com.snp.batch.jobs.sample.batch.dto.ProductDto; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import java.math.BigDecimal; -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.List; - -/** - * 제품 데이터 Reader (Mock 데이터 생성) - * BaseApiReader v2.0을 상속하여 구현 - * - * 특징: - * - WebClient 없이 Mock 데이터 생성 (실제 API 호출 X) - * - 테스트 및 샘플용 Reader - * - * 실전 API 연동 예제는 ProductApiReader.java 참고 - */ -@Slf4j -@Component -public class ProductDataReader extends BaseApiReader { - - /** - * 기본 생성자 (WebClient 없이 Mock 데이터 생성) - */ - public ProductDataReader() { - super(); // WebClient 없이 초기화 - } - - // ======================================== - // 필수 구현 메서드 - // ======================================== - - @Override - protected String getReaderName() { - return "ProductDataReader"; - } - - @Override - protected List fetchDataFromApi() { - log.info("========================================"); - log.info("Mock 샘플 데이터 생성 시작"); - log.info("========================================"); - - return generateMockData(); - } - - // ======================================== - // 라이프사이클 훅 (선택적 오버라이드) - // ======================================== - - @Override - protected void beforeFetch() { - log.info("[{}] Mock 데이터 생성 준비...", getReaderName()); - } - - @Override - protected void afterFetch(List data) { - log.info("[{}] Mock 데이터 생성 완료: {}건", getReaderName(), getDataSize(data)); - } - - /** - * Mock 샘플 데이터 생성 - * 다양한 데이터 타입 포함 - */ - private List generateMockData() { - log.info("========================================"); - log.info("Mock 샘플 데이터 생성 시작"); - log.info("다양한 데이터 타입 테스트용"); - log.info("========================================"); - - List products = new ArrayList<>(); - - // 샘플 1: 전자제품 - products.add(ProductDto.builder() - .productId("PROD-001") - .productName("노트북 - MacBook Pro 16") - .category("전자제품") - .price(new BigDecimal("2999000.00")) - .stockQuantity(15) - .isActive(true) - .rating(4.8) - .manufactureDate(LocalDate.of(2024, 11, 15)) - .weight(2.1f) - .salesCount(1250L) - .description("Apple M3 Max 칩셋, 64GB RAM, 2TB SSD. 프로페셔널을 위한 최고 성능의 노트북.") - .tags("[\"Apple\", \"Laptop\", \"Premium\", \"M3\"]") - .build()); - - // 샘플 2: 가구 - products.add(ProductDto.builder() - .productId("PROD-002") - .productName("인체공학 사무용 의자") - .category("가구") - .price(new BigDecimal("450000.00")) - .stockQuantity(30) - .isActive(true) - .rating(4.5) - .manufactureDate(LocalDate.of(2024, 9, 20)) - .weight(18.5f) - .salesCount(890L) - .description("허리 건강을 위한 메쉬 의자. 10시간 이상 장시간 착석 가능.") - .tags("[\"Office\", \"Ergonomic\", \"Furniture\"]") - .build()); - - // 샘플 3: 식품 - products.add(ProductDto.builder() - .productId("PROD-003") - .productName("유기농 블루베리 (500g)") - .category("식품") - .price(new BigDecimal("12900.00")) - .stockQuantity(100) - .isActive(true) - .rating(4.9) - .manufactureDate(LocalDate.of(2025, 10, 10)) - .weight(0.5f) - .salesCount(3450L) - .description("100% 국내산 유기농 블루베리. 신선하고 달콤합니다.") - .tags("[\"Organic\", \"Fruit\", \"Fresh\", \"Healthy\"]") - .build()); - - // 샘플 4: 의류 - products.add(ProductDto.builder() - .productId("PROD-004") - .productName("겨울용 패딩 점퍼") - .category("의류") - .price(new BigDecimal("189000.00")) - .stockQuantity(50) - .isActive(true) - .rating(4.6) - .manufactureDate(LocalDate.of(2024, 10, 1)) - .weight(1.2f) - .salesCount(2100L) - .description("방수 기능이 있는 오리털 패딩. 영하 20도까지 견딜 수 있습니다.") - .tags("[\"Winter\", \"Padding\", \"Waterproof\"]") - .build()); - - // 샘플 5: 도서 - products.add(ProductDto.builder() - .productId("PROD-005") - .productName("클린 코드 (Clean Code)") - .category("도서") - .price(new BigDecimal("33000.00")) - .stockQuantity(200) - .isActive(true) - .rating(5.0) - .manufactureDate(LocalDate.of(2013, 12, 24)) - .weight(0.8f) - .salesCount(15000L) - .description("Robert C. Martin의 명저. 읽기 좋은 코드를 작성하는 방법.") - .tags("[\"Programming\", \"Book\", \"Classic\", \"BestSeller\"]") - .build()); - - // 샘플 6: 비활성 제품 (테스트용) - products.add(ProductDto.builder() - .productId("PROD-006") - .productName("단종된 구형 스마트폰") - .category("전자제품") - .price(new BigDecimal("99000.00")) - .stockQuantity(0) - .isActive(false) // 비활성 - .rating(3.2) - .manufactureDate(LocalDate.of(2020, 1, 15)) - .weight(0.18f) - .salesCount(5000L) - .description("단종된 제품입니다.") - .tags("[\"Discontinued\", \"Old\"]") - .build()); - - // 샘플 7: NULL 테스트용 - products.add(ProductDto.builder() - .productId("PROD-007") - .productName("일부 정보 누락된 제품") - .category("기타") - .price(new BigDecimal("10000.00")) - .stockQuantity(5) - .isActive(true) - .rating(null) // NULL 값 - .manufactureDate(null) // NULL 값 - .weight(null) // NULL 값 - .salesCount(0L) - .description("일부 필드가 NULL인 테스트 데이터") - .tags(null) // NULL 값 - .build()); - - // 샘플 8: 극단값 테스트 - products.add(ProductDto.builder() - .productId("PROD-008") - .productName("초고가 명품 시계") - .category("악세서리") - .price(new BigDecimal("99999999.99")) // 최대값 - .stockQuantity(1) - .isActive(true) - .rating(5.0) - .manufactureDate(LocalDate.of(2025, 1, 1)) - .weight(0.15f) - .salesCount(999999999L) // 최대값 - .description("세계 최고가의 명품 시계. 한정판 1개.") - .tags("[\"Luxury\", \"Watch\", \"Limited\"]") - .build()); - - // 샘플 9: 소수점 테스트 - products.add(ProductDto.builder() - .productId("PROD-009") - .productName("초경량 블루투스 이어폰") - .category("전자제품") - .price(new BigDecimal("79900.50")) // 소수점 - .stockQuantity(75) - .isActive(true) - .rating(4.35) // 소수점 - .manufactureDate(LocalDate.of(2025, 8, 20)) - .weight(0.045f) // 소수점 - .salesCount(8765L) - .description("초경량 무선 이어폰. 배터리 24시간 사용 가능.") - .tags("[\"Bluetooth\", \"Earbuds\", \"Lightweight\"]") - .build()); - - // 샘플 10: 긴 텍스트 테스트 - products.add(ProductDto.builder() - .productId("PROD-010") - .productName("프리미엄 멀티 비타민") - .category("건강식품") - .price(new BigDecimal("45000.00")) - .stockQuantity(120) - .isActive(true) - .rating(4.7) - .manufactureDate(LocalDate.of(2025, 6, 1)) - .weight(0.3f) - .salesCount(5432L) - .description("하루 한 알로 간편하게 섭취하는 종합 비타민입니다. " + - "비타민 A, B, C, D, E를 포함하여 총 12가지 필수 영양소가 함유되어 있습니다. " + - "GMP 인증 시설에서 제조되었으며, 식약처 인증을 받았습니다. " + - "현대인의 부족한 영양소를 한 번에 보충할 수 있습니다. " + - "임산부, 수유부, 어린이는 전문가와 상담 후 복용하시기 바랍니다.") - .tags("[\"Vitamin\", \"Health\", \"Supplement\", \"Daily\", \"GMP\"]") - .build()); - - log.info("총 {}개의 Mock 샘플 데이터 생성 완료", products.size()); - log.info("데이터 타입: String, BigDecimal, Integer, Boolean, Double, LocalDate, Float, Long, TEXT"); - - return products; - } -} diff --git a/src/main/java/com/snp/batch/jobs/sample/batch/repository/ProductRepository.java b/src/main/java/com/snp/batch/jobs/sample/batch/repository/ProductRepository.java deleted file mode 100644 index 00c27fa..0000000 --- a/src/main/java/com/snp/batch/jobs/sample/batch/repository/ProductRepository.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.snp.batch.jobs.sample.batch.repository; - -import com.snp.batch.jobs.sample.batch.entity.ProductEntity; - -import java.util.List; -import java.util.Optional; - -/** - * 제품 Repository 인터페이스 - * 구현체: ProductRepositoryImpl (JdbcTemplate 기반) - */ -public interface ProductRepository { - - // CRUD 메서드 - Optional findById(Long id); - List findAll(); - long count(); - boolean existsById(Long id); - ProductEntity save(ProductEntity entity); - void saveAll(List entities); - void deleteById(Long id); - void deleteAll(); - - // 커스텀 메서드 - /** - * 제품 ID로 조회 - */ - Optional findByProductId(String productId); - - /** - * 제품 ID 존재 여부 확인 - */ - boolean existsByProductId(String productId); - - /** - * 페이징 조회 - */ - List findAllWithPaging(int offset, int limit); -} diff --git a/src/main/java/com/snp/batch/jobs/sample/batch/repository/ProductRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/sample/batch/repository/ProductRepositoryImpl.java deleted file mode 100644 index 4916592..0000000 --- a/src/main/java/com/snp/batch/jobs/sample/batch/repository/ProductRepositoryImpl.java +++ /dev/null @@ -1,191 +0,0 @@ -package com.snp.batch.jobs.sample.batch.repository; - -import com.snp.batch.common.batch.repository.BaseJdbcRepository; -import com.snp.batch.jobs.sample.batch.entity.ProductEntity; -import lombok.extern.slf4j.Slf4j; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.core.RowMapper; -import org.springframework.stereotype.Repository; - -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.sql.Timestamp; -import java.util.List; -import java.util.Optional; - -/** - * Product Repository (JdbcTemplate 기반) - */ -@Slf4j -@Repository("productRepository") -public class ProductRepositoryImpl extends BaseJdbcRepository implements ProductRepository { - - public ProductRepositoryImpl(JdbcTemplate jdbcTemplate) { - super(jdbcTemplate); - } - - @Override - protected String getTableName() { - return "sample_products"; - } - - @Override - protected String getEntityName() { - return "Product"; - } - - @Override - protected RowMapper getRowMapper() { - return new ProductEntityRowMapper(); - } - - @Override - protected Long extractId(ProductEntity entity) { - return entity.getId(); - } - - @Override - protected String getInsertSql() { - return """ - INSERT INTO sample_products ( - product_id, product_name, category, price, stock_quantity, - is_active, rating, manufacture_date, weight, sales_count, - description, tags, created_at, updated_at, created_by, updated_by - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """; - } - - @Override - protected String getUpdateSql() { - return """ - UPDATE sample_products - SET product_name = ?, - category = ?, - price = ?, - stock_quantity = ?, - is_active = ?, - rating = ?, - manufacture_date = ?, - weight = ?, - sales_count = ?, - description = ?, - tags = ?, - updated_at = ?, - updated_by = ? - WHERE id = ? - """; - } - - @Override - protected void setInsertParameters(PreparedStatement ps, ProductEntity entity) throws Exception { - int idx = 1; - ps.setString(idx++, entity.getProductId()); - ps.setString(idx++, entity.getProductName()); - ps.setString(idx++, entity.getCategory()); - ps.setBigDecimal(idx++, entity.getPrice()); - ps.setObject(idx++, entity.getStockQuantity()); - ps.setObject(idx++, entity.getIsActive()); - ps.setObject(idx++, entity.getRating()); - ps.setObject(idx++, entity.getManufactureDate()); - ps.setObject(idx++, entity.getWeight()); - ps.setObject(idx++, entity.getSalesCount()); - ps.setString(idx++, entity.getDescription()); - ps.setString(idx++, entity.getTags()); - ps.setTimestamp(idx++, entity.getCreatedAt() != null ? - Timestamp.valueOf(entity.getCreatedAt()) : Timestamp.valueOf(now())); - ps.setTimestamp(idx++, entity.getUpdatedAt() != null ? - Timestamp.valueOf(entity.getUpdatedAt()) : Timestamp.valueOf(now())); - ps.setString(idx++, entity.getCreatedBy() != null ? entity.getCreatedBy() : "SYSTEM"); - ps.setString(idx++, entity.getUpdatedBy() != null ? entity.getUpdatedBy() : "SYSTEM"); - } - - @Override - protected void setUpdateParameters(PreparedStatement ps, ProductEntity entity) throws Exception { - int idx = 1; - ps.setString(idx++, entity.getProductName()); - ps.setString(idx++, entity.getCategory()); - ps.setBigDecimal(idx++, entity.getPrice()); - ps.setObject(idx++, entity.getStockQuantity()); - ps.setObject(idx++, entity.getIsActive()); - ps.setObject(idx++, entity.getRating()); - ps.setObject(idx++, entity.getManufactureDate()); - ps.setObject(idx++, entity.getWeight()); - ps.setObject(idx++, entity.getSalesCount()); - ps.setString(idx++, entity.getDescription()); - ps.setString(idx++, entity.getTags()); - ps.setTimestamp(idx++, Timestamp.valueOf(now())); - ps.setString(idx++, entity.getUpdatedBy() != null ? entity.getUpdatedBy() : "SYSTEM"); - ps.setLong(idx++, entity.getId()); - } - - // ==================== 커스텀 쿼리 메서드 ==================== - - /** - * Product ID로 조회 - */ - @Override - public Optional findByProductId(String productId) { - String sql = "SELECT * FROM sample_products WHERE product_id = ?"; - return executeQueryForObject(sql, productId); - } - - /** - * Product ID 존재 여부 확인 - */ - @Override - public boolean existsByProductId(String productId) { - String sql = "SELECT COUNT(*) FROM sample_products WHERE product_id = ?"; - Long count = jdbcTemplate.queryForObject(sql, Long.class, productId); - return count != null && count > 0; - } - - /** - * 페이징 조회 - */ - @Override - public List findAllWithPaging(int offset, int limit) { - String sql = "SELECT * FROM sample_products ORDER BY id DESC LIMIT ? OFFSET ?"; - return executeQueryForList(sql, limit, offset); - } - - // ==================== RowMapper ==================== - - private static class ProductEntityRowMapper implements RowMapper { - @Override - public ProductEntity mapRow(ResultSet rs, int rowNum) throws SQLException { - ProductEntity entity = ProductEntity.builder() - .id(rs.getLong("id")) - .productId(rs.getString("product_id")) - .productName(rs.getString("product_name")) - .category(rs.getString("category")) - .price(rs.getBigDecimal("price")) - .stockQuantity((Integer) rs.getObject("stock_quantity")) - .isActive((Boolean) rs.getObject("is_active")) - .rating((Double) rs.getObject("rating")) - .manufactureDate(rs.getDate("manufacture_date") != null ? - rs.getDate("manufacture_date").toLocalDate() : null) - .weight((Float) rs.getObject("weight")) - .salesCount((Long) rs.getObject("sales_count")) - .description(rs.getString("description")) - .tags(rs.getString("tags")) - .build(); - - // BaseEntity 필드 매핑 - Timestamp createdAt = rs.getTimestamp("created_at"); - if (createdAt != null) { - entity.setCreatedAt(createdAt.toLocalDateTime()); - } - - Timestamp updatedAt = rs.getTimestamp("updated_at"); - if (updatedAt != null) { - entity.setUpdatedAt(updatedAt.toLocalDateTime()); - } - - entity.setCreatedBy(rs.getString("created_by")); - entity.setUpdatedBy(rs.getString("updated_by")); - - return entity; - } - } -} diff --git a/src/main/java/com/snp/batch/jobs/sample/batch/writer/OrderItemWriter.java b/src/main/java/com/snp/batch/jobs/sample/batch/writer/OrderItemWriter.java deleted file mode 100644 index 481d666..0000000 --- a/src/main/java/com/snp/batch/jobs/sample/batch/writer/OrderItemWriter.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.snp.batch.jobs.sample.batch.writer; - -import com.snp.batch.common.batch.writer.BaseWriter; -import com.snp.batch.jobs.sample.batch.entity.OrderItemEntity; -import com.snp.batch.jobs.sample.batch.processor.OrderDataProcessor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.stream.Collectors; - -/** - * 주문 상품 Writer (복잡한 JSON 예제용) - * OrderWrapper에서 OrderItemEntity 리스트만 추출하여 저장 - */ -@Slf4j -@Component -public class OrderItemWriter extends BaseWriter { - - public OrderItemWriter() { - super("OrderItem"); - } - - @Override - protected void writeItems(List wrappers) throws Exception { - // OrderWrapper에서 OrderItemEntity 리스트만 추출 (flatten) - List allItems = wrappers.stream() - .flatMap(wrapper -> wrapper.getItems().stream()) - .collect(Collectors.toList()); - - log.info("주문 상품 데이터 저장: {} 건", allItems.size()); - - // 실제 구현 시 OrderItemRepository.saveAll(allItems) 호출 - // 예제이므로 로그만 출력 - for (OrderItemEntity item : allItems) { - log.info("주문 상품 저장: orderId={}, productId={}, quantity={}, price={}", - item.getOrderId(), - item.getProductId(), - item.getQuantity(), - item.getPrice()); - } - } -} diff --git a/src/main/java/com/snp/batch/jobs/sample/batch/writer/OrderWriter.java b/src/main/java/com/snp/batch/jobs/sample/batch/writer/OrderWriter.java deleted file mode 100644 index d91ceee..0000000 --- a/src/main/java/com/snp/batch/jobs/sample/batch/writer/OrderWriter.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.snp.batch.jobs.sample.batch.writer; - -import com.snp.batch.common.batch.writer.BaseWriter; -import com.snp.batch.jobs.sample.batch.entity.OrderEntity; -import com.snp.batch.jobs.sample.batch.processor.OrderDataProcessor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.stream.Collectors; - -/** - * 주문 Writer (복잡한 JSON 예제용) - * OrderWrapper에서 OrderEntity만 추출하여 저장 - */ -@Slf4j -@Component -public class OrderWriter extends BaseWriter { - - public OrderWriter() { - super("Order"); - } - - @Override - protected void writeItems(List wrappers) throws Exception { - // OrderWrapper에서 OrderEntity만 추출 - List orders = wrappers.stream() - .map(OrderDataProcessor.OrderWrapper::getOrder) - .collect(Collectors.toList()); - - log.info("주문 데이터 저장: {} 건", orders.size()); - - // 실제 구현 시 OrderRepository.saveAll(orders) 호출 - // 예제이므로 로그만 출력 - for (OrderEntity order : orders) { - log.info("주문 저장: orderId={}, customer={}, total={}", - order.getOrderId(), - order.getCustomerName(), - order.getTotalAmount()); - } - } -} diff --git a/src/main/java/com/snp/batch/jobs/sample/batch/writer/ProductDataWriter.java b/src/main/java/com/snp/batch/jobs/sample/batch/writer/ProductDataWriter.java deleted file mode 100644 index 94f7865..0000000 --- a/src/main/java/com/snp/batch/jobs/sample/batch/writer/ProductDataWriter.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.snp.batch.jobs.sample.batch.writer; - -import com.snp.batch.common.batch.writer.BaseWriter; -import com.snp.batch.jobs.sample.batch.entity.ProductEntity; -import com.snp.batch.jobs.sample.batch.repository.ProductRepository; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import java.util.List; - -/** - * 제품 데이터 Writer - * BaseWriter를 상속하여 구현 - */ -@Slf4j -@Component -public class ProductDataWriter extends BaseWriter { - - private final ProductRepository productRepository; - - public ProductDataWriter(ProductRepository productRepository) { - super("Product"); - this.productRepository = productRepository; - } - - @Override - protected void writeItems(List items) throws Exception { - // Repository의 saveAll() 메서드 호출 - productRepository.saveAll(items); - - // 저장된 제품 목록 출력 - log.info("========================================"); - items.forEach(product -> - log.info("✓ 저장 완료: {} - {} (가격: {}원, 재고: {}개)", - product.getProductId(), - product.getProductName(), - product.getPrice(), - product.getStockQuantity()) - ); - log.info("========================================"); - } -} diff --git a/src/main/java/com/snp/batch/jobs/sample/web/controller/ProductWebController.java b/src/main/java/com/snp/batch/jobs/sample/web/controller/ProductWebController.java deleted file mode 100644 index 01700cd..0000000 --- a/src/main/java/com/snp/batch/jobs/sample/web/controller/ProductWebController.java +++ /dev/null @@ -1,129 +0,0 @@ -package com.snp.batch.jobs.sample.web.controller; - -import com.snp.batch.common.web.ApiResponse; -import com.snp.batch.common.web.controller.BaseController; -import com.snp.batch.common.web.service.BaseService; -import com.snp.batch.jobs.sample.web.dto.ProductWebDto; -import com.snp.batch.jobs.sample.web.service.ProductWebService; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -/** - * 제품 웹 API 컨트롤러 (샘플) - * BaseController를 상속하여 공통 CRUD 엔드포인트 자동 생성 - * - * 제공되는 엔드포인트: - * - POST /api/products : 제품 생성 - * - GET /api/products/{id} : 제품 조회 - * - GET /api/products : 전체 제품 조회 - * - GET /api/products/page : 페이징 조회 - * - PUT /api/products/{id} : 제품 수정 - * - DELETE /api/products/{id} : 제품 삭제 - * - GET /api/products/{id}/exists : 존재 여부 확인 - * - * 커스텀 엔드포인트: - * - GET /api/products/by-product-id/{productId} : 제품 ID로 조회 - * - GET /api/products/stats/active-count : 활성 제품 개수 - */ -@Slf4j -@RestController -@RequestMapping("/api/products") -@RequiredArgsConstructor -@Tag(name = "Product API", description = "제품 관리 API (샘플)") -public class ProductWebController extends BaseController { - - private final ProductWebService productWebService; - - @Override - protected BaseService getService() { - return productWebService; - } - - @Override - protected String getResourceName() { - return "Product"; - } - - // ==================== 커스텀 엔드포인트 ==================== - - /** - * 제품 ID로 조회 (비즈니스 키 조회) - * - * @param productId 제품 ID (예: PROD-001) - * @return 제품 DTO - */ - @Operation( - summary = "제품 코드로 조회", - description = "제품 코드(비즈니스 키)로 제품을 조회합니다", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "200", - description = "조회 성공" - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "404", - description = "제품 없음" - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "500", - description = "서버 오류" - ) - } - ) - @GetMapping("/by-product-id/{productId}") - public ResponseEntity> getByProductId( - @Parameter(description = "제품 코드", required = true, example = "PROD-001") - @PathVariable String productId) { - log.info("제품 ID로 조회 요청: {}", productId); - try { - ProductWebDto product = productWebService.findByProductId(productId); - if (product == null) { - return ResponseEntity.notFound().build(); - } - return ResponseEntity.ok(ApiResponse.success(product)); - } catch (Exception e) { - log.error("제품 ID 조회 실패: {}", productId, e); - return ResponseEntity.internalServerError().body( - ApiResponse.error("Failed to get product by productId: " + e.getMessage()) - ); - } - } - - /** - * 활성 제품 개수 조회 - * - * @return 활성 제품 수 - */ - @Operation( - summary = "활성 제품 개수 조회", - description = "현재 활성화된 제품의 총 개수를 조회합니다", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "200", - description = "조회 성공" - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "500", - description = "서버 오류" - ) - } - ) - @GetMapping("/stats/active-count") - public ResponseEntity> getActiveCount() { - log.info("활성 제품 개수 조회 요청"); - try { - long count = productWebService.countActiveProducts(); - return ResponseEntity.ok(ApiResponse.success("Active product count", count)); - } catch (Exception e) { - log.error("활성 제품 개수 조회 실패", e); - return ResponseEntity.internalServerError().body( - ApiResponse.error("Failed to get active product count: " + e.getMessage()) - ); - } - } -} diff --git a/src/main/java/com/snp/batch/jobs/sample/web/dto/ProductResponseDto.java b/src/main/java/com/snp/batch/jobs/sample/web/dto/ProductResponseDto.java deleted file mode 100644 index ac79dc5..0000000 --- a/src/main/java/com/snp/batch/jobs/sample/web/dto/ProductResponseDto.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.snp.batch.jobs.sample.web.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.math.BigDecimal; -import java.time.LocalDate; - -/** - * 제품 API 응답 DTO - * DB에 저장된 제품 데이터를 외부에 제공할 때 사용 - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@Schema(description = "제품 정보 응답 DTO") -public class ProductResponseDto { - - @Schema(description = "제품 ID (Primary Key)", example = "1") - private Long id; - - @Schema(description = "제품 코드", example = "PROD-001") - private String productId; - - @Schema(description = "제품명", example = "노트북 - MacBook Pro 16") - private String productName; - - @Schema(description = "카테고리", example = "전자제품") - private String category; - - @Schema(description = "가격", example = "2999000.00") - private BigDecimal price; - - @Schema(description = "재고 수량", example = "15") - private Integer stockQuantity; - - @Schema(description = "활성화 여부", example = "true") - private Boolean isActive; - - @Schema(description = "평점", example = "4.8") - private Double rating; - - @Schema(description = "제조일", example = "2024-11-15") - private LocalDate manufactureDate; - - @Schema(description = "무게 (kg)", example = "2.1") - private Float weight; - - @Schema(description = "판매 수량", example = "1250") - private Long salesCount; - - @Schema(description = "제품 설명", example = "Apple M3 Max 칩셋, 64GB RAM, 2TB SSD") - private String description; - - @Schema(description = "태그 (JSON 문자열)", example = "[\"Apple\", \"Laptop\", \"Premium\"]") - private String tags; -} diff --git a/src/main/java/com/snp/batch/jobs/sample/web/dto/ProductWebDto.java b/src/main/java/com/snp/batch/jobs/sample/web/dto/ProductWebDto.java deleted file mode 100644 index e12a5dc..0000000 --- a/src/main/java/com/snp/batch/jobs/sample/web/dto/ProductWebDto.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.snp.batch.jobs.sample.web.dto; - -import com.snp.batch.common.web.dto.BaseDto; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.EqualsAndHashCode; -import lombok.NoArgsConstructor; - -import java.math.BigDecimal; -import java.time.LocalDate; - -/** - * 제품 웹 DTO (샘플) - * BaseDto를 상속하여 감사 필드 자동 포함 - * - * 이 DTO는 웹 API에서 사용되며, 배치 DTO와는 별도로 관리됩니다. - */ -@Data -@EqualsAndHashCode(callSuper = true) -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class ProductWebDto extends BaseDto { - - /** - * 제품 ID (비즈니스 키) - */ - private String productId; - - /** - * 제품명 - */ - private String productName; - - /** - * 카테고리 - */ - private String category; - - /** - * 가격 - */ - private BigDecimal price; - - /** - * 재고 수량 - */ - private Integer stockQuantity; - - /** - * 활성 여부 - */ - private Boolean isActive; - - /** - * 평점 - */ - private Double rating; - - /** - * 제조일자 - */ - private LocalDate manufactureDate; - - /** - * 무게 (kg) - */ - private Float weight; - - /** - * 판매 횟수 - */ - private Long salesCount; - - /** - * 설명 - */ - private String description; - - /** - * 태그 (JSON 문자열) - */ - private String tags; -} diff --git a/src/main/java/com/snp/batch/jobs/sample/web/service/ProductWebService.java b/src/main/java/com/snp/batch/jobs/sample/web/service/ProductWebService.java deleted file mode 100644 index 6c876d6..0000000 --- a/src/main/java/com/snp/batch/jobs/sample/web/service/ProductWebService.java +++ /dev/null @@ -1,144 +0,0 @@ -package com.snp.batch.jobs.sample.web.service; - -import com.snp.batch.common.batch.repository.BaseJdbcRepository; -import com.snp.batch.common.web.service.BaseServiceImpl; -import com.snp.batch.jobs.sample.batch.entity.ProductEntity; -import com.snp.batch.jobs.sample.batch.repository.ProductRepository; -import com.snp.batch.jobs.sample.batch.repository.ProductRepositoryImpl; -import com.snp.batch.jobs.sample.web.dto.ProductWebDto; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -import java.util.List; - -/** - * 제품 웹 서비스 (샘플) - JDBC 기반 - * BaseServiceImpl을 상속하여 공통 CRUD 기능 구현 - * - * 이 서비스는 웹 API에서 사용되며, 배치 작업과는 별도로 동작합니다. - * - Batch: ProductDataReader/Processor/Writer (배치 데이터 처리) - * - Web: ProductWebService/Controller (REST API) - */ -@Slf4j -@Service -@RequiredArgsConstructor -public class ProductWebService extends BaseServiceImpl { - - private final ProductRepositoryImpl productRepository; - - @Override - protected BaseJdbcRepository getRepository() { - return productRepository; - } - - @Override - protected String getEntityName() { - return "Product"; - } - - @Override - public ProductWebDto toDto(ProductEntity entity) { - if (entity == null) { - return null; - } - - ProductWebDto dto = ProductWebDto.builder() - .productId(entity.getProductId()) - .productName(entity.getProductName()) - .category(entity.getCategory()) - .price(entity.getPrice()) - .stockQuantity(entity.getStockQuantity()) - .isActive(entity.getIsActive()) - .rating(entity.getRating()) - .manufactureDate(entity.getManufactureDate()) - .weight(entity.getWeight()) - .salesCount(entity.getSalesCount()) - .description(entity.getDescription()) - .tags(entity.getTags()) - .build(); - - // BaseDto 필드 설정 - dto.setCreatedAt(entity.getCreatedAt()); - dto.setUpdatedAt(entity.getUpdatedAt()); - dto.setCreatedBy(entity.getCreatedBy()); - dto.setUpdatedBy(entity.getUpdatedBy()); - - return dto; - } - - @Override - public ProductEntity toEntity(ProductWebDto dto) { - if (dto == null) { - return null; - } - - return ProductEntity.builder() - .productId(dto.getProductId()) - .productName(dto.getProductName()) - .category(dto.getCategory()) - .price(dto.getPrice()) - .stockQuantity(dto.getStockQuantity()) - .isActive(dto.getIsActive()) - .rating(dto.getRating()) - .manufactureDate(dto.getManufactureDate()) - .weight(dto.getWeight()) - .salesCount(dto.getSalesCount()) - .description(dto.getDescription()) - .tags(dto.getTags()) - .build(); - } - - @Override - protected void updateEntity(ProductEntity entity, ProductWebDto dto) { - // 필드 업데이트 - entity.setProductName(dto.getProductName()); - entity.setCategory(dto.getCategory()); - entity.setPrice(dto.getPrice()); - entity.setStockQuantity(dto.getStockQuantity()); - entity.setIsActive(dto.getIsActive()); - entity.setRating(dto.getRating()); - entity.setManufactureDate(dto.getManufactureDate()); - entity.setWeight(dto.getWeight()); - entity.setSalesCount(dto.getSalesCount()); - entity.setDescription(dto.getDescription()); - entity.setTags(dto.getTags()); - - log.debug("Product 업데이트: {}", entity.getProductId()); - } - - @Override - protected Long extractId(ProductEntity entity) { - return entity.getId(); - } - - @Override - protected List executePagingQuery(int offset, int limit) { - // JDBC 페이징 쿼리 실행 - return productRepository.findAllWithPaging(offset, limit); - } - - /** - * 커스텀 메서드: 제품 ID로 조회 - * - * @param productId 제품 ID (비즈니스 키) - * @return 제품 DTO - */ - public ProductWebDto findByProductId(String productId) { - log.debug("제품 ID로 조회: {}", productId); - return productRepository.findByProductId(productId) - .map(this::toDto) - .orElse(null); - } - - /** - * 커스텀 메서드: 활성 제품 개수 - * - * @return 활성 제품 수 - */ - public long countActiveProducts() { - long total = productRepository.count(); - log.debug("전체 제품 수: {}", total); - return total; - } -} diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/config/ShipDetailImportJobConfig.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/config/ShipDetailImportJobConfig.java index 651a179..3e625a3 100644 --- a/src/main/java/com/snp/batch/jobs/shipdetail/batch/config/ShipDetailImportJobConfig.java +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/config/ShipDetailImportJobConfig.java @@ -98,7 +98,7 @@ public class ShipDetailImportJobConfig extends BaseJobConfig SYNC_TABLES = Arrays.asList( + "additionalshipsdata", "bareboatcharterhistory", + "callsignandmmsihistory", "classhistory", "companycompliancedetails", + "companyvesselrelationships", "crewlist", "darkactivityconfirmed", + "flaghistory", "groupbeneficialownerhistory", "iceclass", "namehistory", + "operatorhistory", "ownerhistory", "pandihistory", "safetymanagementcertificatehist", + "shipmanagerhistory", "sistershiplinks", "specialfeature", "statushistory", + "stowagecommodity", "surveydates", "surveydateshistoryunique", + "technicalmanagerhistory", "thrusters" + ); + + /** + * Job 구성: 모든 테이블 동기화 후 마지막 업데이트 실행 + */ + @Bean(name = "ShipDetailSyncJob") + public Job shipDetailSyncJob() { + return new JobBuilder("ShipDetailSyncJob", jobRepository) + .start(shipMasterAndCoreSyncStep()) // 1단계: Ship_Detail_Data, Core20 테이블 동기화 + .next(shipDetailSyncStep()) // 2단계: 선박제원정보 종속 25개 테이블 순차 동기화 + .next(shipDetailSyncLastExecutionUpdateStep()) // 3단계: 최종 성공 시간 업데이트 + .build(); + } + + /** + * 1단계: Ship_Detail_Data, Core20 테이블 동기화 + */ + @Bean + public Tasklet shipMasterAndCoreSyncTasklet() { + return (contribution, chunkContext) -> { + log.info(">>>>> SHIP MASTER & CORE20 동기화 프로시저 호출 시작"); + + // PostgreSQL 기준 프로시저 호출 (CALL) + jdbcTemplate.execute("CALL snp_data.proc_sync_ship_master_and_core()"); + + log.info(">>>>> SHIP MASTER & CORE20 동기화 프로시저 호출 완료"); + return RepeatStatus.FINISHED; + }; + } + + @Bean(name = "ShipMasterAndCoreSyncStep") + public Step shipMasterAndCoreSyncStep() { + return new StepBuilder("ShipMasterAndCoreSyncStep", jobRepository) + .tasklet(shipMasterAndCoreSyncTasklet(), transactionManager) + .build(); + } + + /** + * 2단계: 25개 테이블 동기화 Tasklet + */ + @Bean + public Tasklet shipDetailSyncTasklet() { + return (contribution, chunkContext) -> { + log.info(">>>>> [시작] 25개 테이블 동기화 프로세스"); + + for (String tableName : SYNC_TABLES) { + try { + log.info("테이블 동기화 중: {}", tableName); + // 이전에 생성한 동적 프로시저 호출 + jdbcTemplate.execute("CALL snp_data.proc_sync_ship_detail('" + tableName + "')"); + } catch (Exception e) { + log.error("테이블 동기화 실패: {}. 에러: {}", tableName, e.getMessage()); + // 특정 테이블 실패 시 중단할지, 계속 진행할지에 따라 throw 여부 결정 + throw e; // 중단하려면 주석 해제 + } + } + + log.info(">>>>> [완료] 25개 테이블 동기화 프로세스"); + return RepeatStatus.FINISHED; + }; + } + + @Bean(name = "ShipDetailSyncStep") + public Step shipDetailSyncStep() { + return new StepBuilder("ShipDetailSyncStep", jobRepository) + .tasklet(shipDetailSyncTasklet(), transactionManager) + .build(); + } + + /** + * 3단계: 모든 스텝 성공 시 배치 실행 로그 업데이트 + */ + @Bean + public Tasklet shipDetailSyncLastExecutionUpdateTasklet() { + return (contribution, chunkContext) -> { + log.info(">>>>> 모든 테이블 동기화 성공: BATCH_LAST_EXECUTION 업데이트 시작"); + + jdbcTemplate.execute(getBatchUpdateSql()); + + log.info(">>>>> BATCH_LAST_EXECUTION 업데이트 완료"); + return RepeatStatus.FINISHED; + }; + } + + @Bean(name = "ShipDetailSyncLastExecutionUpdateStep") + public Step shipDetailSyncLastExecutionUpdateStep() { + return new StepBuilder("ShipDetailSyncLastExecutionUpdateStep", jobRepository) + .tasklet(shipDetailSyncLastExecutionUpdateTasklet(), transactionManager) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/config/ShipDetailUpdateJobConfig.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/config/ShipDetailUpdateJobConfig.java new file mode 100644 index 0000000..6cad53b --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/config/ShipDetailUpdateJobConfig.java @@ -0,0 +1,153 @@ +package com.snp.batch.jobs.shipdetail.batch.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.config.BaseMultiStepJobConfig; +import com.snp.batch.jobs.shipdetail.batch.dto.ShipDetailComparisonData; +import com.snp.batch.jobs.shipdetail.batch.dto.ShipDetailUpdate; +import com.snp.batch.jobs.shipdetail.batch.processor.ShipDetailDataProcessor; +import com.snp.batch.jobs.shipdetail.batch.reader.ShipDetailUpdateDataReader; +import com.snp.batch.jobs.shipdetail.batch.writer.ShipDetailDataWriter; +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.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; + +@Slf4j +@Configuration +public class ShipDetailUpdateJobConfig extends BaseMultiStepJobConfig { + + private final ShipDetailDataProcessor shipDetailDataProcessor; + private final ShipDetailDataWriter shipDetailDataWriter; + private final ShipDetailUpdateDataReader shipDetailUpdateDataReader; + private final JdbcTemplate jdbcTemplate; + private final WebClient maritimeApiWebClient; + private final ObjectMapper objectMapper; // ObjectMapper 주입 추가 + private final BatchDateService batchDateService; + private final BatchApiLogService batchApiLogService; + + @Value("${app.batch.ship-api.url}") + private String maritimeApiUrl; + protected String getApiKey() {return "SHIP_DETAIL_UPDATE_API";} + protected String getBatchUpdateSql() { + return String.format("UPDATE SNP_DATA.BATCH_LAST_EXECUTION SET LAST_SUCCESS_DATE = NOW(), UPDATED_AT = NOW() WHERE API_KEY = '%s'", getApiKey());} + + + public ShipDetailUpdateJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + ShipDetailDataProcessor shipDetailDataProcessor, + ShipDetailDataWriter shipDetailDataWriter, + ShipDetailUpdateDataReader shipDetailUpdateDataReader, + JdbcTemplate jdbcTemplate, + @Qualifier("maritimeApiWebClient") WebClient maritimeApiWebClient, + ObjectMapper objectMapper, + BatchDateService batchDateService, + BatchApiLogService batchApiLogService) { // ObjectMapper 주입 추가 + super(jobRepository, transactionManager); + this.shipDetailDataProcessor = shipDetailDataProcessor; + this.shipDetailDataWriter = shipDetailDataWriter; + this.shipDetailUpdateDataReader = shipDetailUpdateDataReader; + this.jdbcTemplate = jdbcTemplate; + this.maritimeApiWebClient = maritimeApiWebClient; + this.objectMapper = objectMapper; // ObjectMapper 초기화 + this.batchDateService = batchDateService; + this.batchApiLogService = batchApiLogService; + } + + @Override + protected String getJobName() { + return "ShipDetailUpdateJob"; + } + + @Override + protected String getStepName() { + return "ShipDetailUpdateStep"; + } + + @Override + protected Job createJobFlow(JobBuilder jobBuilder) { + return jobBuilder + .start(ShipDetailUpdateStep()) + .next(shipDetailLastExecutionUpdateStep()) + .build(); + } + + @Bean + @StepScope + public ShipDetailUpdateDataReader shipDetailUpdateDataReader( + @Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, // SpEL로 ID 추출 + @Value("#{stepExecution.id}") Long stepExecutionId + ) { + ShipDetailUpdateDataReader reader = new ShipDetailUpdateDataReader(maritimeApiWebClient, jdbcTemplate, objectMapper, batchDateService, batchApiLogService, maritimeApiUrl); + reader.setExecutionIds(jobExecutionId, stepExecutionId); // ID 세팅 + return reader; + } + + @Override + protected ItemReader createReader() { // 타입 변경 + return shipDetailUpdateDataReader; + } + + @Override + protected ItemProcessor createProcessor() { + return shipDetailDataProcessor; + } + + @Override + protected ItemWriter createWriter() { // 타입 변경 + return shipDetailDataWriter; + } + + @Override + protected int getChunkSize() { + return 20; + } + + @Bean(name = "ShipDetailUpdateJob") + public Job ShipDetailUpdateJob() { + return job(); + } + + @Bean(name = "ShipDetailUpdateStep") + public Step ShipDetailUpdateStep() { + return step(); + } + + /** + * 2단계: 모든 스텝 성공 시 배치 실행 로그(날짜) 업데이트 + */ + @Bean + public Tasklet shipDetailLastExecutionUpdateTasklet() { + return (contribution, chunkContext) -> { + log.info(">>>>> 모든 스텝 성공: BATCH_LAST_EXECUTION 업데이트 시작"); + + jdbcTemplate.execute(getBatchUpdateSql()); + + log.info(">>>>> BATCH_LAST_EXECUTION 업데이트 완료"); + return RepeatStatus.FINISHED; + }; + } + @Bean(name = "ShipDetailLastExecutionUpdateStep") + public Step shipDetailLastExecutionUpdateStep() { + return new StepBuilder("ShipDetailLastExecutionUpdateStep", jobRepository) + .tasklet(shipDetailLastExecutionUpdateTasklet(), transactionManager) + .build(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/AdditionalInformationDto.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/AdditionalInformationDto.java new file mode 100644 index 0000000..9afd0ec --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/AdditionalInformationDto.java @@ -0,0 +1,47 @@ +package com.snp.batch.jobs.shipdetail.batch.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +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; +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/BareBoatCharterHistoryDto.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/BareBoatCharterHistoryDto.java new file mode 100644 index 0000000..9c8449c --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/BareBoatCharterHistoryDto.java @@ -0,0 +1,30 @@ +package com.snp.batch.jobs.shipdetail.batch.dto; +import com.fasterxml.jackson.annotation.JsonProperty; +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; +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/CallSignAndMmsiHistoryDto.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/CallSignAndMmsiHistoryDto.java new file mode 100644 index 0000000..38153e7 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/CallSignAndMmsiHistoryDto.java @@ -0,0 +1,32 @@ +package com.snp.batch.jobs.shipdetail.batch.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +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; + // MMSI는 JSON에 없으므로 DTO에 포함하지 않음. Entity에서 처리. +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/ClassHistoryDto.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/ClassHistoryDto.java new file mode 100644 index 0000000..75a07be --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/ClassHistoryDto.java @@ -0,0 +1,37 @@ +package com.snp.batch.jobs.shipdetail.batch.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +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; +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/CompanyDetailDto.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/CompanyDetailDto.java new file mode 100644 index 0000000..17f8354 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/CompanyDetailDto.java @@ -0,0 +1,82 @@ +package com.snp.batch.jobs.shipdetail.batch.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +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; + } +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/CompanyVesselRelationshipDto.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/CompanyVesselRelationshipDto.java new file mode 100644 index 0000000..c9369d7 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/CompanyVesselRelationshipDto.java @@ -0,0 +1,64 @@ +package com.snp.batch.jobs.shipdetail.batch.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +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; +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/CrewListDto.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/CrewListDto.java new file mode 100644 index 0000000..89bf1b3 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/CrewListDto.java @@ -0,0 +1,61 @@ +package com.snp.batch.jobs.shipdetail.batch.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +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; +} diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/DarkActivityConfirmedDto.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/DarkActivityConfirmedDto.java new file mode 100644 index 0000000..918374d --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/DarkActivityConfirmedDto.java @@ -0,0 +1,48 @@ +package com.snp.batch.jobs.shipdetail.batch.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +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; +} diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/FlagHistoryDto.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/FlagHistoryDto.java new file mode 100644 index 0000000..ae6b5f6 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/FlagHistoryDto.java @@ -0,0 +1,31 @@ +package com.snp.batch.jobs.shipdetail.batch.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +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; +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/GroupBeneficialOwnerHistoryDto.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/GroupBeneficialOwnerHistoryDto.java new file mode 100644 index 0000000..e27a2d1 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/GroupBeneficialOwnerHistoryDto.java @@ -0,0 +1,45 @@ +package com.snp.batch.jobs.shipdetail.batch.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@NoArgsConstructor +public class GroupBeneficialOwnerHistoryDto { + + // 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("GroupBeneficialOwner") + private String groupBeneficialOwner; // 그룹실질소유자 + + @JsonProperty("GroupBeneficialOwnerCode") + private String groupBeneficialOwnerCode; // 그룹실질소유자코드 + + @JsonProperty("LRNO") + private String lrno; // LR/IMO번호 + + @JsonProperty("Sequence") + private String sequence; // 순번 +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/IceClassDto.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/IceClassDto.java new file mode 100644 index 0000000..fe26e93 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/IceClassDto.java @@ -0,0 +1,27 @@ +package com.snp.batch.jobs.shipdetail.batch.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +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; +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/NameHistoryDto.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/NameHistoryDto.java new file mode 100644 index 0000000..9b9df13 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/NameHistoryDto.java @@ -0,0 +1,29 @@ +package com.snp.batch.jobs.shipdetail.batch.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +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; +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/OperatorHistoryDto.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/OperatorHistoryDto.java new file mode 100644 index 0000000..9a8188a --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/OperatorHistoryDto.java @@ -0,0 +1,43 @@ +package com.snp.batch.jobs.shipdetail.batch.dto; +import com.fasterxml.jackson.annotation.JsonProperty; +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; +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/OwnerHistoryDto.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/OwnerHistoryDto.java index 0e98e35..eddccd5 100644 --- a/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/OwnerHistoryDto.java +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/OwnerHistoryDto.java @@ -49,4 +49,16 @@ public class OwnerHistoryDto { */ @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; + } } diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/PandIHistoryDto.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/PandIHistoryDto.java new file mode 100644 index 0000000..b129dea --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/PandIHistoryDto.java @@ -0,0 +1,33 @@ +package com.snp.batch.jobs.shipdetail.batch.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +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; +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/SafetyManagementCertificateHistoryDto.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/SafetyManagementCertificateHistoryDto.java new file mode 100644 index 0000000..9dffa4f --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/SafetyManagementCertificateHistoryDto.java @@ -0,0 +1,49 @@ +package com.snp.batch.jobs.shipdetail.batch.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +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; +} diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/ShipDetailDto.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/ShipDetailDto.java index 3a1aa44..b6e76b0 100644 --- a/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/ShipDetailDto.java +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/ShipDetailDto.java @@ -2,10 +2,7 @@ package com.snp.batch.jobs.shipdetail.batch.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; +import lombok.*; import java.util.List; @@ -27,7 +24,17 @@ import java.util.List; @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 @@ -176,6 +183,140 @@ public class ShipDetailDto { @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 @@ -183,91 +324,168 @@ public class ShipDetailDto { @JsonProperty("OwnerHistory") private List ownerHistory; + /** + * 승선자 정보 List + * API: CrewList + */ + @JsonProperty("CrewList") + private List crewList; /** - * TODO : Core20 Dto 작성 - * shipresultindex int8 NOT NULL, -- 결과인덱스 - * batch_flag varchar(1) DEFAULT 'N'::character varying NULL -- 업데이트 이력 확인 (N:대기,P:진행,S:완료) - * vesselid varchar(7) NOT NULL, -- 선박ID - * ihslrorimoshipno varchar(7) NOT NULL, -- IMO번호 - * maritimemobileserviceidentitymmsinumber varchar(9) NULL, -- MMSI - * shipname varchar(100) NULL, -- 선명 - * callsign varchar(5) NULL, -- 호출부호 - * flagname varchar(100) NULL, -- 국가 - * portofregistry varchar(50) NULL, -- 등록항 - * classificationsociety varchar(50) NULL, -- 선급 - * shiptypelevel5 varchar(15) NULL, -- 선종(Lv5) - * shiptypelevel5subtype varchar(15) NULL, -- 세부선종 - * yearofbuild varchar(4) NULL, -- 건조연도 - * shipbuilder varchar(100) NULL, -- 조선소 - * lengthoverallloa numeric(3, 3) NULL, -- 전장(LOA)[m] - * breadthmoulded numeric(3, 3) NULL, -- 형폭(몰디드)[m] - * "depth" numeric(3, 3) NULL, -- 깊이[m] - * draught numeric(3, 3) NULL, -- 흘수[m] - * grosstonnage varchar(4) NULL, -- 총톤수(GT) - * deadweight varchar(5) NULL, -- 재화중량톤수(DWT) - * teu varchar(1) NULL, -- 컨테이너(TEU) - * speedservice numeric(2, 2) NULL, -- 항속(kt) - * mainenginetype varchar(2) NULL, -- 주기관 형식 + * 화물 적재 정보 List + * API: StowageCommodity */ - // TODO : List/Array 데이터 추가 + @JsonProperty("StowageCommodity") + private List stowageCommodity; + /** - * 선박 추가 정보 List + * 그룹 실질 소유자 이력 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; - + @JsonProperty("AdditionalInformation") + private List additionalInformation; /** - * auxengine - * auxgenerator - * ballastwatermanagement - * bareboatcharterhistory - * builderaddress - * callsignandmmsihistory - * capacities - * cargopump - * classcurrent - * classhistory - * companycompliancedetails - * companydetailscomplexwithcodesa - * companyfleetcounts - * companyorderbookcounts - * companyvesselrelationships - * crewlist - * darkactivityconfirmed - * enginebuilder - * flaghistory - * grosstonnagehistory - * groupbeneficialownerhistory - * iceclass - * liftinggear - * mainengine - * namehistory - * operatorhistory - * ownerhistory - * pandihistory - * propellers - * safetymanagementcertificatehist - * sales - * scrubberdetails - * shipbuilderandsubcontractor - * shipbuilderdetail - * shipbuilderhistory - * shipcertificatesall - * shipmanagerhistory - * shiptypehistory - * sistershiplinks - * specialfeature - * statushistory - * stowagecommodity - * surveydates - * surveydateshistoryunique - * tankcoatings - * technicalmanagerhistory - * thrusters - * */ + * 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 companyDetailDtoList; } diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/ShipDetailUpdate.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/ShipDetailUpdate.java index 9878b76..2c3f87e 100644 --- a/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/ShipDetailUpdate.java +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/ShipDetailUpdate.java @@ -1,8 +1,6 @@ package com.snp.batch.jobs.shipdetail.batch.dto; -import com.snp.batch.jobs.shipdetail.batch.entity.OwnerHistoryEntity; -import com.snp.batch.jobs.shipdetail.batch.entity.ShipDetailEntity; -import com.snp.batch.jobs.shipdetail.batch.entity.ShipHashEntity; +import com.snp.batch.jobs.shipdetail.batch.entity.*; import lombok.Builder; import lombok.Getter; @@ -24,5 +22,28 @@ public class ShipDetailUpdate { // 이 외에 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/shipdetail/batch/dto/ShipDto.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/ShipDto.java new file mode 100644 index 0000000..ba849b2 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/ShipDto.java @@ -0,0 +1,34 @@ +package com.snp.batch.jobs.shipdetail.batch.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/shipdetail/batch/dto/ShipManagerHistoryDto.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/ShipManagerHistoryDto.java new file mode 100644 index 0000000..f316b20 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/ShipManagerHistoryDto.java @@ -0,0 +1,45 @@ +package com.snp.batch.jobs.shipdetail.batch.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +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; +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/ShipUpdateApiResponse.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/ShipUpdateApiResponse.java new file mode 100644 index 0000000..b4d70d3 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/ShipUpdateApiResponse.java @@ -0,0 +1,23 @@ +package com.snp.batch.jobs.shipdetail.batch.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/shipdetail/batch/dto/SisterShipLinksDto.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/SisterShipLinksDto.java new file mode 100644 index 0000000..f651116 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/SisterShipLinksDto.java @@ -0,0 +1,25 @@ +package com.snp.batch.jobs.shipdetail.batch.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +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; +} diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/SpecialFeatureDto.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/SpecialFeatureDto.java new file mode 100644 index 0000000..ae6dd07 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/SpecialFeatureDto.java @@ -0,0 +1,29 @@ +package com.snp.batch.jobs.shipdetail.batch.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +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; +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/StatusHistoryDto.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/StatusHistoryDto.java new file mode 100644 index 0000000..b969ebf --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/StatusHistoryDto.java @@ -0,0 +1,31 @@ +package com.snp.batch.jobs.shipdetail.batch.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +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; +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/StowageCommodityDto.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/StowageCommodityDto.java new file mode 100644 index 0000000..1a80dc5 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/StowageCommodityDto.java @@ -0,0 +1,43 @@ +package com.snp.batch.jobs.shipdetail.batch.dto; +import com.fasterxml.jackson.annotation.JsonProperty; +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; +} diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/SurveyDatesHistoryDto.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/SurveyDatesHistoryDto.java new file mode 100644 index 0000000..6fd2fd7 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/SurveyDatesHistoryDto.java @@ -0,0 +1,37 @@ +package com.snp.batch.jobs.shipdetail.batch.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +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; +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/SurveyDatesHistoryUniqueDto.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/SurveyDatesHistoryUniqueDto.java new file mode 100644 index 0000000..4072a65 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/SurveyDatesHistoryUniqueDto.java @@ -0,0 +1,31 @@ +package com.snp.batch.jobs.shipdetail.batch.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +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; +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/TargetEnhancedDto.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/TargetEnhancedDto.java index a739d74..6422d83 100644 --- a/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/TargetEnhancedDto.java +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/TargetEnhancedDto.java @@ -46,7 +46,7 @@ public class TargetEnhancedDto { private String imoVerified; @JsonProperty("OnBerth") - private Boolean onBerth; + private Object onBerth; @JsonProperty("DestinationTidied") private String destinationTidied; diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/TechnicalManagerHistoryDto.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/TechnicalManagerHistoryDto.java new file mode 100644 index 0000000..4c87310 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/TechnicalManagerHistoryDto.java @@ -0,0 +1,32 @@ +package com.snp.batch.jobs.shipdetail.batch.dto; +import com.fasterxml.jackson.annotation.JsonProperty; +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; +} diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/ThrustersDto.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/ThrustersDto.java new file mode 100644 index 0000000..6ff5830 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/ThrustersDto.java @@ -0,0 +1,39 @@ +package com.snp.batch.jobs.shipdetail.batch.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +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; +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/AdditionalInformationEntity.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/AdditionalInformationEntity.java new file mode 100644 index 0000000..06107b2 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/AdditionalInformationEntity.java @@ -0,0 +1,30 @@ +package com.snp.batch.jobs.shipdetail.batch.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/shipdetail/batch/entity/BareBoatCharterHistoryEntity.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/BareBoatCharterHistoryEntity.java new file mode 100644 index 0000000..0bea631 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/BareBoatCharterHistoryEntity.java @@ -0,0 +1,21 @@ +package com.snp.batch.jobs.shipdetail.batch.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/shipdetail/batch/entity/CallSignAndMmsiHistoryEntity.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/CallSignAndMmsiHistoryEntity.java new file mode 100644 index 0000000..a8527d7 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/CallSignAndMmsiHistoryEntity.java @@ -0,0 +1,22 @@ +package com.snp.batch.jobs.shipdetail.batch.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/shipdetail/batch/entity/ClassHistoryEntity.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/ClassHistoryEntity.java new file mode 100644 index 0000000..ca8dfcf --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/ClassHistoryEntity.java @@ -0,0 +1,25 @@ +package com.snp.batch.jobs.shipdetail.batch.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/shipdetail/batch/entity/CompanyDetailEntity.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/CompanyDetailEntity.java new file mode 100644 index 0000000..98ecd3a --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/CompanyDetailEntity.java @@ -0,0 +1,46 @@ +package com.snp.batch.jobs.shipdetail.batch.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/shipdetail/batch/entity/CompanyVesselRelationshipEntity.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/CompanyVesselRelationshipEntity.java new file mode 100644 index 0000000..e799e31 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/CompanyVesselRelationshipEntity.java @@ -0,0 +1,39 @@ +package com.snp.batch.jobs.shipdetail.batch.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/shipdetail/batch/entity/CrewListEntity.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/CrewListEntity.java new file mode 100644 index 0000000..86841af --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/CrewListEntity.java @@ -0,0 +1,31 @@ +package com.snp.batch.jobs.shipdetail.batch.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/shipdetail/batch/entity/DarkActivityConfirmedEntity.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/DarkActivityConfirmedEntity.java new file mode 100644 index 0000000..f121368 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/DarkActivityConfirmedEntity.java @@ -0,0 +1,44 @@ +package com.snp.batch.jobs.shipdetail.batch.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/shipdetail/batch/entity/FlagHistoryEntity.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/FlagHistoryEntity.java new file mode 100644 index 0000000..4dc1b2e --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/FlagHistoryEntity.java @@ -0,0 +1,22 @@ +package com.snp.batch.jobs.shipdetail.batch.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/shipdetail/batch/entity/GroupBeneficialOwnerHistoryEntity.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/GroupBeneficialOwnerHistoryEntity.java new file mode 100644 index 0000000..80e0b06 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/GroupBeneficialOwnerHistoryEntity.java @@ -0,0 +1,28 @@ +package com.snp.batch.jobs.shipdetail.batch.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; + + // DB 관리 컬럼 (shipresultindex, vesselid, rowindex)는 Entity에서 제거됨 +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/IceClassEntity.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/IceClassEntity.java new file mode 100644 index 0000000..a019a9a --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/IceClassEntity.java @@ -0,0 +1,20 @@ +package com.snp.batch.jobs.shipdetail.batch.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/shipdetail/batch/entity/NameHistoryEntity.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/NameHistoryEntity.java new file mode 100644 index 0000000..2b974c6 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/NameHistoryEntity.java @@ -0,0 +1,21 @@ +package com.snp.batch.jobs.shipdetail.batch.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/shipdetail/batch/entity/OperatorHistoryEntity.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/OperatorHistoryEntity.java new file mode 100644 index 0000000..c5ddf83 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/OperatorHistoryEntity.java @@ -0,0 +1,23 @@ +package com.snp.batch.jobs.shipdetail.batch.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/shipdetail/batch/entity/OwnerHistoryEntity.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/OwnerHistoryEntity.java index 3e77ef7..ccd1ff9 100644 --- a/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/OwnerHistoryEntity.java +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/OwnerHistoryEntity.java @@ -20,4 +20,5 @@ public class OwnerHistoryEntity extends BaseEntity { private String Owner; private String OwnerCode; private String Sequence; + private String dataSetVersion; } diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/PandIHistoryEntity.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/PandIHistoryEntity.java new file mode 100644 index 0000000..dfcc3d1 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/PandIHistoryEntity.java @@ -0,0 +1,23 @@ +package com.snp.batch.jobs.shipdetail.batch.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/shipdetail/batch/entity/SafetyManagementCertificateHistoryEntity.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/SafetyManagementCertificateHistoryEntity.java new file mode 100644 index 0000000..8ec438f --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/SafetyManagementCertificateHistoryEntity.java @@ -0,0 +1,31 @@ +package com.snp.batch.jobs.shipdetail.batch.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/shipdetail/batch/entity/ShipDetailEntity.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/ShipDetailEntity.java index 3695d6b..a00778a 100644 --- a/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/ShipDetailEntity.java +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/ShipDetailEntity.java @@ -50,7 +50,7 @@ public class ShipDetailEntity extends BaseEntity { * mainenginetype varchar(2) NULL, -- 주기관 형식 * batch_flag varchar(1) DEFAULT 'N'::character varying NULL -- 업데이트 이력 확인 (N:대기,P:진행,S:완료) */ - + private String dataSetVersion; /** * 기본 키 (자동 생성) * 컬럼: id (BIGSERIAL) @@ -205,4 +205,77 @@ public class ShipDetailEntity extends BaseEntity { 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; } diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/ShipManagerHistoryEntity.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/ShipManagerHistoryEntity.java new file mode 100644 index 0000000..2fc6c07 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/ShipManagerHistoryEntity.java @@ -0,0 +1,28 @@ +package com.snp.batch.jobs.shipdetail.batch.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; + + // DB 관리 컬럼 (shipresultindex, vesselid, rowindex)는 Entity에서 제거됨 +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/SisterShipLinksEntity.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/SisterShipLinksEntity.java new file mode 100644 index 0000000..dd7a084 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/SisterShipLinksEntity.java @@ -0,0 +1,19 @@ +package com.snp.batch.jobs.shipdetail.batch.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/shipdetail/batch/entity/SpecialFeatureEntity.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/SpecialFeatureEntity.java new file mode 100644 index 0000000..da57585 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/SpecialFeatureEntity.java @@ -0,0 +1,21 @@ +package com.snp.batch.jobs.shipdetail.batch.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/shipdetail/batch/entity/StatusHistoryEntity.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/StatusHistoryEntity.java new file mode 100644 index 0000000..48deefb --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/StatusHistoryEntity.java @@ -0,0 +1,22 @@ +package com.snp.batch.jobs.shipdetail.batch.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/shipdetail/batch/entity/StowageCommodityEntity.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/StowageCommodityEntity.java new file mode 100644 index 0000000..510c270 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/StowageCommodityEntity.java @@ -0,0 +1,28 @@ +package com.snp.batch.jobs.shipdetail.batch.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) + + // DB 관리 컬럼 (shipresultindex, vesselid, rowindex)는 Entity에서 제거됨 +} diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/SurveyDatesHistoryEntity.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/SurveyDatesHistoryEntity.java new file mode 100644 index 0000000..254d128 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/SurveyDatesHistoryEntity.java @@ -0,0 +1,25 @@ +package com.snp.batch.jobs.shipdetail.batch.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/shipdetail/batch/entity/SurveyDatesHistoryUniqueEntity.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/SurveyDatesHistoryUniqueEntity.java new file mode 100644 index 0000000..47f1ebc --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/SurveyDatesHistoryUniqueEntity.java @@ -0,0 +1,22 @@ +package com.snp.batch.jobs.shipdetail.batch.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/shipdetail/batch/entity/TargetEnhancedEntity.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/TargetEnhancedEntity.java index 2689a12..3ad9c91 100644 --- a/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/TargetEnhancedEntity.java +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/TargetEnhancedEntity.java @@ -39,7 +39,7 @@ public class TargetEnhancedEntity extends BaseEntity { private String imoVerified; - private Boolean onBerth; + private Object onBerth; private String destinationTidied; diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/TechnicalManagerHistoryEntity.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/TechnicalManagerHistoryEntity.java new file mode 100644 index 0000000..996c761 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/TechnicalManagerHistoryEntity.java @@ -0,0 +1,22 @@ +package com.snp.batch.jobs.shipdetail.batch.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/shipdetail/batch/entity/ThrustersEntity.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/ThrustersEntity.java new file mode 100644 index 0000000..9067d73 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/ThrustersEntity.java @@ -0,0 +1,26 @@ +package com.snp.batch.jobs.shipdetail.batch.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/shipdetail/batch/processor/ShipDetailDataProcessor.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/processor/ShipDetailDataProcessor.java index 88ddbdf..1a55189 100644 --- a/src/main/java/com/snp/batch/jobs/shipdetail/batch/processor/ShipDetailDataProcessor.java +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/processor/ShipDetailDataProcessor.java @@ -1,13 +1,8 @@ package com.snp.batch.jobs.shipdetail.batch.processor; import com.snp.batch.common.batch.processor.BaseProcessor; -import com.snp.batch.jobs.shipdetail.batch.dto.OwnerHistoryDto; -import com.snp.batch.jobs.shipdetail.batch.dto.ShipDetailComparisonData; -import com.snp.batch.jobs.shipdetail.batch.dto.ShipDetailDto; -import com.snp.batch.jobs.shipdetail.batch.dto.ShipDetailUpdate; -import com.snp.batch.jobs.shipdetail.batch.entity.OwnerHistoryEntity; -import com.snp.batch.jobs.shipdetail.batch.entity.ShipDetailEntity; -import com.snp.batch.jobs.shipdetail.batch.entity.ShipHashEntity; +import com.snp.batch.jobs.shipdetail.batch.dto.*; +import com.snp.batch.jobs.shipdetail.batch.entity.*; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -37,18 +32,37 @@ public class ShipDetailDataProcessor extends BaseProcessor ownerHistoryEntityList = makeOwnerHistoryEntityList(comparisonData.getStructuredDto().getOwnerHistory()); - - - - log.info("선박 데이터: shipDetailEntity={}", shipDetailEntity.toString()); - log.info("선박 데이터: ownerHistoryEntityList={}", ownerHistoryEntityList.toString()); + List crewListEntityList = makeCrewListEntityList(comparisonData.getStructuredDto().getCrewList()); + List stowageCommodityEntityList = makeStowageCommodityEntityList(comparisonData.getStructuredDto().getStowageCommodity()); + List groupBeneficialOwnerHistoryEntityList = makeGroupBeneficialOwnerHistoryEntityList(comparisonData.getStructuredDto().getGroupBeneficialOwnerHistory()); + List shipManagerHistoryEntityList = makeShipManagerHistoryEntityList(comparisonData.getStructuredDto().getShipManagerHistory()); + List operatorHistoryEntityList = makeOperatorHistoryEntityList(comparisonData.getStructuredDto().getOperatorHistory()); + List technicalManagerHistoryEntityList = makeTechnicalManagerHistoryEntityList(comparisonData.getStructuredDto().getTechnicalManagerHistory()); + List bareBoatCharterHistoryEntityList = makeBareBoatCharterHistoryEntityList(comparisonData.getStructuredDto().getBareBoatCharterHistory()); + List nameHistoryEntityList = makeNameHistoryEntityList(comparisonData.getStructuredDto().getNameHistory()); + List flagHistoryEntityList = makeFlagHistoryEntityList(comparisonData.getStructuredDto().getFlagHistory()); + List additionalInformationEntityList = makeAdditionalInformationEntityList(comparisonData.getStructuredDto().getAdditionalInformation()); + List pandIHistoryEntityList = makePandIHistoryEntityList(comparisonData.getStructuredDto().getPandIHistory()); + List callSignAndMmsiHistoryEntityList = makeCallSignAndMmsiHistoryEntityList(comparisonData.getStructuredDto().getCallSignAndMmsiHistory()); + List iceClassEntityList = makeIceClassEntityList(comparisonData.getStructuredDto().getIceClass()); + List safetyManagementCertificateHistoryEntityList = makeSafetyManagementCertificateHistoryEntityList(comparisonData.getStructuredDto().getSafetyManagementCertificateHistory()); + List classHistoryEntityList = makeClassHistoryEntityList(comparisonData.getStructuredDto().getClassHistory()); + List surveyDatesHistoryEntityList = makeSurveyDatesHistoryEntityList(comparisonData.getStructuredDto().getSurveyDatesHistory()); + List surveyDatesHistoryUniqueEntityList = makeSurveyDatesHistoryUniqueEntityList(comparisonData.getStructuredDto().getSurveyDatesHistoryUnique()); + List sisterShipLinksEntityList = makeSisterShipLinksEntityList(comparisonData.getStructuredDto().getSisterShipLinks()); + List statusHistoryEntityList = makeStatusHistoryEntityList(comparisonData.getStructuredDto().getStatusHistory()); + List specialFeatureEntityList = makeSpecialFeatureEntityList(comparisonData.getStructuredDto().getSpecialFeature()); + List thrustersEntityList = makeThrustersEntityList(comparisonData.getStructuredDto().getThrusters()); + List darkActivityConfirmedEntityList = makeDarkActivityConfirmedEntityList(comparisonData.getStructuredDto().getDarkActivityConfirmed()); +// List companyComplianceEntityList = makeCompanyComplianceEntityList(comparisonData.getStructuredDto().getCompanyComplianceDetails()); : 2026-01-16 Company Compliance 수집 API 분리로 인한 제거 + List companyVesselRelationshipEntityList = makeCompanyVesselRelationshipEntityList(comparisonData.getStructuredDto().getCompanyVesselRelationships()); + List companyDetailEntityList = makeCompanyDetailEntity(comparisonData.getStructuredDto().getCompanyDetailDtoList()); // 3. 최종 업데이트 DTO 생성 (Writer에 전달) return ShipDetailUpdate.builder() @@ -57,6 +71,31 @@ public class ShipDetailDataProcessor extends BaseProcessor makeCrewListEntityList(List dtoList){ + List crewListEntityList = new ArrayList<>(); + if (dtoList == null || dtoList.isEmpty()) { + return crewListEntityList; // 빈 리스트 또는 null 입력 시 빈 리스트 반환 + } + + for (CrewListDto dto : dtoList) { + String datasetVersion = null; + if (dto.getDataSetVersion() != null) { + datasetVersion = dto.getDataSetVersion().getDataSetVersion(); + } + + // superBuilder() 대신 builder() 사용 + CrewListEntity entity = CrewListEntity.builder() + .dataSetVersion(safeGetString(datasetVersion)) + .id(safeGetString(dto.getId())) + .lrno(safeGetString(dto.getLrno())) + .shipname(safeGetString(dto.getShipname())) + .crewlistdate(safeGetString(dto.getCrewListDate())) + .nationality(safeGetString(dto.getNationality())) + .totalcrew(safeGetString(dto.getTotalCrew())) + .totalratings(safeGetString(dto.getTotalRatings())) + .totalofficers(safeGetString(dto.getTotalOfficers())) + .totalcadets(safeGetString(dto.getTotalCadets())) + .totaltrainees(safeGetString(dto.getTotalTrainees())) + .totalridingsquad(safeGetString(dto.getTotalRidingSquad())) + .totalundeclared(safeGetString(dto.getTotalUndeclared())) + // DB에서 관리되는 컬럼 임시 셋팅 로직 제거 + .build(); + + crewListEntityList.add(entity); + } + + return crewListEntityList; + } + + private List makeStowageCommodityEntityList(List dtoList){ + List entityList = new ArrayList<>(); + + if (dtoList == null || dtoList.isEmpty()) { + return entityList; // 빈 리스트 또는 null 입력 시 빈 리스트 반환 + } + + for (StowageCommodityDto dto : dtoList) { + String datasetVersion = null; + if (dto.getDataSetVersion() != null) { + datasetVersion = dto.getDataSetVersion().getDataSetVersion(); + } + + // builder() 사용 및 DB 관리 컬럼 셋팅 로직 제거 + StowageCommodityEntity entity = StowageCommodityEntity.builder() + .dataSetVersion(safeGetString(datasetVersion)) + .commodityCode(safeGetString(dto.getCommodityCode())) + .commodityDecode(safeGetString(dto.getCommodityDecode())) + .lrno(safeGetString(dto.getLrno())) + .sequence(safeGetString(dto.getSequence())) + .stowageCode(safeGetString(dto.getStowageCode())) + .stowageDecode(safeGetString(dto.getStowageDecode())) + .build(); + + entityList.add(entity); + } + + return entityList; + } + + private List makeGroupBeneficialOwnerHistoryEntityList(List dtoList){ + List entityList = new ArrayList<>(); + + if (dtoList == null || dtoList.isEmpty()) { + return entityList; // 빈 리스트 또는 null 입력 시 빈 리스트 반환 + } + + for (GroupBeneficialOwnerHistoryDto dto : dtoList) { + String datasetVersion = null; + if (dto.getDataSetVersion() != null) { + datasetVersion = dto.getDataSetVersion().getDataSetVersion(); + } + + // builder() 사용 및 DB 관리 컬럼 셋팅 로직 제거 + GroupBeneficialOwnerHistoryEntity entity = GroupBeneficialOwnerHistoryEntity.builder() + .dataSetVersion(safeGetString(datasetVersion)) + .companyStatus(safeGetString(dto.getCompanyStatus())) + .effectiveDate(safeGetString(dto.getEffectiveDate())) + .groupBeneficialOwner(safeGetString(dto.getGroupBeneficialOwner())) + .groupBeneficialOwnerCode(safeGetString(dto.getGroupBeneficialOwnerCode())) + .lrno(safeGetString(dto.getLrno())) + .sequence(safeGetString(dto.getSequence())) + .build(); + + entityList.add(entity); + } + + return entityList; + } + + private List makeShipManagerHistoryEntityList(List dtoList){ + List entityList = new ArrayList<>(); + + if (dtoList == null || dtoList.isEmpty()) { + return entityList; // 빈 리스트 또는 null 입력 시 빈 리스트 반환 + } + + for (ShipManagerHistoryDto dto : dtoList) { + String datasetVersion = null; + if (dto.getDataSetVersion() != null) { + datasetVersion = dto.getDataSetVersion().getDataSetVersion(); + } + + // builder() 사용 및 DB 관리 컬럼 셋팅 로직 제거 + ShipManagerHistoryEntity entity = ShipManagerHistoryEntity.builder() + .dataSetVersion(safeGetString(datasetVersion)) + .companyStatus(safeGetString(dto.getCompanyStatus())) + .effectiveDate(safeGetString(dto.getEffectiveDate())) + .lrno(safeGetString(dto.getLrno())) + .sequence(safeGetString(dto.getSequence())) + .shipManager(safeGetString(dto.getShipManager())) + .shipManagerCode(safeGetString(dto.getShipManagerCode())) + .build(); + + entityList.add(entity); + } + + return entityList; + } + + private List makeOperatorHistoryEntityList(List dtoList){ + List entityList = new ArrayList<>(); + + if (dtoList == null || dtoList.isEmpty()) { + return entityList; + } + + for (OperatorHistoryDto dto : dtoList) { + String datasetVersion = null; + if (dto.getDataSetVersion() != null) { + datasetVersion = dto.getDataSetVersion().getDataSetVersion(); + } + + // builder() 사용 및 DB 관리 컬럼 셋팅 로직 제거 + OperatorHistoryEntity entity = OperatorHistoryEntity.builder() + .dataSetVersion(safeGetString(datasetVersion)) + .companyStatus(safeGetString(dto.getCompanyStatus())) + .effectiveDate(safeGetString(dto.getEffectiveDate())) + .lrno(safeGetString(dto.getLrno())) + .operator(safeGetString(dto.getOperator())) + .operatorCode(safeGetString(dto.getOperatorCode())) + .sequence(safeGetString(dto.getSequence())) + .build(); + + entityList.add(entity); + } + + return entityList; + } + + private List makeTechnicalManagerHistoryEntityList(List dtoList){ + List entityList = new ArrayList<>(); + if (dtoList == null || dtoList.isEmpty()) { + return entityList; + } + for (TechnicalManagerHistoryDto dto : dtoList) { + String datasetVersion = (dto.getDataSetVersion() != null) ? dto.getDataSetVersion().getDataSetVersion() : null; + TechnicalManagerHistoryEntity entity = TechnicalManagerHistoryEntity.builder() + .dataSetVersion(safeGetString(datasetVersion)) + .companyStatus(safeGetString(dto.getCompanyStatus())) + .effectiveDate(safeGetString(dto.getEffectiveDate())) + .lrno(safeGetString(dto.getLrno())) + .sequence(safeGetString(dto.getSequence())) + .technicalManager(safeGetString(dto.getTechnicalManager())) + .technicalManagerCode(safeGetString(dto.getTechnicalManagerCode())) + .build(); + entityList.add(entity); + } + return entityList; + } + + private List makeBareBoatCharterHistoryEntityList(List dtoList){ + List entityList = new ArrayList<>(); + if (dtoList == null || dtoList.isEmpty()) { + return entityList; + } + for (BareBoatCharterHistoryDto dto : dtoList) { + String datasetVersion = (dto.getDataSetVersion() != null) ? dto.getDataSetVersion().getDataSetVersion() : null; + BareBoatCharterHistoryEntity entity = BareBoatCharterHistoryEntity.builder() + .dataSetVersion(safeGetString(datasetVersion)) + .lrno(safeGetString(dto.getLrno())) + .sequence(safeGetString(dto.getSequence())) + .effectiveDate(safeGetString(dto.getEffectiveDate())) + .bbChartererCode(safeGetString(dto.getBbChartererCode())) + .bbCharterer(safeGetString(dto.getBbCharterer())) + .build(); + entityList.add(entity); + } + return entityList; + } + + private List makeNameHistoryEntityList(List dtoList){ + List entityList = new ArrayList<>(); + if (dtoList == null || dtoList.isEmpty()) { + return entityList; + } + for (NameHistoryDto dto : dtoList) { + String datasetVersion = (dto.getDataSetVersion() != null) ? dto.getDataSetVersion().getDataSetVersion() : null; + NameHistoryEntity entity = NameHistoryEntity.builder() + .dataSetVersion(safeGetString(datasetVersion)) + .effectiveDate(safeGetString(dto.getEffectiveDate())) + .lrno(safeGetString(dto.getLrno())) + .sequence(safeGetString(dto.getSequence())) + .vesselName(safeGetString(dto.getVesselName())) + .build(); + entityList.add(entity); + } + return entityList; + } + + private List makeFlagHistoryEntityList(List dtoList){ + List entityList = new ArrayList<>(); + if (dtoList == null || dtoList.isEmpty()) { + return entityList; + } + for (FlagHistoryDto dto : dtoList) { + String datasetVersion = (dto.getDataSetVersion() != null) ? dto.getDataSetVersion().getDataSetVersion() : null; + FlagHistoryEntity entity = FlagHistoryEntity.builder() + .dataSetVersion(safeGetString(datasetVersion)) + .effectiveDate(safeGetString(dto.getEffectiveDate())) + .flag(safeGetString(dto.getFlag())) + .flagCode(safeGetString(dto.getFlagCode())) + .lrno(safeGetString(dto.getLrno())) + .sequence(safeGetString(dto.getSequence())) + .build(); + entityList.add(entity); + } + return entityList; + } + + private List makeAdditionalInformationEntityList(List dtoList){ + List entityList = new ArrayList<>(); + if (dtoList == null || dtoList.isEmpty()) { + return entityList; + } + for (AdditionalInformationDto dto : dtoList) { + // DataSetVersion은 DB 스키마에 없으므로 Entity에 매핑하지 않음 + AdditionalInformationEntity entity = AdditionalInformationEntity.builder() + .lrno(safeGetString(dto.getLrno())) + .shipemail(safeGetString(dto.getShipEmail())) + .waterdepthmax(safeGetString(dto.getWaterDepthMax())) + .drilldepthmax(safeGetString(dto.getDrillDepthMax())) + .drillbargeind(safeGetString(dto.getDrillBargeInd())) + .productionvesselind(safeGetString(dto.getProductionVesselInd())) + .deckheatexchangerind(safeGetString(dto.getDeckHeatExchangerInd())) + .deckheatexchangermaterial(safeGetString(dto.getDeckHeatExchangerMaterial())) + .tweendeckportable(safeGetString(dto.getTweenDeckPortable())) + .tweendeckfixed(safeGetString(dto.getTweenDeckFixed())) + .satcomid(safeGetString(dto.getSatComID())) + .satcomansback(safeGetString(dto.getSatComAnsBack())) + .dataSetVersion(safeGetString(dto.getDataSetVersion().getVersion())) + .build(); + entityList.add(entity); + } + return entityList; + } + + private List makePandIHistoryEntityList(List dtoList){ + List entityList = new ArrayList<>(); + if (dtoList == null || dtoList.isEmpty()) { + return entityList; + } + for (PandIHistoryDto dto : dtoList) { + String datasetVersion = (dto.getDataSetVersion() != null) ? dto.getDataSetVersion().getDataSetVersion() : null; + PandIHistoryEntity entity = PandIHistoryEntity.builder() + .dataSetVersion(safeGetString(datasetVersion)) + .lrno(safeGetString(dto.getLrno())) + .sequence(safeGetString(dto.getSequence())) + .pandiclubcode(safeGetString(dto.getPandIClubCode())) + .pandiclubdecode(safeGetString(dto.getPandIClubDecode())) + .effectiveDate(safeGetString(dto.getEffectiveDate())) + .source(safeGetString(dto.getSource())) + .build(); + entityList.add(entity); + } + return entityList; + } + + private List makeCallSignAndMmsiHistoryEntityList(List dtoList){ + List entityList = new ArrayList<>(); + if (dtoList == null || dtoList.isEmpty()) { + return entityList; + } + for (CallSignAndMmsiHistoryDto dto : dtoList) { + String datasetVersion = (dto.getDataSetVersion() != null) ? dto.getDataSetVersion().getDataSetVersion() : null; + CallSignAndMmsiHistoryEntity entity = CallSignAndMmsiHistoryEntity.builder() + .dataSetVersion(safeGetString(datasetVersion)) + .lrno(safeGetString(dto.getLrno())) + .sequence(safeGetString(dto.getSeqNo())) // SeqNo -> sequence + .callsign(safeGetString(dto.getCallSign())) + .mmsi(dto.getMmsi()) // JSON에 MMSI 필드가 없으므로 null 또는 기본값 (필요 시 수정) + .effectiveDate(safeGetString(dto.getEffectiveDate())) + .build(); + entityList.add(entity); + } + return entityList; + } + + private List makeIceClassEntityList(List dtoList){ + List entityList = new ArrayList<>(); + if (dtoList == null || dtoList.isEmpty()) { + return entityList; + } + for (IceClassDto dto : dtoList) { + String datasetVersion = (dto.getDataSetVersion() != null) ? dto.getDataSetVersion().getDataSetVersion() : null; + IceClassEntity entity = IceClassEntity.builder() + .dataSetVersion(safeGetString(datasetVersion)) + .iceClass(safeGetString(dto.getIceClass())) + .iceClassCode(safeGetString(dto.getIceClassCode())) + .lrno(safeGetString(dto.getLrno())) + .build(); + entityList.add(entity); + } + return entityList; + } + + private List makeSafetyManagementCertificateHistoryEntityList(List dtoList){ + List entityList = new ArrayList<>(); + if (dtoList == null || dtoList.isEmpty()) { + return entityList; + } + for (SafetyManagementCertificateHistoryDto dto : dtoList) { + String datasetVersion = (dto.getDataSetVersion() != null) ? dto.getDataSetVersion().getDataSetVersion() : null; + SafetyManagementCertificateHistoryEntity entity = SafetyManagementCertificateHistoryEntity.builder() + .dataSetVersion(safeGetString(datasetVersion)) + .lrno(safeGetString(dto.getLrno())) + .safetyManagementCertificateAuditor(safeGetString(dto.getSafetyManagementCertificateAuditor())) + .safetyManagementCertificateConventionOrVol(safeGetString(dto.getSafetyManagementCertificateConventionOrVol())) + .safetyManagementCertificateDateExpires(safeGetString(dto.getSafetyManagementCertificateDateExpires())) + .safetyManagementCertificateDateIssued(safeGetString(dto.getSafetyManagementCertificateDateIssued())) + .safetyManagementCertificateDOCCompany(safeGetString(dto.getSafetyManagementCertificateDOCCompany())) + .safetyManagementCertificateFlag(safeGetString(dto.getSafetyManagementCertificateFlag())) + .safetyManagementCertificateIssuer(safeGetString(dto.getSafetyManagementCertificateIssuer())) + .safetyManagementCertificateOtherDescription(safeGetString(dto.getSafetyManagementCertificateOtherDescription())) + .safetyManagementCertificateShipName(safeGetString(dto.getSafetyManagementCertificateShipName())) + .safetyManagementCertificateShipType(safeGetString(dto.getSafetyManagementCertificateShipType())) + .safetyManagementCertificateSource(safeGetString(dto.getSafetyManagementCertificateSource())) + .safetyManagementCertificateCompanyCode(safeGetString(dto.getSafetyManagementCertificateCompanyCode())) + .sequence(safeGetString(dto.getSequence())) + .build(); + entityList.add(entity); + } + return entityList; + } + + private List makeClassHistoryEntityList(List dtoList){ + List entityList = new ArrayList<>(); + if (dtoList == null || dtoList.isEmpty()) { + return entityList; + } + for (ClassHistoryDto dto : dtoList) { + String datasetVersion = (dto.getDataSetVersion() != null) ? dto.getDataSetVersion().getDataSetVersion() : null; + ClassHistoryEntity entity = ClassHistoryEntity.builder() + .dataSetVersion(safeGetString(datasetVersion)) + ._class(safeGetString(dto.get_class())) + .classCode(safeGetString(dto.getClassCode())) + .classIndicator(safeGetString(dto.getClassIndicator())) + .classID(safeGetString(dto.getClassID())) + .currentIndicator(safeGetString(dto.getCurrentIndicator())) + .effectiveDate(safeGetString(dto.getEffectiveDate())) + .lrno(safeGetString(dto.getLrno())) + .sequence(safeGetString(dto.getSequence())) + .build(); + entityList.add(entity); + } + return entityList; + } + + private List makeSurveyDatesHistoryEntityList(List dtoList){ + List entityList = new ArrayList<>(); + if (dtoList == null || dtoList.isEmpty()) { + return entityList; + } + for (SurveyDatesHistoryDto dto : dtoList) { + String datasetVersion = (dto.getDataSetVersion() != null) ? dto.getDataSetVersion().getDataSetVersion() : null; + SurveyDatesHistoryEntity entity = SurveyDatesHistoryEntity.builder() + .dataSetVersion(safeGetString(datasetVersion)) + .annualSurvey(safeGetString(dto.getAnnualSurvey())) + .classSociety(safeGetString(dto.getClassSociety())) + .classSocietyCode(safeGetString(dto.getClassSocietyCode())) + .continuousMachinerySurvey(safeGetString(dto.getContinuousMachinerySurvey())) + .dockingSurvey(safeGetString(dto.getDockingSurvey())) + .lrno(safeGetString(dto.getLrno())) + .specialSurvey(safeGetString(dto.getSpecialSurvey())) + .tailShaftSurvey(safeGetString(dto.getTailShaftSurvey())) + .build(); + entityList.add(entity); + } + return entityList; + } + + private List makeSurveyDatesHistoryUniqueEntityList(List dtoList){ + List entityList = new ArrayList<>(); + if (dtoList == null || dtoList.isEmpty()) { + return entityList; + } + for (SurveyDatesHistoryUniqueDto dto : dtoList) { + String datasetVersion = (dto.getDataSetVersion() != null) ? dto.getDataSetVersion().getDataSetVersion() : null; + SurveyDatesHistoryUniqueEntity entity = SurveyDatesHistoryUniqueEntity.builder() + .dataSetVersion(safeGetString(datasetVersion)) + .lrno(safeGetString(dto.getLrno())) + .classSocietyCode(safeGetString(dto.getClassSocietyCode())) + .surveyDate(safeGetString(dto.getSurveyDate())) + .surveyType(safeGetString(dto.getSurveyType())) + .classSociety(safeGetString(dto.getClassSociety())) + .build(); + entityList.add(entity); + } + return entityList; + } + + private List makeSisterShipLinksEntityList(List dtoList){ + List entityList = new ArrayList<>(); + if (dtoList == null || dtoList.isEmpty()) { + return entityList; + } + for (SisterShipLinksDto dto : dtoList) { + String datasetVersion = (dto.getDataSetVersion() != null) ? dto.getDataSetVersion().getDataSetVersion() : null; + SisterShipLinksEntity entity = SisterShipLinksEntity.builder() + .dataSetVersion(safeGetString(datasetVersion)) + .lrno(safeGetString(dto.getLrno())) + .linkedLRNO(safeGetString(dto.getLinkedLRNO())) + .build(); + entityList.add(entity); + } + return entityList; + } + + private List makeStatusHistoryEntityList(List dtoList){ + List entityList = new ArrayList<>(); + if (dtoList == null || dtoList.isEmpty()) { + return entityList; + } + for (StatusHistoryDto dto : dtoList) { + String datasetVersion = (dto.getDataSetVersion() != null) ? dto.getDataSetVersion().getDataSetVersion() : null; + StatusHistoryEntity entity = StatusHistoryEntity.builder() + .dataSetVersion(safeGetString(datasetVersion)) + .lrno(safeGetString(dto.getLrno())) + .sequence(safeGetString(dto.getSequence())) + .status(safeGetString(dto.getStatus())) + .statusCode(safeGetString(dto.getStatusCode())) + .statusDate(safeGetString(dto.getStatusDate())) + .build(); + entityList.add(entity); + } + return entityList; + } + + private List makeSpecialFeatureEntityList(List dtoList){ + List entityList = new ArrayList<>(); + if (dtoList == null || dtoList.isEmpty()) { + return entityList; + } + for (SpecialFeatureDto dto : dtoList) { + String datasetVersion = (dto.getDataSetVersion() != null) ? dto.getDataSetVersion().getDataSetVersion() : null; + SpecialFeatureEntity entity = SpecialFeatureEntity.builder() + .dataSetVersion(safeGetString(datasetVersion)) + .lrno(safeGetString(dto.getLrno())) + .sequence(safeGetString(dto.getSequence())) + .specialFeature(safeGetString(dto.getSpecialFeature())) + .specialFeatureCode(safeGetString(dto.getSpecialFeatureCode())) + .build(); + entityList.add(entity); + } + return entityList; + } + + private List makeThrustersEntityList(List dtoList){ + List entityList = new ArrayList<>(); + if (dtoList == null || dtoList.isEmpty()) { + return entityList; + } + for (ThrustersDto dto : dtoList) { + String datasetVersion = (dto.getDataSetVersion() != null) ? dto.getDataSetVersion().getDataSetVersion() : null; + ThrustersEntity entity = ThrustersEntity.builder() + .dataSetVersion(safeGetString(datasetVersion)) + .lrno(safeGetString(dto.getLrno())) + .sequence(safeGetString(dto.getSequence())) + .thrusterType(safeGetString(dto.getThrusterType())) + .thrusterTypeCode(safeGetString(dto.getThrusterTypeCode())) + .numberOfThrusters(safeGetString(dto.getNumberOfThrusters())) + .thrusterPosition(safeGetString(dto.getThrusterPosition())) + .thrusterBHP(safeGetString(dto.getThrusterBHP())) + .thrusterKW(safeGetString(dto.getThrusterKW())) + .typeOfInstallation(safeGetString(dto.getTypeOfInstallation())) + .build(); + entityList.add(entity); + } + return entityList; + } + + private List makeCompanyVesselRelationshipEntityList(List dtoList){ + List entityList = new ArrayList<>(); + if (dtoList == null || dtoList.isEmpty()) { + return entityList; + } + for (CompanyVesselRelationshipDto dto : dtoList) { + String datasetVersion = (dto.getDataSetVersion() != null) ? dto.getDataSetVersion().getDataSetVersion() : null; + CompanyVesselRelationshipEntity entity = CompanyVesselRelationshipEntity.builder() + .datasetversion(datasetVersion) + .doccode(dto.getDocCode()) + .doccompany(dto.getDocCompany()) + .groupbeneficialowner(dto.getGroupBeneficialOwner()) + .groupbeneficialownercode(dto.getGroupBeneficialOwnerCode()) + .lrno(dto.getLrno()) + .operator(dto.getOperator()) + .operatorcode(dto.getOperatorCode()) + .registeredowner(dto.getRegisteredOwner()) + .registeredownercode(dto.getRegisteredOwnerCode()) + .shipmanager(dto.getShipManager()) + .shipmanagercode(dto.getShipManagerCode()) + .technicalmanager(dto.getTechnicalManager()) + .technicalmanagercode(dto.getTechnicalManagerCode()) + .docgroup(dto.getDocGroup()) + .docgroupcode(dto.getDocGroupCode()) + .operatorgroup(dto.getOperatorGroup()) + .operatorgroupcode(dto.getOperatorGroupCode()) + .shipmanagergroup(dto.getShipManagerGroup()) + .shipmanagergroupcode(dto.getShipManagerGroupCode()) + .technicalmanagergroup(dto.getTechnicalManagerGroup()) + .technicalmanagergroupcode(dto.getTechnicalManagerGroupCode()) + .vesselid(dto.getLrno()) // LRNO를 VesselID로 매핑 + .build(); + entityList.add(entity); + } + return entityList; + } + + private List makeDarkActivityConfirmedEntityList(List dtoList){ + List entityList = new ArrayList<>(); + if (dtoList == null || dtoList.isEmpty()) { + return entityList; + } + for (DarkActivityConfirmedDto dto : dtoList) { + String datasetVersion = (dto.getDataSetVersion() != null) ? dto.getDataSetVersion().getDataSetVersion() : null; + DarkActivityConfirmedEntity entity = DarkActivityConfirmedEntity.builder() + .datasetversion(datasetVersion) + .lrno(dto.getLrno()) + .mmsi(dto.getMmsi()) + .vessel_name(dto.getVesselName()) + .dark_hours(dto.getDarkHours()) + .dark_activity(dto.getDarkActivity()) + .dark_status(dto.getDarkStatus()) + .area_id(dto.getAreaId()) + .area_name(dto.getAreaName()) + .area_country(dto.getAreaCountry()) + .dark_time(dto.getDarkTime()) + .dark_latitude(dto.getDarkLatitude()) + .dark_longitude(dto.getDarkLongitude()) + .dark_speed(dto.getDarkSpeed()) + .dark_heading(dto.getDarkHeading()) + .dark_draught(dto.getDarkDraught()) + .nextseen(dto.getNextSeen()) + .nextseen_speed(dto.getNextSeenSpeed()) + .nextseen_draught(dto.getNextSeenDraught()) + .nextseen_heading(dto.getNextSeenHeading()) + .dark_reported_destination(dto.getDarkReportedDestination()) + .last_port_of_call(dto.getLastPortOfCall()) + .last_port_country_code(dto.getLastPortCountryCode()) + .last_port_country(dto.getLastPortCountry()) + .nextseen_latitude(dto.getNextSeenLatitude()) + .nextseen_longitude(dto.getNextSeenLongitude()) + .nextseen_reported_destination(dto.getNextSeenReportedDestination()) + .vesselid(dto.getLrno()) + .build(); + entityList.add(entity); + } + return entityList; + } + +// private List makeCompanyComplianceEntityList(List dtoList){ +// List entityList = new ArrayList<>(); +// if (dtoList == null || dtoList.isEmpty()) { +// return entityList; +// } +// for (CompanyComplianceDto dto : dtoList) { +// String datasetVersion = (dto.getDataSetVersion() != null) ? dto.getDataSetVersion().getDataSetVersion() : null; +// CompanyComplianceEntity entity = CompanyComplianceEntity.builder() +// .datasetversion(datasetVersion) +// .owcode(dto.getOwCode()) +// .shortcompanyname(dto.getShortCompanyName()) +// .companyonofacsanctionlist(dto.getCompanyOnOFACSanctionList()) +// .companyonunsanctionlist(dto.getCompanyOnUNSanctionList()) +// .companyoneusanctionlist(dto.getCompanyOnEUSanctionList()) +// .companyonbessanctionlist(dto.getCompanyOnBESSanctionList()) +// .companyinofacsanctionedcountry(dto.getCompanyInOFACSanctionedCountry()) +// .companyinfatfjurisdiction(dto.getCompanyInFATFJurisdiction()) +// .companyoverallcompliancestatus(dto.getCompanyOverallComplianceStatus()) +// .companyonaustraliansanctionlist(dto.getCompanyOnAustralianSanctionList()) +// .companyoncanadiansanctionlist(dto.getCompanyOnCanadianSanctionList()) +// .companyonswisssanctionlist(dto.getCompanyOnSwissSanctionList()) +// .companyonofacssilist(dto.getCompanyOnOFACSSIList()) +// .companyonofacnonsdnsanctionlist(dto.getCompanyOnOFACNonSDNSanctionList()) +// .companyonuaesanctionlist(dto.getCompanyOnUAESanctionList()) +// .parentcompanycompliancerisk(dto.getParentCompanyComplianceRisk()) +// .build(); +// entityList.add(entity); +// } +// return entityList; +// } : 2026-01-16 Company Compliance 수집 API 분리로 인한 제거 + + private List makeCompanyDetailEntity(List dtoList) { + List companyDetailEntityList = new ArrayList<>(); + + if (dtoList == null || dtoList.isEmpty()) { + return companyDetailEntityList; + } + + for (CompanyDetailDto dto : dtoList) { + String datasetVersion = (dto.getDataSetVersion() != null) ? dto.getDataSetVersion().getDataSetVersion() : null; + + CompanyDetailEntity entity = CompanyDetailEntity.builder() + .dataSetVersion(safeGetString(datasetVersion)) + .owcode(safeGetString(dto.getOwcode())) + .shortcompanyname(safeGetString(dto.getShortCompanyName())) + .countryname(safeGetString(dto.getCountryName())) + .townname(safeGetString(dto.getTownName())) + .telephone(safeGetString(dto.getTelephone())) + .telex(safeGetString(dto.getTelex())) + .emailaddress(safeGetString(dto.getEmailaddress())) + .website(safeGetString(dto.getWebsite())) + .fullname(safeGetString(dto.getFullName())) + .careofcode(safeGetString(dto.getCareOfCode())) + .roomfloorbuilding1(safeGetString(dto.getRoomFloorBuilding1())) + .roomfloorbuilding2(safeGetString(dto.getRoomFloorBuilding2())) + .roomfloorbuilding3(safeGetString(dto.getRoomFloorBuilding3())) + .pobox(safeGetString(dto.getPoBox())) + .streetnumber(safeGetString(dto.getStreetNumber())) + .street(safeGetString(dto.getStreet())) + .prepostcode(safeGetString(dto.getPrePostcode())) + .postpostcode(safeGetString(dto.getPostPostcode())) + .nationalityofregistration(safeGetString(dto.getNationalityofRegistration())) + .nationalityofcontrol(safeGetString(dto.getNationalityofControl())) + .locationcode(safeGetString(dto.getLocationCode())) + .nationalityofregistrationcode(safeGetString(dto.getNationalityofRegistrationCode())) + .nationalityofcontrolcode(safeGetString(dto.getNationalityofControlCode())) + .lastchangedate(safeGetString(dto.getLastChangeDate())) + .parentcompany(safeGetString(dto.getParentCompany())) + .companystatus(safeGetString(dto.getCompanyStatus())) + .fulladdress(safeGetString(dto.getFullAddress())) + .facsimile(safeGetString(dto.getFacsimile())) + .foundeddate(safeGetString(dto.getFoundedDate())) + .build(); + + companyDetailEntityList.add(entity); + } + return companyDetailEntityList; + } /** * 해시값을 비교하여 변경 여부를 판단합니다. diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/reader/ShipDetailDataReader.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/reader/ShipDetailDataReader.java index 3070467..ee78b5d 100644 --- a/src/main/java/com/snp/batch/jobs/shipdetail/batch/reader/ShipDetailDataReader.java +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/reader/ShipDetailDataReader.java @@ -45,7 +45,7 @@ public class ShipDetailDataReader extends BaseApiReader dbMasterHashes; private int currentBatchIndex = 0; - private final int batchSize = 50; + private final int batchSize = 30; public ShipDetailDataReader(WebClient webClient, JdbcTemplate jdbcTemplate, ObjectMapper objectMapper) { super(webClient); @@ -59,21 +59,23 @@ public class ShipDetailDataReader extends BaseApiReader { + + private final BatchDateService batchDateService; // ✨ BatchDateService 필드 추가 + private final BatchApiLogService batchApiLogService; + private final String maritimeApiUrl; + private final JdbcTemplate jdbcTemplate; + private final ObjectMapper objectMapper; + // 배치 처리 상태 + + private List allImoNumbers; + // DB 해시값을 저장할 맵 + private Map dbMasterHashes; + private int currentBatchIndex = 0; + private final int batchSize = 20; + public ShipDetailUpdateDataReader(WebClient webClient, JdbcTemplate jdbcTemplate, ObjectMapper objectMapper,BatchDateService batchDateService, BatchApiLogService batchApiLogService, String maritimeApiUrl) { + super(webClient); + this.jdbcTemplate = jdbcTemplate; + this.objectMapper = objectMapper; + this.batchDateService = batchDateService; + this.batchApiLogService = batchApiLogService; + this.maritimeApiUrl = maritimeApiUrl; + enableChunkMode(); // ✨ Chunk 모드 활성화 + } + + @Override + protected String getReaderName() { + return "ShipDetailUpdateDataReader"; + } + protected String getShipUpdateApiPath(){ return "/MaritimeWCF/APSShipService.svc/RESTFul/GetShipChangesByLastUpdateDateRange"; } + @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.dbMasterHashes = null; + } + + private static final String FETCH_ALL_HASHES_QUERY = + "SELECT imo_number, ship_detail_hash FROM snp_data.ship_detail_hash_json ORDER BY imo_number"; + + /** + * 최초 1회만 실행: ship_data 테이블에서 IMO 번호 전체 조회 + */ + @Override + protected void beforeFetch() { + // 전처리 과정 + + // 💡 Step 1. 기간 내 변경된 IMO 번호 리스트 조회 + log.info("[{}] 변경된 IMO 번호 조회 시작...", getReaderName()); + ShipUpdateApiResponse response = callShipUpdateApi(); + allImoNumbers = extractUpdateImoNumbers(response); + log.info("[{}] 변경된 IMO 번호 수: {} 개", getReaderName(), response.getShipCount()); + + int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize); + + log.info("[{}] 총 {} 개의 변경된 IMO 번호 조회 완료", getReaderName(), allImoNumbers.size()); + log.info("[{}] {}개씩 배치로 분할하여 API 호출 예정", getReaderName(), batchSize); + log.info("[{}] 예상 배치 수: {} 개", getReaderName(), totalBatches); + + // Step 2. 전 배치 결과 imo_number, ship_detail_json, ship_detail_hash 데이터 전체 조회 + log.info("[{}] DB Master Hash 전체 조회 시작...", getReaderName()); + // 1-1. DB에서 모든 IMO와 Hash 조회 + dbMasterHashes = jdbcTemplate.query(FETCH_ALL_HASHES_QUERY, rs -> { + Map map = new HashMap<>(); + while (rs.next()) { + map.put(rs.getString("imo_number"), rs.getString("ship_detail_hash")); + } + return map; + }); + + log.info("[{}] DB Master Hash 조회 완료. 총 {}건.", getReaderName(), dbMasterHashes.size()); + + // API 통계 초기화 + updateApiCallStats(totalBatches, 0); + } + + /** + * ✨ Chunk 기반 핵심 메서드: 다음 100개 배치를 조회하여 반환 + * + * Spring Batch가 100건씩 read() 호출 완료 후 이 메서드 재호출 + * + * @return 다음 배치 100건 (더 이상 없으면 null) + */ + @Override + protected List fetchNextBatch() throws Exception { + + // 모든 배치 처리 완료 확인 + if (allImoNumbers == null || currentBatchIndex >= allImoNumbers.size()) { + return null; // Job 종료 + } + + // 현재 배치의 시작/끝 인덱스 계산 + int startIndex = currentBatchIndex; + int endIndex = Math.min(currentBatchIndex + batchSize, allImoNumbers.size()); + + // 현재 배치의 IMO 번호 추출 (100개) + 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()); + + try { + // IMO 번호를 쉼표로 연결 (예: "1000019,1000021,1000033,...") + String imoParam = String.join(",", currentBatch); + + // API 호출 + ShipDetailApiResponse response = callApiWithBatch(imoParam); + + // 다음 배치로 인덱스 이동 + currentBatchIndex = endIndex; + + List comparisonList = new ArrayList<>(); + + // 응답 처리 + if (response != null && response.getShipResult() != null) { + // ✨ ShipDetailComparisonData 생성 + for (ShipResultDto shipResult : response.getShipResult()) { + JsonNode rawShipDetailNode = shipResult.getShipDetailNode(); + + if (rawShipDetailNode == null) continue; + + // 💡 1) DTO로 매핑하여 IMO와 구조화된 DTO 확보 + ShipDetailDto structuredDto = null; + try { + structuredDto = objectMapper.treeToValue(rawShipDetailNode, ShipDetailDto.class); + } catch (Exception e) { + log.error("ShipDetailDto 매핑 실패: {}", e.getMessage()); + continue; + } + + String imo = structuredDto.getIhslrorimoshipno(); + if (imo == null || imo.isEmpty()) continue; + + // 💡 2) 원본 JSON 문자열 생성 + String originalJsonString = rawShipDetailNode.toString(); + + // 💡 3) API response json을 Map 형태로 변환, 정렬 및 해시 생성 + Map currentMasterMap = JsonChangeDetector.jsonToSortedFilteredMap(originalJsonString); + String currentMasterHash = JsonChangeDetector.getSha256HashFromMap(currentMasterMap); + + // 💡 4) DB Master Hash 조회 (beforeFetch에서 로드된 맵 사용) + String previousMasterHash = dbMasterHashes.getOrDefault(imo, null); + + // 💡 5) ShipDetailComparisonData DTO 생성 + comparisonList.add(ShipDetailComparisonData.builder() + .imoNumber(imo) + .previousMasterHash(previousMasterHash) + .currentMasterMap(currentMasterMap) + .currentMasterHash(currentMasterHash) + .structuredDto(structuredDto) + .build()); + } + + log.info("[{}] 배치 {}/{} 완료: {} 건 조회", + getReaderName(), currentBatchNumber, totalBatches, comparisonList.size()); + + // API 호출 통계 업데이트 + updateApiCallStats(totalBatches, currentBatchNumber); + + // API 과부하 방지 (다음 배치 전 0.5초 대기) + if (currentBatchIndex < allImoNumbers.size()) { + Thread.sleep(500); + } + + return comparisonList; + + } 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(); + } + } + + /** + * Query Parameter를 사용한 API 호출 + * + * @param imoNumbers 쉼표로 연결된 IMO 번호 (예: "1000019,1000021,...") + * @return API 응답 + */ + 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 // 람다 적용 + ); + } + + private ShipUpdateApiResponse callShipUpdateApi(){ + // 1. BatchDateService를 통해 동적 날짜 파라미터 맵 조회 + Map params = batchDateService.getDateRangeWithoutTimeParams(getApiKey()); + return executeSingleApiCall( + maritimeApiUrl, + getShipUpdateApiPath(), + params, + new ParameterizedTypeReference() {}, + batchApiLogService, + res -> res.getShips() != null ? (long) res.getShips().size() : 0L // 람다 적용 + ); + } + + private List extractUpdateImoNumbers(ShipUpdateApiResponse response) { + if (response.getShips() == null) { + return Collections.emptyList(); + } + return response.getShips() .stream() + // ShipDto 객체에서 imoNumber 필드 (String 타입)를 추출 + .map(ShipDto::getImoNumber) + // IMO 번호가 null이 아닌 경우만 필터링 (선택 사항이지만 안전성을 위해) + .filter(imoNumber -> imoNumber != null) + // 추출된 String imoNumber들을 List으로 수집 + .collect(Collectors.toList()); + } + + @Override + protected void afterFetch(List data) { + int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize); + try{ + if (data == null) { + log.info("[{}] 전체 {} 개 배치 처리 완료", getReaderName(), totalBatches); + log.info("[{}] 총 {} 개의 IMO 번호에 대한 API 호출 종료", + getReaderName(), allImoNumbers.size()); + } + }catch (Exception e){ + log.info("[{}] 전체 {} 개 배치 처리 실패", getReaderName(), totalBatches); + log.info("[{}] 총 {} 개의 IMO 번호에 대한 API 호출 종료", + getReaderName(), allImoNumbers.size()); + } + } + +} diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/reader/ShipLastPositionDataReader.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/reader/ShipLastPositionDataReader.java index 44c3247..d63cbbb 100644 --- a/src/main/java/com/snp/batch/jobs/shipdetail/batch/reader/ShipLastPositionDataReader.java +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/reader/ShipLastPositionDataReader.java @@ -33,21 +33,22 @@ public class ShipLastPositionDataReader extends BaseApiReader return "ShipLastPositionDataReader"; } + @Override + protected void resetCustomState() { + this.currentBatchIndex = 0; + this.allImoNumbers = null; + } + @Override protected String getApiPath() { return "/AisSvc.svc/AIS/GetTargetsByIMOsEnhanced"; } - @Override - protected String getApiBaseUrl() { - return "https://aisapi.maritime.spglobal.com"; - } - private String getTargetTable(){ - return "test_s_p.test_core20"; + return "new_snp.core20"; } private String GET_CORE_IMO_LIST = - "SELECT ihslrorimoshipno FROM " + getTargetTable() + " ORDER BY ihslrorimoshipno"; + "SELECT lrno FROM " + getTargetTable() + " ORDER BY lrno"; @Override protected void beforeFetch(){ diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/repository/ShipDetailRepository.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/repository/ShipDetailRepository.java index 9dd59e3..11e1ce6 100644 --- a/src/main/java/com/snp/batch/jobs/shipdetail/batch/repository/ShipDetailRepository.java +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/repository/ShipDetailRepository.java @@ -1,7 +1,6 @@ package com.snp.batch.jobs.shipdetail.batch.repository; -import com.snp.batch.jobs.shipdetail.batch.entity.OwnerHistoryEntity; -import com.snp.batch.jobs.shipdetail.batch.entity.ShipDetailEntity; +import com.snp.batch.jobs.shipdetail.batch.entity.*; import java.util.List; @@ -16,6 +15,54 @@ public interface ShipDetailRepository { 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/shipdetail/batch/repository/ShipDetailRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/repository/ShipDetailRepositoryImpl.java index e4ecfa7..88cfd22 100644 --- a/src/main/java/com/snp/batch/jobs/shipdetail/batch/repository/ShipDetailRepositoryImpl.java +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/repository/ShipDetailRepositoryImpl.java @@ -1,17 +1,15 @@ package com.snp.batch.jobs.shipdetail.batch.repository; import com.snp.batch.common.batch.repository.BaseJdbcRepository; -import com.snp.batch.jobs.shipdetail.batch.entity.OwnerHistoryEntity; -import com.snp.batch.jobs.shipdetail.batch.entity.ShipDetailEntity; +import com.snp.batch.jobs.shipdetail.batch.entity.*; import lombok.extern.slf4j.Slf4j; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; import org.springframework.stereotype.Repository; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.sql.Timestamp; +import java.sql.*; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.*; /** @@ -29,7 +27,7 @@ public class ShipDetailRepositoryImpl extends BaseJdbcRepository toInsert = entities.stream() @@ -232,7 +591,7 @@ public class ShipDetailRepositoryImpl extends BaseJdbcRepository { @@ -247,6 +606,617 @@ public class ShipDetailRepositoryImpl extends BaseJdbcRepository 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.info("{} 배치 삽입 완료: {} 건", 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.info("{} 배치 삽입 완료: {} 건", 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.info("{} 배치 삽입 완료: {} 건", 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.info("{} 배치 삽입 완료: {} 건", 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.info("{} 배치 삽입 완료: {} 건", 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.info("{} 배치 삽입 완료: {} 건", 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.info("{} 배치 삽입 완료: {} 건", 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.info("{} 배치 삽입 완료: {} 건", 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.info("{} 배치 삽입 완료: {} 건", 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.info("{} 배치 삽입 완료: {} 건", 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.info("{} 배치 삽입 완료: {} 건", 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.info("{} 배치 삽입 완료: {} 건", 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.info("{} 배치 삽입 완료: {} 건", 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.info("{} 배치 삽입 완료: {} 건", 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.info("{} 배치 삽입 완료: {} 건", 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.info("{} 배치 삽입 완료: {} 건", 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.info("{} 배치 삽입 완료: {} 건", 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.info("{} 배치 삽입 완료: {} 건", 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.info("{} 배치 삽입 완료: {} 건", 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.info("{} 배치 삽입 완료: {} 건", 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.info("{} 배치 삽입 완료: {} 건", 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.info("{} 배치 삽입 완료: {} 건", 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.info("{} 배치 삽입 완료: {} 건", 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.info("{} 배치 삽입 완료: {} 건", 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()); + } + public boolean existsByImo(String imo) { String sql = String.format("SELECT COUNT(*) FROM %s WHERE %s = ?", getTableName(), getIdColumnName("ihslrorimoshipno")); Long count = jdbcTemplate.queryForObject(sql, Long.class, imo); @@ -264,6 +1234,7 @@ public class ShipDetailRepositoryImpl extends BaseJdbcRepository { if (items.isEmpty()) { return; } - log.info("선박 상세 정보 데이터 저장: {} 건", items.size()); +// log.info("선박 상세 정보 데이터 저장: {} 건", items.size()); // 1. List -> 3가지 List 형태로 가공 // 1-1. List (Core20 데이터 처리용) @@ -44,14 +43,31 @@ public class ShipDetailDataWriter extends BaseWriter { .collect(Collectors.toList()); // 1-2. List> -> List (OwnerHistory 데이터 처리용) - // OwnerHistory는 Bulk 처리를 위해 단일 평탄화된 리스트로 만듭니다. - List ownerHistoriyListEntities = items.stream() - .flatMap(item -> { - // ⚠️ ShipDetailUpdate DTO에 List 필드가 존재해야 합니다. - List histories = item.getOwnerHistoryEntityList(); - return histories != null ? histories.stream() : new ArrayList().stream(); - }) - .collect(Collectors.toList()); + List ownerHistoriyListEntities = flattenEntities(items, ShipDetailUpdate::getOwnerHistoryEntityList); + List crewListEntities = flattenEntities(items, ShipDetailUpdate::getCrewListEntityList); + List stowageCommodityListEntities = flattenEntities(items, ShipDetailUpdate::getStowageCommodityEntityList); + List groupBeneficialOwnerHistoryListEntities = flattenEntities(items, ShipDetailUpdate::getGroupBeneficialOwnerHistoryEntityList); + List shipManagerHistoryListEntities = flattenEntities(items, ShipDetailUpdate::getShipManagerHistoryEntityList); + List operatorHistoryListEntities = flattenEntities(items, ShipDetailUpdate::getOperatorHistoryEntityList); + List technicalManagerHistoryListEntities = flattenEntities(items, ShipDetailUpdate::getTechnicalManagerHistoryEntityList); + List bareBoatCharterHistoryListEntities = flattenEntities(items, ShipDetailUpdate::getBareBoatCharterHistoryEntityList); + List nameHistoryListEntities = flattenEntities(items, ShipDetailUpdate::getNameHistoryEntityList); + List flagHistoryListEntities = flattenEntities(items, ShipDetailUpdate::getFlagHistoryEntityList); + List additionalInformationListEntities = flattenEntities(items, ShipDetailUpdate::getAdditionalInformationEntityList); + List pandIHistoryListEntities = flattenEntities(items, ShipDetailUpdate::getPandIHistoryEntityList); + List callSignAndMmsiHistoryListEntities = flattenEntities(items, ShipDetailUpdate::getCallSignAndMmsiHistoryEntityList); + List iceClassListEntities = flattenEntities(items, ShipDetailUpdate::getIceClassEntityList); + List safetyManagementCertificateHistoryListEntities = flattenEntities(items, ShipDetailUpdate::getSafetyManagementCertificateHistoryEntityList); + List classHistoryListEntities = flattenEntities(items, ShipDetailUpdate::getClassHistoryEntityList); + List surveyDatesHistoryListEntities = flattenEntities(items, ShipDetailUpdate::getSurveyDatesHistoryEntityList); + List surveyDatesHistoryUniqueListEntities = flattenEntities(items, ShipDetailUpdate::getSurveyDatesHistoryUniqueEntityList); + List sisterShipLinksListEntities = flattenEntities(items, ShipDetailUpdate::getSisterShipLinksEntityList); + List statusHistoryListEntities = flattenEntities(items, ShipDetailUpdate::getStatusHistoryEntityList); + List specialFeatureListEntities = flattenEntities(items, ShipDetailUpdate::getSpecialFeatureEntityList); + List thrustersListEntities = flattenEntities(items, ShipDetailUpdate::getThrustersEntityList); + List darkActivityConfirmedEntities = flattenEntities(items, ShipDetailUpdate::getDarkActivityConfirmedEntityList); + List companyVesselRelationshipEntities = flattenEntities(items, ShipDetailUpdate::getCompanyVesselRelationshipEntityList); + List companyDetailEntities = flattenEntities(items, ShipDetailUpdate::getCompanyDetailEntityList); // 1-3. List (Hash값 데이터 처리용) List hashEntities = items.stream() @@ -63,11 +79,106 @@ public class ShipDetailDataWriter extends BaseWriter { log.debug("Core20 데이터 저장 시작: {} 건", coreEntities.size()); shipDetailRepository.saveAllCoreData(coreEntities); - // ✅ 2-2. OwnerHistory (OwnerHistory 데이터) + // ✅ 2-2. 추가적인 Array/List 데이터 + // OwnerHistory 저장 log.debug("OwnerHistory 데이터 저장 시작: {} 건", ownerHistoriyListEntities.size()); shipDetailRepository.saveAllOwnerHistoryData(ownerHistoriyListEntities); - - // TODO : 추가적인 Array/List 데이터 저장 로직 추가 + + // CrewList 저장 + log.debug("CrewList 데이터 저장 시작: {} 건", crewListEntities.size()); + shipDetailRepository.saveAllCrewListData(crewListEntities); + + // StowageCommodity 저장 + log.debug("StowageCommodity 저장 시작: {} 건", stowageCommodityListEntities.size()); + shipDetailRepository.saveAllStowageCommodityData(stowageCommodityListEntities); + + // GroupBeneficialOwnerHistory 저장 + log.debug("GroupBeneficialOwnerHistory 저장 시작: {} 건", groupBeneficialOwnerHistoryListEntities.size()); + shipDetailRepository.saveAllGroupBeneficialOwnerHistoryData(groupBeneficialOwnerHistoryListEntities); + + // ShipManagerHistory 저장 + log.debug("ShipManagerHistory 저장 시작: {} 건", shipManagerHistoryListEntities.size()); + shipDetailRepository.saveAllShipManagerHistoryData(shipManagerHistoryListEntities); + + // OperatorHistory 저장 + log.debug("OperatorHistory 저장 시작: {} 건", operatorHistoryListEntities.size()); + shipDetailRepository.saveAllOperatorHistoryData(operatorHistoryListEntities); + + // TechnicalManagerHistory 저장 + log.debug("TechnicalManagerHistory 저장 시작: {} 건", technicalManagerHistoryListEntities.size()); + shipDetailRepository.saveAllTechnicalManagerHistoryData(technicalManagerHistoryListEntities); + + // BareBoatCharterHistory 저장 + log.debug("BareBoatCharterHistory 저장 시작: {} 건", bareBoatCharterHistoryListEntities.size()); + shipDetailRepository.saveAllBareBoatCharterHistoryData(bareBoatCharterHistoryListEntities); + + // NameHistory 저장 + log.debug("NameHistory 저장 시작: {} 건", nameHistoryListEntities.size()); + shipDetailRepository.saveAllNameHistoryData(nameHistoryListEntities); + + // FlagHistory 저장 + log.debug("FlagHistory 저장 시작: {} 건", flagHistoryListEntities.size()); + shipDetailRepository.saveAllFlagHistoryData(flagHistoryListEntities); + + // AdditionalInformation 저장 + log.debug("AdditionalInformation 저장 시작: {} 건", additionalInformationListEntities.size()); + shipDetailRepository.saveAllAdditionalInformationData(additionalInformationListEntities); + + // PandIHistory 저장 + log.debug("PandIHistory 저장 시작: {} 건", pandIHistoryListEntities.size()); + shipDetailRepository.saveAllPandIHistoryData(pandIHistoryListEntities); + + // CallSignAndMmsiHistory 저장 + log.debug("CallSignAndMmsiHistory 저장 시작: {} 건", callSignAndMmsiHistoryListEntities.size()); + shipDetailRepository.saveAllCallSignAndMmsiHistoryData(callSignAndMmsiHistoryListEntities); + + // IceClass 저장 + log.debug("IceClass 저장 시작: {} 건", iceClassListEntities.size()); + shipDetailRepository.saveAllIceClassData(iceClassListEntities); + + // SafetyManagementCertificateHistory 저장 + log.debug("SafetyManagementCertificateHistory 저장 시작: {} 건", safetyManagementCertificateHistoryListEntities.size()); + shipDetailRepository.saveAllSafetyManagementCertificateHistoryData(safetyManagementCertificateHistoryListEntities); + + // ClassHistory 저장 + log.debug("ClassHistory 저장 시작: {} 건", classHistoryListEntities.size()); + shipDetailRepository.saveAllClassHistoryData(classHistoryListEntities); + + // SurveyDatesHistory 저장 + log.debug("SurveyDatesHistory 저장 시작: {} 건", surveyDatesHistoryListEntities.size()); + shipDetailRepository.saveAllSurveyDatesHistoryData(surveyDatesHistoryListEntities); + + // SurveyDatesHistoryUnique 저장 + log.debug("SurveyDatesHistoryUnique 저장 시작: {} 건", surveyDatesHistoryUniqueListEntities.size()); + shipDetailRepository.saveAllSurveyDatesHistoryUniqueData(surveyDatesHistoryUniqueListEntities); + + // SisterShipLinks 저장 + log.debug("SisterShipLinks 저장 시작: {} 건", sisterShipLinksListEntities.size()); + shipDetailRepository.saveAllSisterShipLinksData(sisterShipLinksListEntities); + + // StatusHistory 저장 + log.debug("StatusHistory 저장 시작: {} 건", statusHistoryListEntities.size()); + shipDetailRepository.saveAllStatusHistoryData(statusHistoryListEntities); + + // SpecialFeature 저장 + log.debug("SpecialFeature 저장 시작: {} 건", specialFeatureListEntities.size()); + shipDetailRepository.saveAllSpecialFeatureData(specialFeatureListEntities); + + // Thrusters 저장 + log.debug("Thrusters 저장 시작: {} 건", thrustersListEntities.size()); + shipDetailRepository.saveAllThrustersData(thrustersListEntities); + + // DarkActivityConfirmed 저장 + log.debug("DarkActivityConfirmed 저장 시작: {} 건", darkActivityConfirmedEntities.size()); + shipDetailRepository.saveAllDarkActivityConfirmedData(darkActivityConfirmedEntities); + + // CompanyVesselRelationship 저장 + log.debug("CompanyVesselRelationship 저장 시작: {} 건", companyVesselRelationshipEntities.size()); + shipDetailRepository.saveAllCompanyVesselRelationshipData(companyVesselRelationshipEntities); + + // CompanyVesselRelationship 저장 + log.debug("Company Detail 저장 시작: {} 건", companyDetailEntities.size()); + shipDetailRepository.saveAllCompanyDetailData(companyDetailEntities); // ✅ 2-3. ShipHashRepository (Hash값 데이터) log.debug("Ship Hash 데이터 저장 시작: {} 건", hashEntities.size()); @@ -77,4 +188,26 @@ public class ShipDetailDataWriter extends BaseWriter { } + /** + * List에서 특정 Entity List들을 추출하여 하나의 List로 병합(Flatten)합니다. + * + * @param items 처리할 ShipDetailUpdate 리스트 + * @param entityListGetter ShipDetailUpdate에서 원하는 Entity List를 가져오는 Function (예: ShipDetailUpdate::getCrewListEntityList) + * @param Entity의 타입 + * @return 평탄화된 Entity 리스트 + */ + public static List flattenEntities( + List items, + Function> entityListGetter) { + + return items.stream() + // DTO에서 특정 Entity List를 추출합니다. + .map(entityListGetter) + // List가 null인 경우는 필터링하여 NPE를 방지합니다. + .filter(list -> list != null) + // List> 형태를 List 형태로 평탄화합니다. + .flatMap(Collection::stream) + .collect(Collectors.toList()); + } + } diff --git a/src/main/java/com/snp/batch/jobs/shipimport/batch/processor/ShipDataProcessor.java b/src/main/java/com/snp/batch/jobs/shipimport/batch/processor/ShipDataProcessor.java index c5af094..ff5d98a 100644 --- a/src/main/java/com/snp/batch/jobs/shipimport/batch/processor/ShipDataProcessor.java +++ b/src/main/java/com/snp/batch/jobs/shipimport/batch/processor/ShipDataProcessor.java @@ -29,7 +29,7 @@ public class ShipDataProcessor extends BaseProcessor { return null; // 스킵 } - log.debug("선박 데이터 처리 중: IMO {}", item.getImoNumber()); +// log.debug("선박 데이터 처리 중: IMO {}", item.getImoNumber()); // 중복 체크 및 업데이트 return shipRepository.findByImoNumber(item.getImoNumber()) diff --git a/src/main/java/com/snp/batch/jobs/shipimport/batch/repository/ShipRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/shipimport/batch/repository/ShipRepositoryImpl.java index 0684917..14c8e94 100644 --- a/src/main/java/com/snp/batch/jobs/shipimport/batch/repository/ShipRepositoryImpl.java +++ b/src/main/java/com/snp/batch/jobs/shipimport/batch/repository/ShipRepositoryImpl.java @@ -26,7 +26,7 @@ public class ShipRepositoryImpl extends BaseJdbcRepository imp @Override protected String getTableName() { - return "ship_data"; + return "snp_data.ship_data"; } @Override @@ -47,7 +47,7 @@ public class ShipRepositoryImpl extends BaseJdbcRepository imp @Override protected String getInsertSql() { return """ - INSERT INTO ship_data (imo_number, core_ship_ind, dataset_version, import_date, created_at, updated_at, created_by, updated_by) + INSERT INTO snp_data.ship_data (imo_number, core_ship_ind, dataset_version, import_date, created_at, updated_at, created_by, updated_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """; } @@ -55,7 +55,7 @@ public class ShipRepositoryImpl extends BaseJdbcRepository imp @Override protected String getUpdateSql() { return """ - UPDATE ship_data + UPDATE snp_data.ship_data SET core_ship_ind = ?, dataset_version = ?, import_date = ?, @@ -100,7 +100,7 @@ public class ShipRepositoryImpl extends BaseJdbcRepository imp */ @Override public Optional findByImoNumber(String imoNumber) { - String sql = "SELECT * FROM ship_data WHERE imo_number = ?"; + String sql = "SELECT * FROM snp_data.ship_data WHERE imo_number = ?"; return executeQueryForObject(sql, imoNumber); } @@ -109,7 +109,7 @@ public class ShipRepositoryImpl extends BaseJdbcRepository imp */ @Override public boolean existsByImoNumber(String imoNumber) { - String sql = "SELECT COUNT(*) FROM ship_data WHERE imo_number = ?"; + String sql = "SELECT COUNT(*) FROM snp_data.ship_data WHERE imo_number = ?"; Long count = jdbcTemplate.queryForObject(sql, Long.class, imoNumber); return count != null && count > 0; } 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..b4c9eff --- /dev/null +++ b/src/main/java/com/snp/batch/service/BatchDateService.java @@ -0,0 +1,120 @@ +package com.snp.batch.service; + +import com.snp.batch.global.repository.BatchLastExecutionRepository; +import lombok.extern.slf4j.Slf4j; +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; +@Slf4j +@Service +public class BatchDateService { + private final BatchLastExecutionRepository repository; + + public BatchDateService(BatchLastExecutionRepository repository) { + this.repository = repository; + } + + public Map getDateRangeWithoutTimeParams(String apiKey) { + return repository.findDateRangeByApiKey(apiKey) + .map(projection -> { + Map params = new HashMap<>(); + + LocalDateTime fromTarget = (projection.getRangeFromDate() != null) + ? projection.getRangeFromDate() + : projection.getLastSuccessDate(); + + LocalDateTime toTarget = (projection.getRangeToDate() != null) + ? projection.getRangeToDate() + : LocalDateTime.now(); + + // 2. 파라미터 맵에 날짜 정보 매핑 + putDateParams(params, "from", fromTarget); + putDateParams(params, "to", toTarget); + + // 3. 고정 값 설정 + params.put("shipsCategory", "0"); + + return params; + }) + .orElseGet(() -> { + log.warn("해당 apiKey에 대한 데이터를 찾을 수 없습니다: {}", apiKey); + return new HashMap<>(); + }); + } + + /** + * 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())); + } + } + + public Map getDateRangeWithTimezoneParams(String apiKey) { + return repository.findDateRangeByApiKey(apiKey) + .map(projection -> { + Map params = new HashMap<>(); + // 'Z'를 문자열 리터럴이 아닌 실제 타임존 기호(X)로 처리 + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSX"); + + // 한국 시간을 UTC로 변환하는 헬퍼 메소드 (아래 정의) + params.put("fromDate", formatToUtc(projection.getRangeFromDate() != null ? + projection.getRangeFromDate() : projection.getLastSuccessDate(), formatter)); + + LocalDateTime toDateTime = projection.getRangeToDate() != null ? + projection.getRangeToDate() : LocalDateTime.now(); + + params.put("toDate", formatToUtc(toDateTime, formatter)); + + return params; + }) + .orElseGet(() -> { + log.warn("해당 apiKey에 대한 데이터를 찾을 수 없습니다: {}", apiKey); + return new HashMap<>(); + }); + } + + public Map getDateRangeWithTimezoneParams(String apiKey, String dateParam1, String dateParam2) { + return repository.findDateRangeByApiKey(apiKey) + .map(projection -> { + Map params = new HashMap<>(); + // 'Z'를 문자열 리터럴이 아닌 실제 타임존 기호(X)로 처리 + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSX"); + + // 한국 시간을 UTC로 변환하는 헬퍼 메소드 (아래 정의) + params.put(dateParam1, formatToUtc(projection.getRangeFromDate() != null ? + projection.getRangeFromDate() : projection.getLastSuccessDate(), formatter)); + + LocalDateTime toDateTime = projection.getRangeToDate() != null ? + projection.getRangeToDate() : LocalDateTime.now(); + + params.put(dateParam2, formatToUtc(toDateTime, formatter)); + + return params; + }) + .orElseGet(() -> { + log.warn("해당 apiKey에 대한 데이터를 찾을 수 없습니다: {}", apiKey); + return new HashMap<>(); + }); + } + + // 한국 시간(LocalDateTime)을 UTC 문자열로 변환하는 로직 + private String formatToUtc(LocalDateTime localDateTime, DateTimeFormatter formatter) { + if (localDateTime == null) return null; + // 1. 한국 시간대(KST)임을 명시 + // 2. UTC로 시간대를 변경 (9시간 빠짐) + // 3. 포맷팅 (끝에 Z가 자동으로 붙음) + return localDateTime.atZone(ZoneId.of("Asia/Seoul")) + .withZoneSameInstant(ZoneOffset.UTC) + .format(formatter); + } + +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..9227899 --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,135 @@ +spring: + application: + name: snp-batch + + # 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: true + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true + default_schema: snp_data + + # Batch Configuration + batch: + jdbc: + table-prefix: "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: always # 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: snp_data.QRTZ_ + org.quartz.jobStore.isClustered: false + org.quartz.jobStore.misfireThreshold: 60000 + +# Server Configuration +server: + port: 8081 +# 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: + batch: + chunk-size: 1000 + api: + url: https://api.example.com/data + timeout: 30000 + ship-api: + url: https://shipsapi.maritime.spglobal.com + username: 7cc0517d-5ed6-452e-a06f-5bbfd6ab6ade + password: 2LLzSJNqtxWVD8zC + ais-api: + url: https://aisapi.maritime.spglobal.com + webservice-api: + url: https://webservices.maritime.spglobal.com + schedule: + enabled: true + cron: "0 0 * * * ?" # Every hour + + # AIS Target 배치 설정 + ais-target: + since-seconds: 60 # API 조회 범위 (초) + chunk-size: 50000 # 배치 청크 크기 + schedule: + cron: "15 * * * * ?" # 매 분 15초 실행 + # AIS Target 캐시 설정 + ais-target-cache: + ttl-minutes: 120 # 캐시 TTL (분) - 2시간 + max-size: 300000 # 최대 캐시 크기 - 30만 건 + + # ClassType 분류 설정 + class-type: + refresh-hour: 4 # Core20 캐시 갱신 시간 (기본: 04시) + + # Core20 캐시 테이블 설정 (환경별로 테이블/컬럼명이 다를 수 있음) + core20: + schema: snp_data # 스키마명 + table: ship_detail_data # 테이블명 + imo-column: ihslrorimoshipno # IMO/LRNO 컬럼명 (PK, NOT NULL) + mmsi-column: maritimemobileserviceidentitymmsinumber # MMSI 컬럼명 (NULLABLE) + + # 파티션 관리 설정 + partition: + # 일별 파티션 테이블 목록 (네이밍: {table}_YYMMDD) + daily-tables: + - schema: snp_data + table-name: ais_target + partition-column: message_timestamp + periods-ahead: 3 # 미리 생성할 일수 + # 월별 파티션 테이블 목록 (네이밍: {table}_YYYY_MM) + monthly-tables: [] # 현재 없음 + # 기본 보관기간 + retention: + daily-default-days: 14 # 일별 파티션 기본 보관기간 (14일) + monthly-default-months: 1 # 월별 파티션 기본 보관기간 (1개월) + # 개별 테이블 보관기간 설정 (옵션) + custom: + # - table-name: ais_target + # retention-days: 30 # ais_target만 30일 보관 \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..fc2c167 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,137 @@ +spring: + application: + name: snp-batch + + # PostgreSQL Database Configuration + datasource: + url: jdbc:postgresql://10.187.58.58:5432/mdadb + username: mda + password: mda#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: true + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true + default_schema: snp_data + + # Batch Configuration + batch: + jdbc: + table-prefix: "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: snp_data.QRTZ_ + org.quartz.jobStore.isClustered: false + org.quartz.jobStore.misfireThreshold: 60000 + +# Server Configuration +server: + port: 9000 +# 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: + batch: + chunk-size: 1000 + api: + url: https://api.example.com/data + timeout: 30000 + ship-api: + url: http://10.29.16.219:18030/shipsapi + username: 7cc0517d-5ed6-452e-a06f-5bbfd6ab6ade + password: 2LLzSJNqtxWVD8zC + ais-api: + url: http://10.29.16.219:18030/aisapi + webservice-api: + url: http://10.29.16.219:18030/webservices + schedule: + enabled: true + cron: "0 0 * * * ?" # Every hour + + # AIS Target 배치 설정 + ais-target: + since-seconds: 60 # API 조회 범위 (초) + chunk-size: 50000 # 배치 청크 크기 + schedule: + cron: "15 * * * * ?" # 매 분 15초 실행 + # AIS Target 캐시 설정 + ais-target-cache: + ttl-minutes: 120 # 캐시 TTL (분) - 2시간 + max-size: 300000 # 최대 캐시 크기 - 30만 건 + + # ClassType 분류 설정 + class-type: + refresh-hour: 4 # Core20 캐시 갱신 시간 (기본: 04시) + + # Core20 캐시 테이블 설정 (환경별로 테이블/컬럼명이 다를 수 있음) + core20: + schema: snp_data # 스키마명 + table: ship_detail_data # 테이블명 + imo-column: ihslrorimoshipno # IMO/LRNO 컬럼명 (PK, NOT NULL) + mmsi-column: maritimemobileserviceidentitymmsinumber # MMSI 컬럼명 (NULLABLE) + + # 파티션 관리 설정 + partition: + # 일별 파티션 테이블 목록 (네이밍: {table}_YYMMDD) + daily-tables: + - schema: snp_data + table-name: ais_target + partition-column: message_timestamp + periods-ahead: 3 # 미리 생성할 일수 + # 월별 파티션 테이블 목록 (네이밍: {table}_YYYY_MM) + monthly-tables: [] # 현재 없음 + # 기본 보관기간 + retention: + daily-default-days: 14 # 일별 파티션 기본 보관기간 (14일) + monthly-default-months: 1 # 월별 파티션 기본 보관기간 (1개월) + # 개별 테이블 보관기간 설정 (옵션) + custom: + # - table-name: ais_target + # retention-days: 30 # ais_target만 30일 보관 \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 194ea8f..6df95eb 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -17,16 +17,17 @@ spring: jpa: hibernate: ddl-auto: update - show-sql: true + show-sql: false properties: hibernate: dialect: org.hibernate.dialect.PostgreSQLDialect format_sql: true - default_schema: public + default_schema: snp_data # Batch Configuration batch: jdbc: + table-prefix: "snp_data.batch_" initialize-schema: never # Changed to 'never' as tables already exist job: enabled: false # Prevent auto-run on startup @@ -48,7 +49,7 @@ spring: 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: QRTZ_ + org.quartz.jobStore.tablePrefix: snp_data.QRTZ_ org.quartz.jobStore.isClustered: false org.quartz.jobStore.misfireThreshold: 60000 @@ -56,7 +57,7 @@ spring: server: port: 8081 servlet: - context-path: / + context-path: /snp-api # Actuator Configuration management: @@ -68,18 +69,9 @@ management: health: show-details: always -# Logging Configuration +# Logging Configuration (logback-spring.xml에서 상세 설정) logging: - level: - root: INFO - com.snp.batch: DEBUG - org.springframework.batch: DEBUG - org.springframework.jdbc: DEBUG - pattern: - console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n" - file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" - file: - name: logs/snp-batch.log + config: classpath:logback-spring.xml # Custom Application Properties app: @@ -92,6 +84,58 @@ app: url: https://shipsapi.maritime.spglobal.com username: 7cc0517d-5ed6-452e-a06f-5bbfd6ab6ade password: 2LLzSJNqtxWVD8zC + ais-api: + url: https://aisapi.maritime.spglobal.com + webservice-api: + url: https://webservices.maritime.spglobal.com schedule: enabled: true cron: "0 0 * * * ?" # Every hour + + # AIS Target Import 배치 설정 (캐시 업데이트 전용) + ais-target: + since-seconds: 60 # API 조회 범위 (초) + chunk-size: 50000 # 배치 청크 크기 + schedule: + cron: "15 * * * * ?" # 매 분 15초 실행 + + # AIS Target DB Sync 배치 설정 (캐시 → DB 저장) + ais-target-db-sync: + time-range-minutes: 15 # 캐시에서 조회할 시간 범위 (분) + schedule: + cron: "0 0/15 * * * ?" # 매 15분 정각 실행 (00, 15, 30, 45분) + + # AIS Target 캐시 설정 + ais-target-cache: + ttl-minutes: 120 # 캐시 TTL (분) - 2시간 + max-size: 300000 # 최대 캐시 크기 - 30만 건 + + # ClassType 분류 설정 + class-type: + refresh-hour: 4 # Core20 캐시 갱신 시간 (기본: 04시) + + # Core20 캐시 테이블 설정 (환경별로 테이블/컬럼명이 다를 수 있음) + core20: + schema: snp_data # 스키마명 + table: ship_detail_data # 테이블명 + imo-column: ihslrorimoshipno # IMO/LRNO 컬럼명 (PK, NOT NULL) + mmsi-column: maritimemobileserviceidentitymmsinumber # MMSI 컬럼명 (NULLABLE) + + # 파티션 관리 설정 + partition: + # 일별 파티션 테이블 목록 (네이밍: {table}_YYMMDD) + daily-tables: + - schema: snp_data + table-name: ais_target + partition-column: message_timestamp + periods-ahead: 3 # 미리 생성할 일수 + # 월별 파티션 테이블 목록 (네이밍: {table}_YYYY_MM) + monthly-tables: [] # 현재 없음 + # 기본 보관기간 + retention: + daily-default-days: 14 # 일별 파티션 기본 보관기간 (14일) + monthly-default-months: 1 # 월별 파티션 기본 보관기간 (1개월) + # 개별 테이블 보관기간 설정 (옵션) + custom: + # - table-name: ais_target + # retention-days: 30 # ais_target만 30일 보관 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..6dc417d --- /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 = '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 = '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 = '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..64e4327 --- /dev/null +++ b/src/main/resources/sql/old_job_cleanup.sql @@ -0,0 +1,67 @@ + -- 오래된 STARTED 상태 Job을 정리하는 SQL 쿼리입니다. +-- 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/static/css/bootstrap-icons.css b/src/main/resources/static/css/bootstrap-icons.css new file mode 100644 index 0000000..ac403b0 --- /dev/null +++ b/src/main/resources/static/css/bootstrap-icons.css @@ -0,0 +1,2078 @@ +/*! + * Bootstrap Icons v1.10.5 (https://icons.getbootstrap.com/) + * Copyright 2019-2023 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/icons/blob/main/LICENSE) + */ + +@font-face { + font-display: block; + font-family: "bootstrap-icons"; + src: url("../fonts/bootstrap-icons.woff2") format("woff2"), +url("../fonts/bootstrap-icons.woff") format("woff"); +} + +.bi::before, +[class^="bi-"]::before, +[class*=" bi-"]::before { + display: inline-block; + font-family: bootstrap-icons !important; + font-style: normal; + font-weight: normal !important; + font-variant: normal; + text-transform: none; + line-height: 1; + vertical-align: -.125em; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.bi-123::before { content: "\f67f"; } +.bi-alarm-fill::before { content: "\f101"; } +.bi-alarm::before { content: "\f102"; } +.bi-align-bottom::before { content: "\f103"; } +.bi-align-center::before { content: "\f104"; } +.bi-align-end::before { content: "\f105"; } +.bi-align-middle::before { content: "\f106"; } +.bi-align-start::before { content: "\f107"; } +.bi-align-top::before { content: "\f108"; } +.bi-alt::before { content: "\f109"; } +.bi-app-indicator::before { content: "\f10a"; } +.bi-app::before { content: "\f10b"; } +.bi-archive-fill::before { content: "\f10c"; } +.bi-archive::before { content: "\f10d"; } +.bi-arrow-90deg-down::before { content: "\f10e"; } +.bi-arrow-90deg-left::before { content: "\f10f"; } +.bi-arrow-90deg-right::before { content: "\f110"; } +.bi-arrow-90deg-up::before { content: "\f111"; } +.bi-arrow-bar-down::before { content: "\f112"; } +.bi-arrow-bar-left::before { content: "\f113"; } +.bi-arrow-bar-right::before { content: "\f114"; } +.bi-arrow-bar-up::before { content: "\f115"; } +.bi-arrow-clockwise::before { content: "\f116"; } +.bi-arrow-counterclockwise::before { content: "\f117"; } +.bi-arrow-down-circle-fill::before { content: "\f118"; } +.bi-arrow-down-circle::before { content: "\f119"; } +.bi-arrow-down-left-circle-fill::before { content: "\f11a"; } +.bi-arrow-down-left-circle::before { content: "\f11b"; } +.bi-arrow-down-left-square-fill::before { content: "\f11c"; } +.bi-arrow-down-left-square::before { content: "\f11d"; } +.bi-arrow-down-left::before { content: "\f11e"; } +.bi-arrow-down-right-circle-fill::before { content: "\f11f"; } +.bi-arrow-down-right-circle::before { content: "\f120"; } +.bi-arrow-down-right-square-fill::before { content: "\f121"; } +.bi-arrow-down-right-square::before { content: "\f122"; } +.bi-arrow-down-right::before { content: "\f123"; } +.bi-arrow-down-short::before { content: "\f124"; } +.bi-arrow-down-square-fill::before { content: "\f125"; } +.bi-arrow-down-square::before { content: "\f126"; } +.bi-arrow-down-up::before { content: "\f127"; } +.bi-arrow-down::before { content: "\f128"; } +.bi-arrow-left-circle-fill::before { content: "\f129"; } +.bi-arrow-left-circle::before { content: "\f12a"; } +.bi-arrow-left-right::before { content: "\f12b"; } +.bi-arrow-left-short::before { content: "\f12c"; } +.bi-arrow-left-square-fill::before { content: "\f12d"; } +.bi-arrow-left-square::before { content: "\f12e"; } +.bi-arrow-left::before { content: "\f12f"; } +.bi-arrow-repeat::before { content: "\f130"; } +.bi-arrow-return-left::before { content: "\f131"; } +.bi-arrow-return-right::before { content: "\f132"; } +.bi-arrow-right-circle-fill::before { content: "\f133"; } +.bi-arrow-right-circle::before { content: "\f134"; } +.bi-arrow-right-short::before { content: "\f135"; } +.bi-arrow-right-square-fill::before { content: "\f136"; } +.bi-arrow-right-square::before { content: "\f137"; } +.bi-arrow-right::before { content: "\f138"; } +.bi-arrow-up-circle-fill::before { content: "\f139"; } +.bi-arrow-up-circle::before { content: "\f13a"; } +.bi-arrow-up-left-circle-fill::before { content: "\f13b"; } +.bi-arrow-up-left-circle::before { content: "\f13c"; } +.bi-arrow-up-left-square-fill::before { content: "\f13d"; } +.bi-arrow-up-left-square::before { content: "\f13e"; } +.bi-arrow-up-left::before { content: "\f13f"; } +.bi-arrow-up-right-circle-fill::before { content: "\f140"; } +.bi-arrow-up-right-circle::before { content: "\f141"; } +.bi-arrow-up-right-square-fill::before { content: "\f142"; } +.bi-arrow-up-right-square::before { content: "\f143"; } +.bi-arrow-up-right::before { content: "\f144"; } +.bi-arrow-up-short::before { content: "\f145"; } +.bi-arrow-up-square-fill::before { content: "\f146"; } +.bi-arrow-up-square::before { content: "\f147"; } +.bi-arrow-up::before { content: "\f148"; } +.bi-arrows-angle-contract::before { content: "\f149"; } +.bi-arrows-angle-expand::before { content: "\f14a"; } +.bi-arrows-collapse::before { content: "\f14b"; } +.bi-arrows-expand::before { content: "\f14c"; } +.bi-arrows-fullscreen::before { content: "\f14d"; } +.bi-arrows-move::before { content: "\f14e"; } +.bi-aspect-ratio-fill::before { content: "\f14f"; } +.bi-aspect-ratio::before { content: "\f150"; } +.bi-asterisk::before { content: "\f151"; } +.bi-at::before { content: "\f152"; } +.bi-award-fill::before { content: "\f153"; } +.bi-award::before { content: "\f154"; } +.bi-back::before { content: "\f155"; } +.bi-backspace-fill::before { content: "\f156"; } +.bi-backspace-reverse-fill::before { content: "\f157"; } +.bi-backspace-reverse::before { content: "\f158"; } +.bi-backspace::before { content: "\f159"; } +.bi-badge-3d-fill::before { content: "\f15a"; } +.bi-badge-3d::before { content: "\f15b"; } +.bi-badge-4k-fill::before { content: "\f15c"; } +.bi-badge-4k::before { content: "\f15d"; } +.bi-badge-8k-fill::before { content: "\f15e"; } +.bi-badge-8k::before { content: "\f15f"; } +.bi-badge-ad-fill::before { content: "\f160"; } +.bi-badge-ad::before { content: "\f161"; } +.bi-badge-ar-fill::before { content: "\f162"; } +.bi-badge-ar::before { content: "\f163"; } +.bi-badge-cc-fill::before { content: "\f164"; } +.bi-badge-cc::before { content: "\f165"; } +.bi-badge-hd-fill::before { content: "\f166"; } +.bi-badge-hd::before { content: "\f167"; } +.bi-badge-tm-fill::before { content: "\f168"; } +.bi-badge-tm::before { content: "\f169"; } +.bi-badge-vo-fill::before { content: "\f16a"; } +.bi-badge-vo::before { content: "\f16b"; } +.bi-badge-vr-fill::before { content: "\f16c"; } +.bi-badge-vr::before { content: "\f16d"; } +.bi-badge-wc-fill::before { content: "\f16e"; } +.bi-badge-wc::before { content: "\f16f"; } +.bi-bag-check-fill::before { content: "\f170"; } +.bi-bag-check::before { content: "\f171"; } +.bi-bag-dash-fill::before { content: "\f172"; } +.bi-bag-dash::before { content: "\f173"; } +.bi-bag-fill::before { content: "\f174"; } +.bi-bag-plus-fill::before { content: "\f175"; } +.bi-bag-plus::before { content: "\f176"; } +.bi-bag-x-fill::before { content: "\f177"; } +.bi-bag-x::before { content: "\f178"; } +.bi-bag::before { content: "\f179"; } +.bi-bar-chart-fill::before { content: "\f17a"; } +.bi-bar-chart-line-fill::before { content: "\f17b"; } +.bi-bar-chart-line::before { content: "\f17c"; } +.bi-bar-chart-steps::before { content: "\f17d"; } +.bi-bar-chart::before { content: "\f17e"; } +.bi-basket-fill::before { content: "\f17f"; } +.bi-basket::before { content: "\f180"; } +.bi-basket2-fill::before { content: "\f181"; } +.bi-basket2::before { content: "\f182"; } +.bi-basket3-fill::before { content: "\f183"; } +.bi-basket3::before { content: "\f184"; } +.bi-battery-charging::before { content: "\f185"; } +.bi-battery-full::before { content: "\f186"; } +.bi-battery-half::before { content: "\f187"; } +.bi-battery::before { content: "\f188"; } +.bi-bell-fill::before { content: "\f189"; } +.bi-bell::before { content: "\f18a"; } +.bi-bezier::before { content: "\f18b"; } +.bi-bezier2::before { content: "\f18c"; } +.bi-bicycle::before { content: "\f18d"; } +.bi-binoculars-fill::before { content: "\f18e"; } +.bi-binoculars::before { content: "\f18f"; } +.bi-blockquote-left::before { content: "\f190"; } +.bi-blockquote-right::before { content: "\f191"; } +.bi-book-fill::before { content: "\f192"; } +.bi-book-half::before { content: "\f193"; } +.bi-book::before { content: "\f194"; } +.bi-bookmark-check-fill::before { content: "\f195"; } +.bi-bookmark-check::before { content: "\f196"; } +.bi-bookmark-dash-fill::before { content: "\f197"; } +.bi-bookmark-dash::before { content: "\f198"; } +.bi-bookmark-fill::before { content: "\f199"; } +.bi-bookmark-heart-fill::before { content: "\f19a"; } +.bi-bookmark-heart::before { content: "\f19b"; } +.bi-bookmark-plus-fill::before { content: "\f19c"; } +.bi-bookmark-plus::before { content: "\f19d"; } +.bi-bookmark-star-fill::before { content: "\f19e"; } +.bi-bookmark-star::before { content: "\f19f"; } +.bi-bookmark-x-fill::before { content: "\f1a0"; } +.bi-bookmark-x::before { content: "\f1a1"; } +.bi-bookmark::before { content: "\f1a2"; } +.bi-bookmarks-fill::before { content: "\f1a3"; } +.bi-bookmarks::before { content: "\f1a4"; } +.bi-bookshelf::before { content: "\f1a5"; } +.bi-bootstrap-fill::before { content: "\f1a6"; } +.bi-bootstrap-reboot::before { content: "\f1a7"; } +.bi-bootstrap::before { content: "\f1a8"; } +.bi-border-all::before { content: "\f1a9"; } +.bi-border-bottom::before { content: "\f1aa"; } +.bi-border-center::before { content: "\f1ab"; } +.bi-border-inner::before { content: "\f1ac"; } +.bi-border-left::before { content: "\f1ad"; } +.bi-border-middle::before { content: "\f1ae"; } +.bi-border-outer::before { content: "\f1af"; } +.bi-border-right::before { content: "\f1b0"; } +.bi-border-style::before { content: "\f1b1"; } +.bi-border-top::before { content: "\f1b2"; } +.bi-border-width::before { content: "\f1b3"; } +.bi-border::before { content: "\f1b4"; } +.bi-bounding-box-circles::before { content: "\f1b5"; } +.bi-bounding-box::before { content: "\f1b6"; } +.bi-box-arrow-down-left::before { content: "\f1b7"; } +.bi-box-arrow-down-right::before { content: "\f1b8"; } +.bi-box-arrow-down::before { content: "\f1b9"; } +.bi-box-arrow-in-down-left::before { content: "\f1ba"; } +.bi-box-arrow-in-down-right::before { content: "\f1bb"; } +.bi-box-arrow-in-down::before { content: "\f1bc"; } +.bi-box-arrow-in-left::before { content: "\f1bd"; } +.bi-box-arrow-in-right::before { content: "\f1be"; } +.bi-box-arrow-in-up-left::before { content: "\f1bf"; } +.bi-box-arrow-in-up-right::before { content: "\f1c0"; } +.bi-box-arrow-in-up::before { content: "\f1c1"; } +.bi-box-arrow-left::before { content: "\f1c2"; } +.bi-box-arrow-right::before { content: "\f1c3"; } +.bi-box-arrow-up-left::before { content: "\f1c4"; } +.bi-box-arrow-up-right::before { content: "\f1c5"; } +.bi-box-arrow-up::before { content: "\f1c6"; } +.bi-box-seam::before { content: "\f1c7"; } +.bi-box::before { content: "\f1c8"; } +.bi-braces::before { content: "\f1c9"; } +.bi-bricks::before { content: "\f1ca"; } +.bi-briefcase-fill::before { content: "\f1cb"; } +.bi-briefcase::before { content: "\f1cc"; } +.bi-brightness-alt-high-fill::before { content: "\f1cd"; } +.bi-brightness-alt-high::before { content: "\f1ce"; } +.bi-brightness-alt-low-fill::before { content: "\f1cf"; } +.bi-brightness-alt-low::before { content: "\f1d0"; } +.bi-brightness-high-fill::before { content: "\f1d1"; } +.bi-brightness-high::before { content: "\f1d2"; } +.bi-brightness-low-fill::before { content: "\f1d3"; } +.bi-brightness-low::before { content: "\f1d4"; } +.bi-broadcast-pin::before { content: "\f1d5"; } +.bi-broadcast::before { content: "\f1d6"; } +.bi-brush-fill::before { content: "\f1d7"; } +.bi-brush::before { content: "\f1d8"; } +.bi-bucket-fill::before { content: "\f1d9"; } +.bi-bucket::before { content: "\f1da"; } +.bi-bug-fill::before { content: "\f1db"; } +.bi-bug::before { content: "\f1dc"; } +.bi-building::before { content: "\f1dd"; } +.bi-bullseye::before { content: "\f1de"; } +.bi-calculator-fill::before { content: "\f1df"; } +.bi-calculator::before { content: "\f1e0"; } +.bi-calendar-check-fill::before { content: "\f1e1"; } +.bi-calendar-check::before { content: "\f1e2"; } +.bi-calendar-date-fill::before { content: "\f1e3"; } +.bi-calendar-date::before { content: "\f1e4"; } +.bi-calendar-day-fill::before { content: "\f1e5"; } +.bi-calendar-day::before { content: "\f1e6"; } +.bi-calendar-event-fill::before { content: "\f1e7"; } +.bi-calendar-event::before { content: "\f1e8"; } +.bi-calendar-fill::before { content: "\f1e9"; } +.bi-calendar-minus-fill::before { content: "\f1ea"; } +.bi-calendar-minus::before { content: "\f1eb"; } +.bi-calendar-month-fill::before { content: "\f1ec"; } +.bi-calendar-month::before { content: "\f1ed"; } +.bi-calendar-plus-fill::before { content: "\f1ee"; } +.bi-calendar-plus::before { content: "\f1ef"; } +.bi-calendar-range-fill::before { content: "\f1f0"; } +.bi-calendar-range::before { content: "\f1f1"; } +.bi-calendar-week-fill::before { content: "\f1f2"; } +.bi-calendar-week::before { content: "\f1f3"; } +.bi-calendar-x-fill::before { content: "\f1f4"; } +.bi-calendar-x::before { content: "\f1f5"; } +.bi-calendar::before { content: "\f1f6"; } +.bi-calendar2-check-fill::before { content: "\f1f7"; } +.bi-calendar2-check::before { content: "\f1f8"; } +.bi-calendar2-date-fill::before { content: "\f1f9"; } +.bi-calendar2-date::before { content: "\f1fa"; } +.bi-calendar2-day-fill::before { content: "\f1fb"; } +.bi-calendar2-day::before { content: "\f1fc"; } +.bi-calendar2-event-fill::before { content: "\f1fd"; } +.bi-calendar2-event::before { content: "\f1fe"; } +.bi-calendar2-fill::before { content: "\f1ff"; } +.bi-calendar2-minus-fill::before { content: "\f200"; } +.bi-calendar2-minus::before { content: "\f201"; } +.bi-calendar2-month-fill::before { content: "\f202"; } +.bi-calendar2-month::before { content: "\f203"; } +.bi-calendar2-plus-fill::before { content: "\f204"; } +.bi-calendar2-plus::before { content: "\f205"; } +.bi-calendar2-range-fill::before { content: "\f206"; } +.bi-calendar2-range::before { content: "\f207"; } +.bi-calendar2-week-fill::before { content: "\f208"; } +.bi-calendar2-week::before { content: "\f209"; } +.bi-calendar2-x-fill::before { content: "\f20a"; } +.bi-calendar2-x::before { content: "\f20b"; } +.bi-calendar2::before { content: "\f20c"; } +.bi-calendar3-event-fill::before { content: "\f20d"; } +.bi-calendar3-event::before { content: "\f20e"; } +.bi-calendar3-fill::before { content: "\f20f"; } +.bi-calendar3-range-fill::before { content: "\f210"; } +.bi-calendar3-range::before { content: "\f211"; } +.bi-calendar3-week-fill::before { content: "\f212"; } +.bi-calendar3-week::before { content: "\f213"; } +.bi-calendar3::before { content: "\f214"; } +.bi-calendar4-event::before { content: "\f215"; } +.bi-calendar4-range::before { content: "\f216"; } +.bi-calendar4-week::before { content: "\f217"; } +.bi-calendar4::before { content: "\f218"; } +.bi-camera-fill::before { content: "\f219"; } +.bi-camera-reels-fill::before { content: "\f21a"; } +.bi-camera-reels::before { content: "\f21b"; } +.bi-camera-video-fill::before { content: "\f21c"; } +.bi-camera-video-off-fill::before { content: "\f21d"; } +.bi-camera-video-off::before { content: "\f21e"; } +.bi-camera-video::before { content: "\f21f"; } +.bi-camera::before { content: "\f220"; } +.bi-camera2::before { content: "\f221"; } +.bi-capslock-fill::before { content: "\f222"; } +.bi-capslock::before { content: "\f223"; } +.bi-card-checklist::before { content: "\f224"; } +.bi-card-heading::before { content: "\f225"; } +.bi-card-image::before { content: "\f226"; } +.bi-card-list::before { content: "\f227"; } +.bi-card-text::before { content: "\f228"; } +.bi-caret-down-fill::before { content: "\f229"; } +.bi-caret-down-square-fill::before { content: "\f22a"; } +.bi-caret-down-square::before { content: "\f22b"; } +.bi-caret-down::before { content: "\f22c"; } +.bi-caret-left-fill::before { content: "\f22d"; } +.bi-caret-left-square-fill::before { content: "\f22e"; } +.bi-caret-left-square::before { content: "\f22f"; } +.bi-caret-left::before { content: "\f230"; } +.bi-caret-right-fill::before { content: "\f231"; } +.bi-caret-right-square-fill::before { content: "\f232"; } +.bi-caret-right-square::before { content: "\f233"; } +.bi-caret-right::before { content: "\f234"; } +.bi-caret-up-fill::before { content: "\f235"; } +.bi-caret-up-square-fill::before { content: "\f236"; } +.bi-caret-up-square::before { content: "\f237"; } +.bi-caret-up::before { content: "\f238"; } +.bi-cart-check-fill::before { content: "\f239"; } +.bi-cart-check::before { content: "\f23a"; } +.bi-cart-dash-fill::before { content: "\f23b"; } +.bi-cart-dash::before { content: "\f23c"; } +.bi-cart-fill::before { content: "\f23d"; } +.bi-cart-plus-fill::before { content: "\f23e"; } +.bi-cart-plus::before { content: "\f23f"; } +.bi-cart-x-fill::before { content: "\f240"; } +.bi-cart-x::before { content: "\f241"; } +.bi-cart::before { content: "\f242"; } +.bi-cart2::before { content: "\f243"; } +.bi-cart3::before { content: "\f244"; } +.bi-cart4::before { content: "\f245"; } +.bi-cash-stack::before { content: "\f246"; } +.bi-cash::before { content: "\f247"; } +.bi-cast::before { content: "\f248"; } +.bi-chat-dots-fill::before { content: "\f249"; } +.bi-chat-dots::before { content: "\f24a"; } +.bi-chat-fill::before { content: "\f24b"; } +.bi-chat-left-dots-fill::before { content: "\f24c"; } +.bi-chat-left-dots::before { content: "\f24d"; } +.bi-chat-left-fill::before { content: "\f24e"; } +.bi-chat-left-quote-fill::before { content: "\f24f"; } +.bi-chat-left-quote::before { content: "\f250"; } +.bi-chat-left-text-fill::before { content: "\f251"; } +.bi-chat-left-text::before { content: "\f252"; } +.bi-chat-left::before { content: "\f253"; } +.bi-chat-quote-fill::before { content: "\f254"; } +.bi-chat-quote::before { content: "\f255"; } +.bi-chat-right-dots-fill::before { content: "\f256"; } +.bi-chat-right-dots::before { content: "\f257"; } +.bi-chat-right-fill::before { content: "\f258"; } +.bi-chat-right-quote-fill::before { content: "\f259"; } +.bi-chat-right-quote::before { content: "\f25a"; } +.bi-chat-right-text-fill::before { content: "\f25b"; } +.bi-chat-right-text::before { content: "\f25c"; } +.bi-chat-right::before { content: "\f25d"; } +.bi-chat-square-dots-fill::before { content: "\f25e"; } +.bi-chat-square-dots::before { content: "\f25f"; } +.bi-chat-square-fill::before { content: "\f260"; } +.bi-chat-square-quote-fill::before { content: "\f261"; } +.bi-chat-square-quote::before { content: "\f262"; } +.bi-chat-square-text-fill::before { content: "\f263"; } +.bi-chat-square-text::before { content: "\f264"; } +.bi-chat-square::before { content: "\f265"; } +.bi-chat-text-fill::before { content: "\f266"; } +.bi-chat-text::before { content: "\f267"; } +.bi-chat::before { content: "\f268"; } +.bi-check-all::before { content: "\f269"; } +.bi-check-circle-fill::before { content: "\f26a"; } +.bi-check-circle::before { content: "\f26b"; } +.bi-check-square-fill::before { content: "\f26c"; } +.bi-check-square::before { content: "\f26d"; } +.bi-check::before { content: "\f26e"; } +.bi-check2-all::before { content: "\f26f"; } +.bi-check2-circle::before { content: "\f270"; } +.bi-check2-square::before { content: "\f271"; } +.bi-check2::before { content: "\f272"; } +.bi-chevron-bar-contract::before { content: "\f273"; } +.bi-chevron-bar-down::before { content: "\f274"; } +.bi-chevron-bar-expand::before { content: "\f275"; } +.bi-chevron-bar-left::before { content: "\f276"; } +.bi-chevron-bar-right::before { content: "\f277"; } +.bi-chevron-bar-up::before { content: "\f278"; } +.bi-chevron-compact-down::before { content: "\f279"; } +.bi-chevron-compact-left::before { content: "\f27a"; } +.bi-chevron-compact-right::before { content: "\f27b"; } +.bi-chevron-compact-up::before { content: "\f27c"; } +.bi-chevron-contract::before { content: "\f27d"; } +.bi-chevron-double-down::before { content: "\f27e"; } +.bi-chevron-double-left::before { content: "\f27f"; } +.bi-chevron-double-right::before { content: "\f280"; } +.bi-chevron-double-up::before { content: "\f281"; } +.bi-chevron-down::before { content: "\f282"; } +.bi-chevron-expand::before { content: "\f283"; } +.bi-chevron-left::before { content: "\f284"; } +.bi-chevron-right::before { content: "\f285"; } +.bi-chevron-up::before { content: "\f286"; } +.bi-circle-fill::before { content: "\f287"; } +.bi-circle-half::before { content: "\f288"; } +.bi-circle-square::before { content: "\f289"; } +.bi-circle::before { content: "\f28a"; } +.bi-clipboard-check::before { content: "\f28b"; } +.bi-clipboard-data::before { content: "\f28c"; } +.bi-clipboard-minus::before { content: "\f28d"; } +.bi-clipboard-plus::before { content: "\f28e"; } +.bi-clipboard-x::before { content: "\f28f"; } +.bi-clipboard::before { content: "\f290"; } +.bi-clock-fill::before { content: "\f291"; } +.bi-clock-history::before { content: "\f292"; } +.bi-clock::before { content: "\f293"; } +.bi-cloud-arrow-down-fill::before { content: "\f294"; } +.bi-cloud-arrow-down::before { content: "\f295"; } +.bi-cloud-arrow-up-fill::before { content: "\f296"; } +.bi-cloud-arrow-up::before { content: "\f297"; } +.bi-cloud-check-fill::before { content: "\f298"; } +.bi-cloud-check::before { content: "\f299"; } +.bi-cloud-download-fill::before { content: "\f29a"; } +.bi-cloud-download::before { content: "\f29b"; } +.bi-cloud-drizzle-fill::before { content: "\f29c"; } +.bi-cloud-drizzle::before { content: "\f29d"; } +.bi-cloud-fill::before { content: "\f29e"; } +.bi-cloud-fog-fill::before { content: "\f29f"; } +.bi-cloud-fog::before { content: "\f2a0"; } +.bi-cloud-fog2-fill::before { content: "\f2a1"; } +.bi-cloud-fog2::before { content: "\f2a2"; } +.bi-cloud-hail-fill::before { content: "\f2a3"; } +.bi-cloud-hail::before { content: "\f2a4"; } +.bi-cloud-haze-fill::before { content: "\f2a6"; } +.bi-cloud-haze::before { content: "\f2a7"; } +.bi-cloud-haze2-fill::before { content: "\f2a8"; } +.bi-cloud-lightning-fill::before { content: "\f2a9"; } +.bi-cloud-lightning-rain-fill::before { content: "\f2aa"; } +.bi-cloud-lightning-rain::before { content: "\f2ab"; } +.bi-cloud-lightning::before { content: "\f2ac"; } +.bi-cloud-minus-fill::before { content: "\f2ad"; } +.bi-cloud-minus::before { content: "\f2ae"; } +.bi-cloud-moon-fill::before { content: "\f2af"; } +.bi-cloud-moon::before { content: "\f2b0"; } +.bi-cloud-plus-fill::before { content: "\f2b1"; } +.bi-cloud-plus::before { content: "\f2b2"; } +.bi-cloud-rain-fill::before { content: "\f2b3"; } +.bi-cloud-rain-heavy-fill::before { content: "\f2b4"; } +.bi-cloud-rain-heavy::before { content: "\f2b5"; } +.bi-cloud-rain::before { content: "\f2b6"; } +.bi-cloud-slash-fill::before { content: "\f2b7"; } +.bi-cloud-slash::before { content: "\f2b8"; } +.bi-cloud-sleet-fill::before { content: "\f2b9"; } +.bi-cloud-sleet::before { content: "\f2ba"; } +.bi-cloud-snow-fill::before { content: "\f2bb"; } +.bi-cloud-snow::before { content: "\f2bc"; } +.bi-cloud-sun-fill::before { content: "\f2bd"; } +.bi-cloud-sun::before { content: "\f2be"; } +.bi-cloud-upload-fill::before { content: "\f2bf"; } +.bi-cloud-upload::before { content: "\f2c0"; } +.bi-cloud::before { content: "\f2c1"; } +.bi-clouds-fill::before { content: "\f2c2"; } +.bi-clouds::before { content: "\f2c3"; } +.bi-cloudy-fill::before { content: "\f2c4"; } +.bi-cloudy::before { content: "\f2c5"; } +.bi-code-slash::before { content: "\f2c6"; } +.bi-code-square::before { content: "\f2c7"; } +.bi-code::before { content: "\f2c8"; } +.bi-collection-fill::before { content: "\f2c9"; } +.bi-collection-play-fill::before { content: "\f2ca"; } +.bi-collection-play::before { content: "\f2cb"; } +.bi-collection::before { content: "\f2cc"; } +.bi-columns-gap::before { content: "\f2cd"; } +.bi-columns::before { content: "\f2ce"; } +.bi-command::before { content: "\f2cf"; } +.bi-compass-fill::before { content: "\f2d0"; } +.bi-compass::before { content: "\f2d1"; } +.bi-cone-striped::before { content: "\f2d2"; } +.bi-cone::before { content: "\f2d3"; } +.bi-controller::before { content: "\f2d4"; } +.bi-cpu-fill::before { content: "\f2d5"; } +.bi-cpu::before { content: "\f2d6"; } +.bi-credit-card-2-back-fill::before { content: "\f2d7"; } +.bi-credit-card-2-back::before { content: "\f2d8"; } +.bi-credit-card-2-front-fill::before { content: "\f2d9"; } +.bi-credit-card-2-front::before { content: "\f2da"; } +.bi-credit-card-fill::before { content: "\f2db"; } +.bi-credit-card::before { content: "\f2dc"; } +.bi-crop::before { content: "\f2dd"; } +.bi-cup-fill::before { content: "\f2de"; } +.bi-cup-straw::before { content: "\f2df"; } +.bi-cup::before { content: "\f2e0"; } +.bi-cursor-fill::before { content: "\f2e1"; } +.bi-cursor-text::before { content: "\f2e2"; } +.bi-cursor::before { content: "\f2e3"; } +.bi-dash-circle-dotted::before { content: "\f2e4"; } +.bi-dash-circle-fill::before { content: "\f2e5"; } +.bi-dash-circle::before { content: "\f2e6"; } +.bi-dash-square-dotted::before { content: "\f2e7"; } +.bi-dash-square-fill::before { content: "\f2e8"; } +.bi-dash-square::before { content: "\f2e9"; } +.bi-dash::before { content: "\f2ea"; } +.bi-diagram-2-fill::before { content: "\f2eb"; } +.bi-diagram-2::before { content: "\f2ec"; } +.bi-diagram-3-fill::before { content: "\f2ed"; } +.bi-diagram-3::before { content: "\f2ee"; } +.bi-diamond-fill::before { content: "\f2ef"; } +.bi-diamond-half::before { content: "\f2f0"; } +.bi-diamond::before { content: "\f2f1"; } +.bi-dice-1-fill::before { content: "\f2f2"; } +.bi-dice-1::before { content: "\f2f3"; } +.bi-dice-2-fill::before { content: "\f2f4"; } +.bi-dice-2::before { content: "\f2f5"; } +.bi-dice-3-fill::before { content: "\f2f6"; } +.bi-dice-3::before { content: "\f2f7"; } +.bi-dice-4-fill::before { content: "\f2f8"; } +.bi-dice-4::before { content: "\f2f9"; } +.bi-dice-5-fill::before { content: "\f2fa"; } +.bi-dice-5::before { content: "\f2fb"; } +.bi-dice-6-fill::before { content: "\f2fc"; } +.bi-dice-6::before { content: "\f2fd"; } +.bi-disc-fill::before { content: "\f2fe"; } +.bi-disc::before { content: "\f2ff"; } +.bi-discord::before { content: "\f300"; } +.bi-display-fill::before { content: "\f301"; } +.bi-display::before { content: "\f302"; } +.bi-distribute-horizontal::before { content: "\f303"; } +.bi-distribute-vertical::before { content: "\f304"; } +.bi-door-closed-fill::before { content: "\f305"; } +.bi-door-closed::before { content: "\f306"; } +.bi-door-open-fill::before { content: "\f307"; } +.bi-door-open::before { content: "\f308"; } +.bi-dot::before { content: "\f309"; } +.bi-download::before { content: "\f30a"; } +.bi-droplet-fill::before { content: "\f30b"; } +.bi-droplet-half::before { content: "\f30c"; } +.bi-droplet::before { content: "\f30d"; } +.bi-earbuds::before { content: "\f30e"; } +.bi-easel-fill::before { content: "\f30f"; } +.bi-easel::before { content: "\f310"; } +.bi-egg-fill::before { content: "\f311"; } +.bi-egg-fried::before { content: "\f312"; } +.bi-egg::before { content: "\f313"; } +.bi-eject-fill::before { content: "\f314"; } +.bi-eject::before { content: "\f315"; } +.bi-emoji-angry-fill::before { content: "\f316"; } +.bi-emoji-angry::before { content: "\f317"; } +.bi-emoji-dizzy-fill::before { content: "\f318"; } +.bi-emoji-dizzy::before { content: "\f319"; } +.bi-emoji-expressionless-fill::before { content: "\f31a"; } +.bi-emoji-expressionless::before { content: "\f31b"; } +.bi-emoji-frown-fill::before { content: "\f31c"; } +.bi-emoji-frown::before { content: "\f31d"; } +.bi-emoji-heart-eyes-fill::before { content: "\f31e"; } +.bi-emoji-heart-eyes::before { content: "\f31f"; } +.bi-emoji-laughing-fill::before { content: "\f320"; } +.bi-emoji-laughing::before { content: "\f321"; } +.bi-emoji-neutral-fill::before { content: "\f322"; } +.bi-emoji-neutral::before { content: "\f323"; } +.bi-emoji-smile-fill::before { content: "\f324"; } +.bi-emoji-smile-upside-down-fill::before { content: "\f325"; } +.bi-emoji-smile-upside-down::before { content: "\f326"; } +.bi-emoji-smile::before { content: "\f327"; } +.bi-emoji-sunglasses-fill::before { content: "\f328"; } +.bi-emoji-sunglasses::before { content: "\f329"; } +.bi-emoji-wink-fill::before { content: "\f32a"; } +.bi-emoji-wink::before { content: "\f32b"; } +.bi-envelope-fill::before { content: "\f32c"; } +.bi-envelope-open-fill::before { content: "\f32d"; } +.bi-envelope-open::before { content: "\f32e"; } +.bi-envelope::before { content: "\f32f"; } +.bi-eraser-fill::before { content: "\f330"; } +.bi-eraser::before { content: "\f331"; } +.bi-exclamation-circle-fill::before { content: "\f332"; } +.bi-exclamation-circle::before { content: "\f333"; } +.bi-exclamation-diamond-fill::before { content: "\f334"; } +.bi-exclamation-diamond::before { content: "\f335"; } +.bi-exclamation-octagon-fill::before { content: "\f336"; } +.bi-exclamation-octagon::before { content: "\f337"; } +.bi-exclamation-square-fill::before { content: "\f338"; } +.bi-exclamation-square::before { content: "\f339"; } +.bi-exclamation-triangle-fill::before { content: "\f33a"; } +.bi-exclamation-triangle::before { content: "\f33b"; } +.bi-exclamation::before { content: "\f33c"; } +.bi-exclude::before { content: "\f33d"; } +.bi-eye-fill::before { content: "\f33e"; } +.bi-eye-slash-fill::before { content: "\f33f"; } +.bi-eye-slash::before { content: "\f340"; } +.bi-eye::before { content: "\f341"; } +.bi-eyedropper::before { content: "\f342"; } +.bi-eyeglasses::before { content: "\f343"; } +.bi-facebook::before { content: "\f344"; } +.bi-file-arrow-down-fill::before { content: "\f345"; } +.bi-file-arrow-down::before { content: "\f346"; } +.bi-file-arrow-up-fill::before { content: "\f347"; } +.bi-file-arrow-up::before { content: "\f348"; } +.bi-file-bar-graph-fill::before { content: "\f349"; } +.bi-file-bar-graph::before { content: "\f34a"; } +.bi-file-binary-fill::before { content: "\f34b"; } +.bi-file-binary::before { content: "\f34c"; } +.bi-file-break-fill::before { content: "\f34d"; } +.bi-file-break::before { content: "\f34e"; } +.bi-file-check-fill::before { content: "\f34f"; } +.bi-file-check::before { content: "\f350"; } +.bi-file-code-fill::before { content: "\f351"; } +.bi-file-code::before { content: "\f352"; } +.bi-file-diff-fill::before { content: "\f353"; } +.bi-file-diff::before { content: "\f354"; } +.bi-file-earmark-arrow-down-fill::before { content: "\f355"; } +.bi-file-earmark-arrow-down::before { content: "\f356"; } +.bi-file-earmark-arrow-up-fill::before { content: "\f357"; } +.bi-file-earmark-arrow-up::before { content: "\f358"; } +.bi-file-earmark-bar-graph-fill::before { content: "\f359"; } +.bi-file-earmark-bar-graph::before { content: "\f35a"; } +.bi-file-earmark-binary-fill::before { content: "\f35b"; } +.bi-file-earmark-binary::before { content: "\f35c"; } +.bi-file-earmark-break-fill::before { content: "\f35d"; } +.bi-file-earmark-break::before { content: "\f35e"; } +.bi-file-earmark-check-fill::before { content: "\f35f"; } +.bi-file-earmark-check::before { content: "\f360"; } +.bi-file-earmark-code-fill::before { content: "\f361"; } +.bi-file-earmark-code::before { content: "\f362"; } +.bi-file-earmark-diff-fill::before { content: "\f363"; } +.bi-file-earmark-diff::before { content: "\f364"; } +.bi-file-earmark-easel-fill::before { content: "\f365"; } +.bi-file-earmark-easel::before { content: "\f366"; } +.bi-file-earmark-excel-fill::before { content: "\f367"; } +.bi-file-earmark-excel::before { content: "\f368"; } +.bi-file-earmark-fill::before { content: "\f369"; } +.bi-file-earmark-font-fill::before { content: "\f36a"; } +.bi-file-earmark-font::before { content: "\f36b"; } +.bi-file-earmark-image-fill::before { content: "\f36c"; } +.bi-file-earmark-image::before { content: "\f36d"; } +.bi-file-earmark-lock-fill::before { content: "\f36e"; } +.bi-file-earmark-lock::before { content: "\f36f"; } +.bi-file-earmark-lock2-fill::before { content: "\f370"; } +.bi-file-earmark-lock2::before { content: "\f371"; } +.bi-file-earmark-medical-fill::before { content: "\f372"; } +.bi-file-earmark-medical::before { content: "\f373"; } +.bi-file-earmark-minus-fill::before { content: "\f374"; } +.bi-file-earmark-minus::before { content: "\f375"; } +.bi-file-earmark-music-fill::before { content: "\f376"; } +.bi-file-earmark-music::before { content: "\f377"; } +.bi-file-earmark-person-fill::before { content: "\f378"; } +.bi-file-earmark-person::before { content: "\f379"; } +.bi-file-earmark-play-fill::before { content: "\f37a"; } +.bi-file-earmark-play::before { content: "\f37b"; } +.bi-file-earmark-plus-fill::before { content: "\f37c"; } +.bi-file-earmark-plus::before { content: "\f37d"; } +.bi-file-earmark-post-fill::before { content: "\f37e"; } +.bi-file-earmark-post::before { content: "\f37f"; } +.bi-file-earmark-ppt-fill::before { content: "\f380"; } +.bi-file-earmark-ppt::before { content: "\f381"; } +.bi-file-earmark-richtext-fill::before { content: "\f382"; } +.bi-file-earmark-richtext::before { content: "\f383"; } +.bi-file-earmark-ruled-fill::before { content: "\f384"; } +.bi-file-earmark-ruled::before { content: "\f385"; } +.bi-file-earmark-slides-fill::before { content: "\f386"; } +.bi-file-earmark-slides::before { content: "\f387"; } +.bi-file-earmark-spreadsheet-fill::before { content: "\f388"; } +.bi-file-earmark-spreadsheet::before { content: "\f389"; } +.bi-file-earmark-text-fill::before { content: "\f38a"; } +.bi-file-earmark-text::before { content: "\f38b"; } +.bi-file-earmark-word-fill::before { content: "\f38c"; } +.bi-file-earmark-word::before { content: "\f38d"; } +.bi-file-earmark-x-fill::before { content: "\f38e"; } +.bi-file-earmark-x::before { content: "\f38f"; } +.bi-file-earmark-zip-fill::before { content: "\f390"; } +.bi-file-earmark-zip::before { content: "\f391"; } +.bi-file-earmark::before { content: "\f392"; } +.bi-file-easel-fill::before { content: "\f393"; } +.bi-file-easel::before { content: "\f394"; } +.bi-file-excel-fill::before { content: "\f395"; } +.bi-file-excel::before { content: "\f396"; } +.bi-file-fill::before { content: "\f397"; } +.bi-file-font-fill::before { content: "\f398"; } +.bi-file-font::before { content: "\f399"; } +.bi-file-image-fill::before { content: "\f39a"; } +.bi-file-image::before { content: "\f39b"; } +.bi-file-lock-fill::before { content: "\f39c"; } +.bi-file-lock::before { content: "\f39d"; } +.bi-file-lock2-fill::before { content: "\f39e"; } +.bi-file-lock2::before { content: "\f39f"; } +.bi-file-medical-fill::before { content: "\f3a0"; } +.bi-file-medical::before { content: "\f3a1"; } +.bi-file-minus-fill::before { content: "\f3a2"; } +.bi-file-minus::before { content: "\f3a3"; } +.bi-file-music-fill::before { content: "\f3a4"; } +.bi-file-music::before { content: "\f3a5"; } +.bi-file-person-fill::before { content: "\f3a6"; } +.bi-file-person::before { content: "\f3a7"; } +.bi-file-play-fill::before { content: "\f3a8"; } +.bi-file-play::before { content: "\f3a9"; } +.bi-file-plus-fill::before { content: "\f3aa"; } +.bi-file-plus::before { content: "\f3ab"; } +.bi-file-post-fill::before { content: "\f3ac"; } +.bi-file-post::before { content: "\f3ad"; } +.bi-file-ppt-fill::before { content: "\f3ae"; } +.bi-file-ppt::before { content: "\f3af"; } +.bi-file-richtext-fill::before { content: "\f3b0"; } +.bi-file-richtext::before { content: "\f3b1"; } +.bi-file-ruled-fill::before { content: "\f3b2"; } +.bi-file-ruled::before { content: "\f3b3"; } +.bi-file-slides-fill::before { content: "\f3b4"; } +.bi-file-slides::before { content: "\f3b5"; } +.bi-file-spreadsheet-fill::before { content: "\f3b6"; } +.bi-file-spreadsheet::before { content: "\f3b7"; } +.bi-file-text-fill::before { content: "\f3b8"; } +.bi-file-text::before { content: "\f3b9"; } +.bi-file-word-fill::before { content: "\f3ba"; } +.bi-file-word::before { content: "\f3bb"; } +.bi-file-x-fill::before { content: "\f3bc"; } +.bi-file-x::before { content: "\f3bd"; } +.bi-file-zip-fill::before { content: "\f3be"; } +.bi-file-zip::before { content: "\f3bf"; } +.bi-file::before { content: "\f3c0"; } +.bi-files-alt::before { content: "\f3c1"; } +.bi-files::before { content: "\f3c2"; } +.bi-film::before { content: "\f3c3"; } +.bi-filter-circle-fill::before { content: "\f3c4"; } +.bi-filter-circle::before { content: "\f3c5"; } +.bi-filter-left::before { content: "\f3c6"; } +.bi-filter-right::before { content: "\f3c7"; } +.bi-filter-square-fill::before { content: "\f3c8"; } +.bi-filter-square::before { content: "\f3c9"; } +.bi-filter::before { content: "\f3ca"; } +.bi-flag-fill::before { content: "\f3cb"; } +.bi-flag::before { content: "\f3cc"; } +.bi-flower1::before { content: "\f3cd"; } +.bi-flower2::before { content: "\f3ce"; } +.bi-flower3::before { content: "\f3cf"; } +.bi-folder-check::before { content: "\f3d0"; } +.bi-folder-fill::before { content: "\f3d1"; } +.bi-folder-minus::before { content: "\f3d2"; } +.bi-folder-plus::before { content: "\f3d3"; } +.bi-folder-symlink-fill::before { content: "\f3d4"; } +.bi-folder-symlink::before { content: "\f3d5"; } +.bi-folder-x::before { content: "\f3d6"; } +.bi-folder::before { content: "\f3d7"; } +.bi-folder2-open::before { content: "\f3d8"; } +.bi-folder2::before { content: "\f3d9"; } +.bi-fonts::before { content: "\f3da"; } +.bi-forward-fill::before { content: "\f3db"; } +.bi-forward::before { content: "\f3dc"; } +.bi-front::before { content: "\f3dd"; } +.bi-fullscreen-exit::before { content: "\f3de"; } +.bi-fullscreen::before { content: "\f3df"; } +.bi-funnel-fill::before { content: "\f3e0"; } +.bi-funnel::before { content: "\f3e1"; } +.bi-gear-fill::before { content: "\f3e2"; } +.bi-gear-wide-connected::before { content: "\f3e3"; } +.bi-gear-wide::before { content: "\f3e4"; } +.bi-gear::before { content: "\f3e5"; } +.bi-gem::before { content: "\f3e6"; } +.bi-geo-alt-fill::before { content: "\f3e7"; } +.bi-geo-alt::before { content: "\f3e8"; } +.bi-geo-fill::before { content: "\f3e9"; } +.bi-geo::before { content: "\f3ea"; } +.bi-gift-fill::before { content: "\f3eb"; } +.bi-gift::before { content: "\f3ec"; } +.bi-github::before { content: "\f3ed"; } +.bi-globe::before { content: "\f3ee"; } +.bi-globe2::before { content: "\f3ef"; } +.bi-google::before { content: "\f3f0"; } +.bi-graph-down::before { content: "\f3f1"; } +.bi-graph-up::before { content: "\f3f2"; } +.bi-grid-1x2-fill::before { content: "\f3f3"; } +.bi-grid-1x2::before { content: "\f3f4"; } +.bi-grid-3x2-gap-fill::before { content: "\f3f5"; } +.bi-grid-3x2-gap::before { content: "\f3f6"; } +.bi-grid-3x2::before { content: "\f3f7"; } +.bi-grid-3x3-gap-fill::before { content: "\f3f8"; } +.bi-grid-3x3-gap::before { content: "\f3f9"; } +.bi-grid-3x3::before { content: "\f3fa"; } +.bi-grid-fill::before { content: "\f3fb"; } +.bi-grid::before { content: "\f3fc"; } +.bi-grip-horizontal::before { content: "\f3fd"; } +.bi-grip-vertical::before { content: "\f3fe"; } +.bi-hammer::before { content: "\f3ff"; } +.bi-hand-index-fill::before { content: "\f400"; } +.bi-hand-index-thumb-fill::before { content: "\f401"; } +.bi-hand-index-thumb::before { content: "\f402"; } +.bi-hand-index::before { content: "\f403"; } +.bi-hand-thumbs-down-fill::before { content: "\f404"; } +.bi-hand-thumbs-down::before { content: "\f405"; } +.bi-hand-thumbs-up-fill::before { content: "\f406"; } +.bi-hand-thumbs-up::before { content: "\f407"; } +.bi-handbag-fill::before { content: "\f408"; } +.bi-handbag::before { content: "\f409"; } +.bi-hash::before { content: "\f40a"; } +.bi-hdd-fill::before { content: "\f40b"; } +.bi-hdd-network-fill::before { content: "\f40c"; } +.bi-hdd-network::before { content: "\f40d"; } +.bi-hdd-rack-fill::before { content: "\f40e"; } +.bi-hdd-rack::before { content: "\f40f"; } +.bi-hdd-stack-fill::before { content: "\f410"; } +.bi-hdd-stack::before { content: "\f411"; } +.bi-hdd::before { content: "\f412"; } +.bi-headphones::before { content: "\f413"; } +.bi-headset::before { content: "\f414"; } +.bi-heart-fill::before { content: "\f415"; } +.bi-heart-half::before { content: "\f416"; } +.bi-heart::before { content: "\f417"; } +.bi-heptagon-fill::before { content: "\f418"; } +.bi-heptagon-half::before { content: "\f419"; } +.bi-heptagon::before { content: "\f41a"; } +.bi-hexagon-fill::before { content: "\f41b"; } +.bi-hexagon-half::before { content: "\f41c"; } +.bi-hexagon::before { content: "\f41d"; } +.bi-hourglass-bottom::before { content: "\f41e"; } +.bi-hourglass-split::before { content: "\f41f"; } +.bi-hourglass-top::before { content: "\f420"; } +.bi-hourglass::before { content: "\f421"; } +.bi-house-door-fill::before { content: "\f422"; } +.bi-house-door::before { content: "\f423"; } +.bi-house-fill::before { content: "\f424"; } +.bi-house::before { content: "\f425"; } +.bi-hr::before { content: "\f426"; } +.bi-hurricane::before { content: "\f427"; } +.bi-image-alt::before { content: "\f428"; } +.bi-image-fill::before { content: "\f429"; } +.bi-image::before { content: "\f42a"; } +.bi-images::before { content: "\f42b"; } +.bi-inbox-fill::before { content: "\f42c"; } +.bi-inbox::before { content: "\f42d"; } +.bi-inboxes-fill::before { content: "\f42e"; } +.bi-inboxes::before { content: "\f42f"; } +.bi-info-circle-fill::before { content: "\f430"; } +.bi-info-circle::before { content: "\f431"; } +.bi-info-square-fill::before { content: "\f432"; } +.bi-info-square::before { content: "\f433"; } +.bi-info::before { content: "\f434"; } +.bi-input-cursor-text::before { content: "\f435"; } +.bi-input-cursor::before { content: "\f436"; } +.bi-instagram::before { content: "\f437"; } +.bi-intersect::before { content: "\f438"; } +.bi-journal-album::before { content: "\f439"; } +.bi-journal-arrow-down::before { content: "\f43a"; } +.bi-journal-arrow-up::before { content: "\f43b"; } +.bi-journal-bookmark-fill::before { content: "\f43c"; } +.bi-journal-bookmark::before { content: "\f43d"; } +.bi-journal-check::before { content: "\f43e"; } +.bi-journal-code::before { content: "\f43f"; } +.bi-journal-medical::before { content: "\f440"; } +.bi-journal-minus::before { content: "\f441"; } +.bi-journal-plus::before { content: "\f442"; } +.bi-journal-richtext::before { content: "\f443"; } +.bi-journal-text::before { content: "\f444"; } +.bi-journal-x::before { content: "\f445"; } +.bi-journal::before { content: "\f446"; } +.bi-journals::before { content: "\f447"; } +.bi-joystick::before { content: "\f448"; } +.bi-justify-left::before { content: "\f449"; } +.bi-justify-right::before { content: "\f44a"; } +.bi-justify::before { content: "\f44b"; } +.bi-kanban-fill::before { content: "\f44c"; } +.bi-kanban::before { content: "\f44d"; } +.bi-key-fill::before { content: "\f44e"; } +.bi-key::before { content: "\f44f"; } +.bi-keyboard-fill::before { content: "\f450"; } +.bi-keyboard::before { content: "\f451"; } +.bi-ladder::before { content: "\f452"; } +.bi-lamp-fill::before { content: "\f453"; } +.bi-lamp::before { content: "\f454"; } +.bi-laptop-fill::before { content: "\f455"; } +.bi-laptop::before { content: "\f456"; } +.bi-layer-backward::before { content: "\f457"; } +.bi-layer-forward::before { content: "\f458"; } +.bi-layers-fill::before { content: "\f459"; } +.bi-layers-half::before { content: "\f45a"; } +.bi-layers::before { content: "\f45b"; } +.bi-layout-sidebar-inset-reverse::before { content: "\f45c"; } +.bi-layout-sidebar-inset::before { content: "\f45d"; } +.bi-layout-sidebar-reverse::before { content: "\f45e"; } +.bi-layout-sidebar::before { content: "\f45f"; } +.bi-layout-split::before { content: "\f460"; } +.bi-layout-text-sidebar-reverse::before { content: "\f461"; } +.bi-layout-text-sidebar::before { content: "\f462"; } +.bi-layout-text-window-reverse::before { content: "\f463"; } +.bi-layout-text-window::before { content: "\f464"; } +.bi-layout-three-columns::before { content: "\f465"; } +.bi-layout-wtf::before { content: "\f466"; } +.bi-life-preserver::before { content: "\f467"; } +.bi-lightbulb-fill::before { content: "\f468"; } +.bi-lightbulb-off-fill::before { content: "\f469"; } +.bi-lightbulb-off::before { content: "\f46a"; } +.bi-lightbulb::before { content: "\f46b"; } +.bi-lightning-charge-fill::before { content: "\f46c"; } +.bi-lightning-charge::before { content: "\f46d"; } +.bi-lightning-fill::before { content: "\f46e"; } +.bi-lightning::before { content: "\f46f"; } +.bi-link-45deg::before { content: "\f470"; } +.bi-link::before { content: "\f471"; } +.bi-linkedin::before { content: "\f472"; } +.bi-list-check::before { content: "\f473"; } +.bi-list-nested::before { content: "\f474"; } +.bi-list-ol::before { content: "\f475"; } +.bi-list-stars::before { content: "\f476"; } +.bi-list-task::before { content: "\f477"; } +.bi-list-ul::before { content: "\f478"; } +.bi-list::before { content: "\f479"; } +.bi-lock-fill::before { content: "\f47a"; } +.bi-lock::before { content: "\f47b"; } +.bi-mailbox::before { content: "\f47c"; } +.bi-mailbox2::before { content: "\f47d"; } +.bi-map-fill::before { content: "\f47e"; } +.bi-map::before { content: "\f47f"; } +.bi-markdown-fill::before { content: "\f480"; } +.bi-markdown::before { content: "\f481"; } +.bi-mask::before { content: "\f482"; } +.bi-megaphone-fill::before { content: "\f483"; } +.bi-megaphone::before { content: "\f484"; } +.bi-menu-app-fill::before { content: "\f485"; } +.bi-menu-app::before { content: "\f486"; } +.bi-menu-button-fill::before { content: "\f487"; } +.bi-menu-button-wide-fill::before { content: "\f488"; } +.bi-menu-button-wide::before { content: "\f489"; } +.bi-menu-button::before { content: "\f48a"; } +.bi-menu-down::before { content: "\f48b"; } +.bi-menu-up::before { content: "\f48c"; } +.bi-mic-fill::before { content: "\f48d"; } +.bi-mic-mute-fill::before { content: "\f48e"; } +.bi-mic-mute::before { content: "\f48f"; } +.bi-mic::before { content: "\f490"; } +.bi-minecart-loaded::before { content: "\f491"; } +.bi-minecart::before { content: "\f492"; } +.bi-moisture::before { content: "\f493"; } +.bi-moon-fill::before { content: "\f494"; } +.bi-moon-stars-fill::before { content: "\f495"; } +.bi-moon-stars::before { content: "\f496"; } +.bi-moon::before { content: "\f497"; } +.bi-mouse-fill::before { content: "\f498"; } +.bi-mouse::before { content: "\f499"; } +.bi-mouse2-fill::before { content: "\f49a"; } +.bi-mouse2::before { content: "\f49b"; } +.bi-mouse3-fill::before { content: "\f49c"; } +.bi-mouse3::before { content: "\f49d"; } +.bi-music-note-beamed::before { content: "\f49e"; } +.bi-music-note-list::before { content: "\f49f"; } +.bi-music-note::before { content: "\f4a0"; } +.bi-music-player-fill::before { content: "\f4a1"; } +.bi-music-player::before { content: "\f4a2"; } +.bi-newspaper::before { content: "\f4a3"; } +.bi-node-minus-fill::before { content: "\f4a4"; } +.bi-node-minus::before { content: "\f4a5"; } +.bi-node-plus-fill::before { content: "\f4a6"; } +.bi-node-plus::before { content: "\f4a7"; } +.bi-nut-fill::before { content: "\f4a8"; } +.bi-nut::before { content: "\f4a9"; } +.bi-octagon-fill::before { content: "\f4aa"; } +.bi-octagon-half::before { content: "\f4ab"; } +.bi-octagon::before { content: "\f4ac"; } +.bi-option::before { content: "\f4ad"; } +.bi-outlet::before { content: "\f4ae"; } +.bi-paint-bucket::before { content: "\f4af"; } +.bi-palette-fill::before { content: "\f4b0"; } +.bi-palette::before { content: "\f4b1"; } +.bi-palette2::before { content: "\f4b2"; } +.bi-paperclip::before { content: "\f4b3"; } +.bi-paragraph::before { content: "\f4b4"; } +.bi-patch-check-fill::before { content: "\f4b5"; } +.bi-patch-check::before { content: "\f4b6"; } +.bi-patch-exclamation-fill::before { content: "\f4b7"; } +.bi-patch-exclamation::before { content: "\f4b8"; } +.bi-patch-minus-fill::before { content: "\f4b9"; } +.bi-patch-minus::before { content: "\f4ba"; } +.bi-patch-plus-fill::before { content: "\f4bb"; } +.bi-patch-plus::before { content: "\f4bc"; } +.bi-patch-question-fill::before { content: "\f4bd"; } +.bi-patch-question::before { content: "\f4be"; } +.bi-pause-btn-fill::before { content: "\f4bf"; } +.bi-pause-btn::before { content: "\f4c0"; } +.bi-pause-circle-fill::before { content: "\f4c1"; } +.bi-pause-circle::before { content: "\f4c2"; } +.bi-pause-fill::before { content: "\f4c3"; } +.bi-pause::before { content: "\f4c4"; } +.bi-peace-fill::before { content: "\f4c5"; } +.bi-peace::before { content: "\f4c6"; } +.bi-pen-fill::before { content: "\f4c7"; } +.bi-pen::before { content: "\f4c8"; } +.bi-pencil-fill::before { content: "\f4c9"; } +.bi-pencil-square::before { content: "\f4ca"; } +.bi-pencil::before { content: "\f4cb"; } +.bi-pentagon-fill::before { content: "\f4cc"; } +.bi-pentagon-half::before { content: "\f4cd"; } +.bi-pentagon::before { content: "\f4ce"; } +.bi-people-fill::before { content: "\f4cf"; } +.bi-people::before { content: "\f4d0"; } +.bi-percent::before { content: "\f4d1"; } +.bi-person-badge-fill::before { content: "\f4d2"; } +.bi-person-badge::before { content: "\f4d3"; } +.bi-person-bounding-box::before { content: "\f4d4"; } +.bi-person-check-fill::before { content: "\f4d5"; } +.bi-person-check::before { content: "\f4d6"; } +.bi-person-circle::before { content: "\f4d7"; } +.bi-person-dash-fill::before { content: "\f4d8"; } +.bi-person-dash::before { content: "\f4d9"; } +.bi-person-fill::before { content: "\f4da"; } +.bi-person-lines-fill::before { content: "\f4db"; } +.bi-person-plus-fill::before { content: "\f4dc"; } +.bi-person-plus::before { content: "\f4dd"; } +.bi-person-square::before { content: "\f4de"; } +.bi-person-x-fill::before { content: "\f4df"; } +.bi-person-x::before { content: "\f4e0"; } +.bi-person::before { content: "\f4e1"; } +.bi-phone-fill::before { content: "\f4e2"; } +.bi-phone-landscape-fill::before { content: "\f4e3"; } +.bi-phone-landscape::before { content: "\f4e4"; } +.bi-phone-vibrate-fill::before { content: "\f4e5"; } +.bi-phone-vibrate::before { content: "\f4e6"; } +.bi-phone::before { content: "\f4e7"; } +.bi-pie-chart-fill::before { content: "\f4e8"; } +.bi-pie-chart::before { content: "\f4e9"; } +.bi-pin-angle-fill::before { content: "\f4ea"; } +.bi-pin-angle::before { content: "\f4eb"; } +.bi-pin-fill::before { content: "\f4ec"; } +.bi-pin::before { content: "\f4ed"; } +.bi-pip-fill::before { content: "\f4ee"; } +.bi-pip::before { content: "\f4ef"; } +.bi-play-btn-fill::before { content: "\f4f0"; } +.bi-play-btn::before { content: "\f4f1"; } +.bi-play-circle-fill::before { content: "\f4f2"; } +.bi-play-circle::before { content: "\f4f3"; } +.bi-play-fill::before { content: "\f4f4"; } +.bi-play::before { content: "\f4f5"; } +.bi-plug-fill::before { content: "\f4f6"; } +.bi-plug::before { content: "\f4f7"; } +.bi-plus-circle-dotted::before { content: "\f4f8"; } +.bi-plus-circle-fill::before { content: "\f4f9"; } +.bi-plus-circle::before { content: "\f4fa"; } +.bi-plus-square-dotted::before { content: "\f4fb"; } +.bi-plus-square-fill::before { content: "\f4fc"; } +.bi-plus-square::before { content: "\f4fd"; } +.bi-plus::before { content: "\f4fe"; } +.bi-power::before { content: "\f4ff"; } +.bi-printer-fill::before { content: "\f500"; } +.bi-printer::before { content: "\f501"; } +.bi-puzzle-fill::before { content: "\f502"; } +.bi-puzzle::before { content: "\f503"; } +.bi-question-circle-fill::before { content: "\f504"; } +.bi-question-circle::before { content: "\f505"; } +.bi-question-diamond-fill::before { content: "\f506"; } +.bi-question-diamond::before { content: "\f507"; } +.bi-question-octagon-fill::before { content: "\f508"; } +.bi-question-octagon::before { content: "\f509"; } +.bi-question-square-fill::before { content: "\f50a"; } +.bi-question-square::before { content: "\f50b"; } +.bi-question::before { content: "\f50c"; } +.bi-rainbow::before { content: "\f50d"; } +.bi-receipt-cutoff::before { content: "\f50e"; } +.bi-receipt::before { content: "\f50f"; } +.bi-reception-0::before { content: "\f510"; } +.bi-reception-1::before { content: "\f511"; } +.bi-reception-2::before { content: "\f512"; } +.bi-reception-3::before { content: "\f513"; } +.bi-reception-4::before { content: "\f514"; } +.bi-record-btn-fill::before { content: "\f515"; } +.bi-record-btn::before { content: "\f516"; } +.bi-record-circle-fill::before { content: "\f517"; } +.bi-record-circle::before { content: "\f518"; } +.bi-record-fill::before { content: "\f519"; } +.bi-record::before { content: "\f51a"; } +.bi-record2-fill::before { content: "\f51b"; } +.bi-record2::before { content: "\f51c"; } +.bi-reply-all-fill::before { content: "\f51d"; } +.bi-reply-all::before { content: "\f51e"; } +.bi-reply-fill::before { content: "\f51f"; } +.bi-reply::before { content: "\f520"; } +.bi-rss-fill::before { content: "\f521"; } +.bi-rss::before { content: "\f522"; } +.bi-rulers::before { content: "\f523"; } +.bi-save-fill::before { content: "\f524"; } +.bi-save::before { content: "\f525"; } +.bi-save2-fill::before { content: "\f526"; } +.bi-save2::before { content: "\f527"; } +.bi-scissors::before { content: "\f528"; } +.bi-screwdriver::before { content: "\f529"; } +.bi-search::before { content: "\f52a"; } +.bi-segmented-nav::before { content: "\f52b"; } +.bi-server::before { content: "\f52c"; } +.bi-share-fill::before { content: "\f52d"; } +.bi-share::before { content: "\f52e"; } +.bi-shield-check::before { content: "\f52f"; } +.bi-shield-exclamation::before { content: "\f530"; } +.bi-shield-fill-check::before { content: "\f531"; } +.bi-shield-fill-exclamation::before { content: "\f532"; } +.bi-shield-fill-minus::before { content: "\f533"; } +.bi-shield-fill-plus::before { content: "\f534"; } +.bi-shield-fill-x::before { content: "\f535"; } +.bi-shield-fill::before { content: "\f536"; } +.bi-shield-lock-fill::before { content: "\f537"; } +.bi-shield-lock::before { content: "\f538"; } +.bi-shield-minus::before { content: "\f539"; } +.bi-shield-plus::before { content: "\f53a"; } +.bi-shield-shaded::before { content: "\f53b"; } +.bi-shield-slash-fill::before { content: "\f53c"; } +.bi-shield-slash::before { content: "\f53d"; } +.bi-shield-x::before { content: "\f53e"; } +.bi-shield::before { content: "\f53f"; } +.bi-shift-fill::before { content: "\f540"; } +.bi-shift::before { content: "\f541"; } +.bi-shop-window::before { content: "\f542"; } +.bi-shop::before { content: "\f543"; } +.bi-shuffle::before { content: "\f544"; } +.bi-signpost-2-fill::before { content: "\f545"; } +.bi-signpost-2::before { content: "\f546"; } +.bi-signpost-fill::before { content: "\f547"; } +.bi-signpost-split-fill::before { content: "\f548"; } +.bi-signpost-split::before { content: "\f549"; } +.bi-signpost::before { content: "\f54a"; } +.bi-sim-fill::before { content: "\f54b"; } +.bi-sim::before { content: "\f54c"; } +.bi-skip-backward-btn-fill::before { content: "\f54d"; } +.bi-skip-backward-btn::before { content: "\f54e"; } +.bi-skip-backward-circle-fill::before { content: "\f54f"; } +.bi-skip-backward-circle::before { content: "\f550"; } +.bi-skip-backward-fill::before { content: "\f551"; } +.bi-skip-backward::before { content: "\f552"; } +.bi-skip-end-btn-fill::before { content: "\f553"; } +.bi-skip-end-btn::before { content: "\f554"; } +.bi-skip-end-circle-fill::before { content: "\f555"; } +.bi-skip-end-circle::before { content: "\f556"; } +.bi-skip-end-fill::before { content: "\f557"; } +.bi-skip-end::before { content: "\f558"; } +.bi-skip-forward-btn-fill::before { content: "\f559"; } +.bi-skip-forward-btn::before { content: "\f55a"; } +.bi-skip-forward-circle-fill::before { content: "\f55b"; } +.bi-skip-forward-circle::before { content: "\f55c"; } +.bi-skip-forward-fill::before { content: "\f55d"; } +.bi-skip-forward::before { content: "\f55e"; } +.bi-skip-start-btn-fill::before { content: "\f55f"; } +.bi-skip-start-btn::before { content: "\f560"; } +.bi-skip-start-circle-fill::before { content: "\f561"; } +.bi-skip-start-circle::before { content: "\f562"; } +.bi-skip-start-fill::before { content: "\f563"; } +.bi-skip-start::before { content: "\f564"; } +.bi-slack::before { content: "\f565"; } +.bi-slash-circle-fill::before { content: "\f566"; } +.bi-slash-circle::before { content: "\f567"; } +.bi-slash-square-fill::before { content: "\f568"; } +.bi-slash-square::before { content: "\f569"; } +.bi-slash::before { content: "\f56a"; } +.bi-sliders::before { content: "\f56b"; } +.bi-smartwatch::before { content: "\f56c"; } +.bi-snow::before { content: "\f56d"; } +.bi-snow2::before { content: "\f56e"; } +.bi-snow3::before { content: "\f56f"; } +.bi-sort-alpha-down-alt::before { content: "\f570"; } +.bi-sort-alpha-down::before { content: "\f571"; } +.bi-sort-alpha-up-alt::before { content: "\f572"; } +.bi-sort-alpha-up::before { content: "\f573"; } +.bi-sort-down-alt::before { content: "\f574"; } +.bi-sort-down::before { content: "\f575"; } +.bi-sort-numeric-down-alt::before { content: "\f576"; } +.bi-sort-numeric-down::before { content: "\f577"; } +.bi-sort-numeric-up-alt::before { content: "\f578"; } +.bi-sort-numeric-up::before { content: "\f579"; } +.bi-sort-up-alt::before { content: "\f57a"; } +.bi-sort-up::before { content: "\f57b"; } +.bi-soundwave::before { content: "\f57c"; } +.bi-speaker-fill::before { content: "\f57d"; } +.bi-speaker::before { content: "\f57e"; } +.bi-speedometer::before { content: "\f57f"; } +.bi-speedometer2::before { content: "\f580"; } +.bi-spellcheck::before { content: "\f581"; } +.bi-square-fill::before { content: "\f582"; } +.bi-square-half::before { content: "\f583"; } +.bi-square::before { content: "\f584"; } +.bi-stack::before { content: "\f585"; } +.bi-star-fill::before { content: "\f586"; } +.bi-star-half::before { content: "\f587"; } +.bi-star::before { content: "\f588"; } +.bi-stars::before { content: "\f589"; } +.bi-stickies-fill::before { content: "\f58a"; } +.bi-stickies::before { content: "\f58b"; } +.bi-sticky-fill::before { content: "\f58c"; } +.bi-sticky::before { content: "\f58d"; } +.bi-stop-btn-fill::before { content: "\f58e"; } +.bi-stop-btn::before { content: "\f58f"; } +.bi-stop-circle-fill::before { content: "\f590"; } +.bi-stop-circle::before { content: "\f591"; } +.bi-stop-fill::before { content: "\f592"; } +.bi-stop::before { content: "\f593"; } +.bi-stoplights-fill::before { content: "\f594"; } +.bi-stoplights::before { content: "\f595"; } +.bi-stopwatch-fill::before { content: "\f596"; } +.bi-stopwatch::before { content: "\f597"; } +.bi-subtract::before { content: "\f598"; } +.bi-suit-club-fill::before { content: "\f599"; } +.bi-suit-club::before { content: "\f59a"; } +.bi-suit-diamond-fill::before { content: "\f59b"; } +.bi-suit-diamond::before { content: "\f59c"; } +.bi-suit-heart-fill::before { content: "\f59d"; } +.bi-suit-heart::before { content: "\f59e"; } +.bi-suit-spade-fill::before { content: "\f59f"; } +.bi-suit-spade::before { content: "\f5a0"; } +.bi-sun-fill::before { content: "\f5a1"; } +.bi-sun::before { content: "\f5a2"; } +.bi-sunglasses::before { content: "\f5a3"; } +.bi-sunrise-fill::before { content: "\f5a4"; } +.bi-sunrise::before { content: "\f5a5"; } +.bi-sunset-fill::before { content: "\f5a6"; } +.bi-sunset::before { content: "\f5a7"; } +.bi-symmetry-horizontal::before { content: "\f5a8"; } +.bi-symmetry-vertical::before { content: "\f5a9"; } +.bi-table::before { content: "\f5aa"; } +.bi-tablet-fill::before { content: "\f5ab"; } +.bi-tablet-landscape-fill::before { content: "\f5ac"; } +.bi-tablet-landscape::before { content: "\f5ad"; } +.bi-tablet::before { content: "\f5ae"; } +.bi-tag-fill::before { content: "\f5af"; } +.bi-tag::before { content: "\f5b0"; } +.bi-tags-fill::before { content: "\f5b1"; } +.bi-tags::before { content: "\f5b2"; } +.bi-telegram::before { content: "\f5b3"; } +.bi-telephone-fill::before { content: "\f5b4"; } +.bi-telephone-forward-fill::before { content: "\f5b5"; } +.bi-telephone-forward::before { content: "\f5b6"; } +.bi-telephone-inbound-fill::before { content: "\f5b7"; } +.bi-telephone-inbound::before { content: "\f5b8"; } +.bi-telephone-minus-fill::before { content: "\f5b9"; } +.bi-telephone-minus::before { content: "\f5ba"; } +.bi-telephone-outbound-fill::before { content: "\f5bb"; } +.bi-telephone-outbound::before { content: "\f5bc"; } +.bi-telephone-plus-fill::before { content: "\f5bd"; } +.bi-telephone-plus::before { content: "\f5be"; } +.bi-telephone-x-fill::before { content: "\f5bf"; } +.bi-telephone-x::before { content: "\f5c0"; } +.bi-telephone::before { content: "\f5c1"; } +.bi-terminal-fill::before { content: "\f5c2"; } +.bi-terminal::before { content: "\f5c3"; } +.bi-text-center::before { content: "\f5c4"; } +.bi-text-indent-left::before { content: "\f5c5"; } +.bi-text-indent-right::before { content: "\f5c6"; } +.bi-text-left::before { content: "\f5c7"; } +.bi-text-paragraph::before { content: "\f5c8"; } +.bi-text-right::before { content: "\f5c9"; } +.bi-textarea-resize::before { content: "\f5ca"; } +.bi-textarea-t::before { content: "\f5cb"; } +.bi-textarea::before { content: "\f5cc"; } +.bi-thermometer-half::before { content: "\f5cd"; } +.bi-thermometer-high::before { content: "\f5ce"; } +.bi-thermometer-low::before { content: "\f5cf"; } +.bi-thermometer-snow::before { content: "\f5d0"; } +.bi-thermometer-sun::before { content: "\f5d1"; } +.bi-thermometer::before { content: "\f5d2"; } +.bi-three-dots-vertical::before { content: "\f5d3"; } +.bi-three-dots::before { content: "\f5d4"; } +.bi-toggle-off::before { content: "\f5d5"; } +.bi-toggle-on::before { content: "\f5d6"; } +.bi-toggle2-off::before { content: "\f5d7"; } +.bi-toggle2-on::before { content: "\f5d8"; } +.bi-toggles::before { content: "\f5d9"; } +.bi-toggles2::before { content: "\f5da"; } +.bi-tools::before { content: "\f5db"; } +.bi-tornado::before { content: "\f5dc"; } +.bi-trash-fill::before { content: "\f5dd"; } +.bi-trash::before { content: "\f5de"; } +.bi-trash2-fill::before { content: "\f5df"; } +.bi-trash2::before { content: "\f5e0"; } +.bi-tree-fill::before { content: "\f5e1"; } +.bi-tree::before { content: "\f5e2"; } +.bi-triangle-fill::before { content: "\f5e3"; } +.bi-triangle-half::before { content: "\f5e4"; } +.bi-triangle::before { content: "\f5e5"; } +.bi-trophy-fill::before { content: "\f5e6"; } +.bi-trophy::before { content: "\f5e7"; } +.bi-tropical-storm::before { content: "\f5e8"; } +.bi-truck-flatbed::before { content: "\f5e9"; } +.bi-truck::before { content: "\f5ea"; } +.bi-tsunami::before { content: "\f5eb"; } +.bi-tv-fill::before { content: "\f5ec"; } +.bi-tv::before { content: "\f5ed"; } +.bi-twitch::before { content: "\f5ee"; } +.bi-twitter::before { content: "\f5ef"; } +.bi-type-bold::before { content: "\f5f0"; } +.bi-type-h1::before { content: "\f5f1"; } +.bi-type-h2::before { content: "\f5f2"; } +.bi-type-h3::before { content: "\f5f3"; } +.bi-type-italic::before { content: "\f5f4"; } +.bi-type-strikethrough::before { content: "\f5f5"; } +.bi-type-underline::before { content: "\f5f6"; } +.bi-type::before { content: "\f5f7"; } +.bi-ui-checks-grid::before { content: "\f5f8"; } +.bi-ui-checks::before { content: "\f5f9"; } +.bi-ui-radios-grid::before { content: "\f5fa"; } +.bi-ui-radios::before { content: "\f5fb"; } +.bi-umbrella-fill::before { content: "\f5fc"; } +.bi-umbrella::before { content: "\f5fd"; } +.bi-union::before { content: "\f5fe"; } +.bi-unlock-fill::before { content: "\f5ff"; } +.bi-unlock::before { content: "\f600"; } +.bi-upc-scan::before { content: "\f601"; } +.bi-upc::before { content: "\f602"; } +.bi-upload::before { content: "\f603"; } +.bi-vector-pen::before { content: "\f604"; } +.bi-view-list::before { content: "\f605"; } +.bi-view-stacked::before { content: "\f606"; } +.bi-vinyl-fill::before { content: "\f607"; } +.bi-vinyl::before { content: "\f608"; } +.bi-voicemail::before { content: "\f609"; } +.bi-volume-down-fill::before { content: "\f60a"; } +.bi-volume-down::before { content: "\f60b"; } +.bi-volume-mute-fill::before { content: "\f60c"; } +.bi-volume-mute::before { content: "\f60d"; } +.bi-volume-off-fill::before { content: "\f60e"; } +.bi-volume-off::before { content: "\f60f"; } +.bi-volume-up-fill::before { content: "\f610"; } +.bi-volume-up::before { content: "\f611"; } +.bi-vr::before { content: "\f612"; } +.bi-wallet-fill::before { content: "\f613"; } +.bi-wallet::before { content: "\f614"; } +.bi-wallet2::before { content: "\f615"; } +.bi-watch::before { content: "\f616"; } +.bi-water::before { content: "\f617"; } +.bi-whatsapp::before { content: "\f618"; } +.bi-wifi-1::before { content: "\f619"; } +.bi-wifi-2::before { content: "\f61a"; } +.bi-wifi-off::before { content: "\f61b"; } +.bi-wifi::before { content: "\f61c"; } +.bi-wind::before { content: "\f61d"; } +.bi-window-dock::before { content: "\f61e"; } +.bi-window-sidebar::before { content: "\f61f"; } +.bi-window::before { content: "\f620"; } +.bi-wrench::before { content: "\f621"; } +.bi-x-circle-fill::before { content: "\f622"; } +.bi-x-circle::before { content: "\f623"; } +.bi-x-diamond-fill::before { content: "\f624"; } +.bi-x-diamond::before { content: "\f625"; } +.bi-x-octagon-fill::before { content: "\f626"; } +.bi-x-octagon::before { content: "\f627"; } +.bi-x-square-fill::before { content: "\f628"; } +.bi-x-square::before { content: "\f629"; } +.bi-x::before { content: "\f62a"; } +.bi-youtube::before { content: "\f62b"; } +.bi-zoom-in::before { content: "\f62c"; } +.bi-zoom-out::before { content: "\f62d"; } +.bi-bank::before { content: "\f62e"; } +.bi-bank2::before { content: "\f62f"; } +.bi-bell-slash-fill::before { content: "\f630"; } +.bi-bell-slash::before { content: "\f631"; } +.bi-cash-coin::before { content: "\f632"; } +.bi-check-lg::before { content: "\f633"; } +.bi-coin::before { content: "\f634"; } +.bi-currency-bitcoin::before { content: "\f635"; } +.bi-currency-dollar::before { content: "\f636"; } +.bi-currency-euro::before { content: "\f637"; } +.bi-currency-exchange::before { content: "\f638"; } +.bi-currency-pound::before { content: "\f639"; } +.bi-currency-yen::before { content: "\f63a"; } +.bi-dash-lg::before { content: "\f63b"; } +.bi-exclamation-lg::before { content: "\f63c"; } +.bi-file-earmark-pdf-fill::before { content: "\f63d"; } +.bi-file-earmark-pdf::before { content: "\f63e"; } +.bi-file-pdf-fill::before { content: "\f63f"; } +.bi-file-pdf::before { content: "\f640"; } +.bi-gender-ambiguous::before { content: "\f641"; } +.bi-gender-female::before { content: "\f642"; } +.bi-gender-male::before { content: "\f643"; } +.bi-gender-trans::before { content: "\f644"; } +.bi-headset-vr::before { content: "\f645"; } +.bi-info-lg::before { content: "\f646"; } +.bi-mastodon::before { content: "\f647"; } +.bi-messenger::before { content: "\f648"; } +.bi-piggy-bank-fill::before { content: "\f649"; } +.bi-piggy-bank::before { content: "\f64a"; } +.bi-pin-map-fill::before { content: "\f64b"; } +.bi-pin-map::before { content: "\f64c"; } +.bi-plus-lg::before { content: "\f64d"; } +.bi-question-lg::before { content: "\f64e"; } +.bi-recycle::before { content: "\f64f"; } +.bi-reddit::before { content: "\f650"; } +.bi-safe-fill::before { content: "\f651"; } +.bi-safe2-fill::before { content: "\f652"; } +.bi-safe2::before { content: "\f653"; } +.bi-sd-card-fill::before { content: "\f654"; } +.bi-sd-card::before { content: "\f655"; } +.bi-skype::before { content: "\f656"; } +.bi-slash-lg::before { content: "\f657"; } +.bi-translate::before { content: "\f658"; } +.bi-x-lg::before { content: "\f659"; } +.bi-safe::before { content: "\f65a"; } +.bi-apple::before { content: "\f65b"; } +.bi-microsoft::before { content: "\f65d"; } +.bi-windows::before { content: "\f65e"; } +.bi-behance::before { content: "\f65c"; } +.bi-dribbble::before { content: "\f65f"; } +.bi-line::before { content: "\f660"; } +.bi-medium::before { content: "\f661"; } +.bi-paypal::before { content: "\f662"; } +.bi-pinterest::before { content: "\f663"; } +.bi-signal::before { content: "\f664"; } +.bi-snapchat::before { content: "\f665"; } +.bi-spotify::before { content: "\f666"; } +.bi-stack-overflow::before { content: "\f667"; } +.bi-strava::before { content: "\f668"; } +.bi-wordpress::before { content: "\f669"; } +.bi-vimeo::before { content: "\f66a"; } +.bi-activity::before { content: "\f66b"; } +.bi-easel2-fill::before { content: "\f66c"; } +.bi-easel2::before { content: "\f66d"; } +.bi-easel3-fill::before { content: "\f66e"; } +.bi-easel3::before { content: "\f66f"; } +.bi-fan::before { content: "\f670"; } +.bi-fingerprint::before { content: "\f671"; } +.bi-graph-down-arrow::before { content: "\f672"; } +.bi-graph-up-arrow::before { content: "\f673"; } +.bi-hypnotize::before { content: "\f674"; } +.bi-magic::before { content: "\f675"; } +.bi-person-rolodex::before { content: "\f676"; } +.bi-person-video::before { content: "\f677"; } +.bi-person-video2::before { content: "\f678"; } +.bi-person-video3::before { content: "\f679"; } +.bi-person-workspace::before { content: "\f67a"; } +.bi-radioactive::before { content: "\f67b"; } +.bi-webcam-fill::before { content: "\f67c"; } +.bi-webcam::before { content: "\f67d"; } +.bi-yin-yang::before { content: "\f67e"; } +.bi-bandaid-fill::before { content: "\f680"; } +.bi-bandaid::before { content: "\f681"; } +.bi-bluetooth::before { content: "\f682"; } +.bi-body-text::before { content: "\f683"; } +.bi-boombox::before { content: "\f684"; } +.bi-boxes::before { content: "\f685"; } +.bi-dpad-fill::before { content: "\f686"; } +.bi-dpad::before { content: "\f687"; } +.bi-ear-fill::before { content: "\f688"; } +.bi-ear::before { content: "\f689"; } +.bi-envelope-check-fill::before { content: "\f68b"; } +.bi-envelope-check::before { content: "\f68c"; } +.bi-envelope-dash-fill::before { content: "\f68e"; } +.bi-envelope-dash::before { content: "\f68f"; } +.bi-envelope-exclamation-fill::before { content: "\f691"; } +.bi-envelope-exclamation::before { content: "\f692"; } +.bi-envelope-plus-fill::before { content: "\f693"; } +.bi-envelope-plus::before { content: "\f694"; } +.bi-envelope-slash-fill::before { content: "\f696"; } +.bi-envelope-slash::before { content: "\f697"; } +.bi-envelope-x-fill::before { content: "\f699"; } +.bi-envelope-x::before { content: "\f69a"; } +.bi-explicit-fill::before { content: "\f69b"; } +.bi-explicit::before { content: "\f69c"; } +.bi-git::before { content: "\f69d"; } +.bi-infinity::before { content: "\f69e"; } +.bi-list-columns-reverse::before { content: "\f69f"; } +.bi-list-columns::before { content: "\f6a0"; } +.bi-meta::before { content: "\f6a1"; } +.bi-nintendo-switch::before { content: "\f6a4"; } +.bi-pc-display-horizontal::before { content: "\f6a5"; } +.bi-pc-display::before { content: "\f6a6"; } +.bi-pc-horizontal::before { content: "\f6a7"; } +.bi-pc::before { content: "\f6a8"; } +.bi-playstation::before { content: "\f6a9"; } +.bi-plus-slash-minus::before { content: "\f6aa"; } +.bi-projector-fill::before { content: "\f6ab"; } +.bi-projector::before { content: "\f6ac"; } +.bi-qr-code-scan::before { content: "\f6ad"; } +.bi-qr-code::before { content: "\f6ae"; } +.bi-quora::before { content: "\f6af"; } +.bi-quote::before { content: "\f6b0"; } +.bi-robot::before { content: "\f6b1"; } +.bi-send-check-fill::before { content: "\f6b2"; } +.bi-send-check::before { content: "\f6b3"; } +.bi-send-dash-fill::before { content: "\f6b4"; } +.bi-send-dash::before { content: "\f6b5"; } +.bi-send-exclamation-fill::before { content: "\f6b7"; } +.bi-send-exclamation::before { content: "\f6b8"; } +.bi-send-fill::before { content: "\f6b9"; } +.bi-send-plus-fill::before { content: "\f6ba"; } +.bi-send-plus::before { content: "\f6bb"; } +.bi-send-slash-fill::before { content: "\f6bc"; } +.bi-send-slash::before { content: "\f6bd"; } +.bi-send-x-fill::before { content: "\f6be"; } +.bi-send-x::before { content: "\f6bf"; } +.bi-send::before { content: "\f6c0"; } +.bi-steam::before { content: "\f6c1"; } +.bi-terminal-dash::before { content: "\f6c3"; } +.bi-terminal-plus::before { content: "\f6c4"; } +.bi-terminal-split::before { content: "\f6c5"; } +.bi-ticket-detailed-fill::before { content: "\f6c6"; } +.bi-ticket-detailed::before { content: "\f6c7"; } +.bi-ticket-fill::before { content: "\f6c8"; } +.bi-ticket-perforated-fill::before { content: "\f6c9"; } +.bi-ticket-perforated::before { content: "\f6ca"; } +.bi-ticket::before { content: "\f6cb"; } +.bi-tiktok::before { content: "\f6cc"; } +.bi-window-dash::before { content: "\f6cd"; } +.bi-window-desktop::before { content: "\f6ce"; } +.bi-window-fullscreen::before { content: "\f6cf"; } +.bi-window-plus::before { content: "\f6d0"; } +.bi-window-split::before { content: "\f6d1"; } +.bi-window-stack::before { content: "\f6d2"; } +.bi-window-x::before { content: "\f6d3"; } +.bi-xbox::before { content: "\f6d4"; } +.bi-ethernet::before { content: "\f6d5"; } +.bi-hdmi-fill::before { content: "\f6d6"; } +.bi-hdmi::before { content: "\f6d7"; } +.bi-usb-c-fill::before { content: "\f6d8"; } +.bi-usb-c::before { content: "\f6d9"; } +.bi-usb-fill::before { content: "\f6da"; } +.bi-usb-plug-fill::before { content: "\f6db"; } +.bi-usb-plug::before { content: "\f6dc"; } +.bi-usb-symbol::before { content: "\f6dd"; } +.bi-usb::before { content: "\f6de"; } +.bi-boombox-fill::before { content: "\f6df"; } +.bi-displayport::before { content: "\f6e1"; } +.bi-gpu-card::before { content: "\f6e2"; } +.bi-memory::before { content: "\f6e3"; } +.bi-modem-fill::before { content: "\f6e4"; } +.bi-modem::before { content: "\f6e5"; } +.bi-motherboard-fill::before { content: "\f6e6"; } +.bi-motherboard::before { content: "\f6e7"; } +.bi-optical-audio-fill::before { content: "\f6e8"; } +.bi-optical-audio::before { content: "\f6e9"; } +.bi-pci-card::before { content: "\f6ea"; } +.bi-router-fill::before { content: "\f6eb"; } +.bi-router::before { content: "\f6ec"; } +.bi-thunderbolt-fill::before { content: "\f6ef"; } +.bi-thunderbolt::before { content: "\f6f0"; } +.bi-usb-drive-fill::before { content: "\f6f1"; } +.bi-usb-drive::before { content: "\f6f2"; } +.bi-usb-micro-fill::before { content: "\f6f3"; } +.bi-usb-micro::before { content: "\f6f4"; } +.bi-usb-mini-fill::before { content: "\f6f5"; } +.bi-usb-mini::before { content: "\f6f6"; } +.bi-cloud-haze2::before { content: "\f6f7"; } +.bi-device-hdd-fill::before { content: "\f6f8"; } +.bi-device-hdd::before { content: "\f6f9"; } +.bi-device-ssd-fill::before { content: "\f6fa"; } +.bi-device-ssd::before { content: "\f6fb"; } +.bi-displayport-fill::before { content: "\f6fc"; } +.bi-mortarboard-fill::before { content: "\f6fd"; } +.bi-mortarboard::before { content: "\f6fe"; } +.bi-terminal-x::before { content: "\f6ff"; } +.bi-arrow-through-heart-fill::before { content: "\f700"; } +.bi-arrow-through-heart::before { content: "\f701"; } +.bi-badge-sd-fill::before { content: "\f702"; } +.bi-badge-sd::before { content: "\f703"; } +.bi-bag-heart-fill::before { content: "\f704"; } +.bi-bag-heart::before { content: "\f705"; } +.bi-balloon-fill::before { content: "\f706"; } +.bi-balloon-heart-fill::before { content: "\f707"; } +.bi-balloon-heart::before { content: "\f708"; } +.bi-balloon::before { content: "\f709"; } +.bi-box2-fill::before { content: "\f70a"; } +.bi-box2-heart-fill::before { content: "\f70b"; } +.bi-box2-heart::before { content: "\f70c"; } +.bi-box2::before { content: "\f70d"; } +.bi-braces-asterisk::before { content: "\f70e"; } +.bi-calendar-heart-fill::before { content: "\f70f"; } +.bi-calendar-heart::before { content: "\f710"; } +.bi-calendar2-heart-fill::before { content: "\f711"; } +.bi-calendar2-heart::before { content: "\f712"; } +.bi-chat-heart-fill::before { content: "\f713"; } +.bi-chat-heart::before { content: "\f714"; } +.bi-chat-left-heart-fill::before { content: "\f715"; } +.bi-chat-left-heart::before { content: "\f716"; } +.bi-chat-right-heart-fill::before { content: "\f717"; } +.bi-chat-right-heart::before { content: "\f718"; } +.bi-chat-square-heart-fill::before { content: "\f719"; } +.bi-chat-square-heart::before { content: "\f71a"; } +.bi-clipboard-check-fill::before { content: "\f71b"; } +.bi-clipboard-data-fill::before { content: "\f71c"; } +.bi-clipboard-fill::before { content: "\f71d"; } +.bi-clipboard-heart-fill::before { content: "\f71e"; } +.bi-clipboard-heart::before { content: "\f71f"; } +.bi-clipboard-minus-fill::before { content: "\f720"; } +.bi-clipboard-plus-fill::before { content: "\f721"; } +.bi-clipboard-pulse::before { content: "\f722"; } +.bi-clipboard-x-fill::before { content: "\f723"; } +.bi-clipboard2-check-fill::before { content: "\f724"; } +.bi-clipboard2-check::before { content: "\f725"; } +.bi-clipboard2-data-fill::before { content: "\f726"; } +.bi-clipboard2-data::before { content: "\f727"; } +.bi-clipboard2-fill::before { content: "\f728"; } +.bi-clipboard2-heart-fill::before { content: "\f729"; } +.bi-clipboard2-heart::before { content: "\f72a"; } +.bi-clipboard2-minus-fill::before { content: "\f72b"; } +.bi-clipboard2-minus::before { content: "\f72c"; } +.bi-clipboard2-plus-fill::before { content: "\f72d"; } +.bi-clipboard2-plus::before { content: "\f72e"; } +.bi-clipboard2-pulse-fill::before { content: "\f72f"; } +.bi-clipboard2-pulse::before { content: "\f730"; } +.bi-clipboard2-x-fill::before { content: "\f731"; } +.bi-clipboard2-x::before { content: "\f732"; } +.bi-clipboard2::before { content: "\f733"; } +.bi-emoji-kiss-fill::before { content: "\f734"; } +.bi-emoji-kiss::before { content: "\f735"; } +.bi-envelope-heart-fill::before { content: "\f736"; } +.bi-envelope-heart::before { content: "\f737"; } +.bi-envelope-open-heart-fill::before { content: "\f738"; } +.bi-envelope-open-heart::before { content: "\f739"; } +.bi-envelope-paper-fill::before { content: "\f73a"; } +.bi-envelope-paper-heart-fill::before { content: "\f73b"; } +.bi-envelope-paper-heart::before { content: "\f73c"; } +.bi-envelope-paper::before { content: "\f73d"; } +.bi-filetype-aac::before { content: "\f73e"; } +.bi-filetype-ai::before { content: "\f73f"; } +.bi-filetype-bmp::before { content: "\f740"; } +.bi-filetype-cs::before { content: "\f741"; } +.bi-filetype-css::before { content: "\f742"; } +.bi-filetype-csv::before { content: "\f743"; } +.bi-filetype-doc::before { content: "\f744"; } +.bi-filetype-docx::before { content: "\f745"; } +.bi-filetype-exe::before { content: "\f746"; } +.bi-filetype-gif::before { content: "\f747"; } +.bi-filetype-heic::before { content: "\f748"; } +.bi-filetype-html::before { content: "\f749"; } +.bi-filetype-java::before { content: "\f74a"; } +.bi-filetype-jpg::before { content: "\f74b"; } +.bi-filetype-js::before { content: "\f74c"; } +.bi-filetype-jsx::before { content: "\f74d"; } +.bi-filetype-key::before { content: "\f74e"; } +.bi-filetype-m4p::before { content: "\f74f"; } +.bi-filetype-md::before { content: "\f750"; } +.bi-filetype-mdx::before { content: "\f751"; } +.bi-filetype-mov::before { content: "\f752"; } +.bi-filetype-mp3::before { content: "\f753"; } +.bi-filetype-mp4::before { content: "\f754"; } +.bi-filetype-otf::before { content: "\f755"; } +.bi-filetype-pdf::before { content: "\f756"; } +.bi-filetype-php::before { content: "\f757"; } +.bi-filetype-png::before { content: "\f758"; } +.bi-filetype-ppt::before { content: "\f75a"; } +.bi-filetype-psd::before { content: "\f75b"; } +.bi-filetype-py::before { content: "\f75c"; } +.bi-filetype-raw::before { content: "\f75d"; } +.bi-filetype-rb::before { content: "\f75e"; } +.bi-filetype-sass::before { content: "\f75f"; } +.bi-filetype-scss::before { content: "\f760"; } +.bi-filetype-sh::before { content: "\f761"; } +.bi-filetype-svg::before { content: "\f762"; } +.bi-filetype-tiff::before { content: "\f763"; } +.bi-filetype-tsx::before { content: "\f764"; } +.bi-filetype-ttf::before { content: "\f765"; } +.bi-filetype-txt::before { content: "\f766"; } +.bi-filetype-wav::before { content: "\f767"; } +.bi-filetype-woff::before { content: "\f768"; } +.bi-filetype-xls::before { content: "\f76a"; } +.bi-filetype-xml::before { content: "\f76b"; } +.bi-filetype-yml::before { content: "\f76c"; } +.bi-heart-arrow::before { content: "\f76d"; } +.bi-heart-pulse-fill::before { content: "\f76e"; } +.bi-heart-pulse::before { content: "\f76f"; } +.bi-heartbreak-fill::before { content: "\f770"; } +.bi-heartbreak::before { content: "\f771"; } +.bi-hearts::before { content: "\f772"; } +.bi-hospital-fill::before { content: "\f773"; } +.bi-hospital::before { content: "\f774"; } +.bi-house-heart-fill::before { content: "\f775"; } +.bi-house-heart::before { content: "\f776"; } +.bi-incognito::before { content: "\f777"; } +.bi-magnet-fill::before { content: "\f778"; } +.bi-magnet::before { content: "\f779"; } +.bi-person-heart::before { content: "\f77a"; } +.bi-person-hearts::before { content: "\f77b"; } +.bi-phone-flip::before { content: "\f77c"; } +.bi-plugin::before { content: "\f77d"; } +.bi-postage-fill::before { content: "\f77e"; } +.bi-postage-heart-fill::before { content: "\f77f"; } +.bi-postage-heart::before { content: "\f780"; } +.bi-postage::before { content: "\f781"; } +.bi-postcard-fill::before { content: "\f782"; } +.bi-postcard-heart-fill::before { content: "\f783"; } +.bi-postcard-heart::before { content: "\f784"; } +.bi-postcard::before { content: "\f785"; } +.bi-search-heart-fill::before { content: "\f786"; } +.bi-search-heart::before { content: "\f787"; } +.bi-sliders2-vertical::before { content: "\f788"; } +.bi-sliders2::before { content: "\f789"; } +.bi-trash3-fill::before { content: "\f78a"; } +.bi-trash3::before { content: "\f78b"; } +.bi-valentine::before { content: "\f78c"; } +.bi-valentine2::before { content: "\f78d"; } +.bi-wrench-adjustable-circle-fill::before { content: "\f78e"; } +.bi-wrench-adjustable-circle::before { content: "\f78f"; } +.bi-wrench-adjustable::before { content: "\f790"; } +.bi-filetype-json::before { content: "\f791"; } +.bi-filetype-pptx::before { content: "\f792"; } +.bi-filetype-xlsx::before { content: "\f793"; } +.bi-1-circle-fill::before { content: "\f796"; } +.bi-1-circle::before { content: "\f797"; } +.bi-1-square-fill::before { content: "\f798"; } +.bi-1-square::before { content: "\f799"; } +.bi-2-circle-fill::before { content: "\f79c"; } +.bi-2-circle::before { content: "\f79d"; } +.bi-2-square-fill::before { content: "\f79e"; } +.bi-2-square::before { content: "\f79f"; } +.bi-3-circle-fill::before { content: "\f7a2"; } +.bi-3-circle::before { content: "\f7a3"; } +.bi-3-square-fill::before { content: "\f7a4"; } +.bi-3-square::before { content: "\f7a5"; } +.bi-4-circle-fill::before { content: "\f7a8"; } +.bi-4-circle::before { content: "\f7a9"; } +.bi-4-square-fill::before { content: "\f7aa"; } +.bi-4-square::before { content: "\f7ab"; } +.bi-5-circle-fill::before { content: "\f7ae"; } +.bi-5-circle::before { content: "\f7af"; } +.bi-5-square-fill::before { content: "\f7b0"; } +.bi-5-square::before { content: "\f7b1"; } +.bi-6-circle-fill::before { content: "\f7b4"; } +.bi-6-circle::before { content: "\f7b5"; } +.bi-6-square-fill::before { content: "\f7b6"; } +.bi-6-square::before { content: "\f7b7"; } +.bi-7-circle-fill::before { content: "\f7ba"; } +.bi-7-circle::before { content: "\f7bb"; } +.bi-7-square-fill::before { content: "\f7bc"; } +.bi-7-square::before { content: "\f7bd"; } +.bi-8-circle-fill::before { content: "\f7c0"; } +.bi-8-circle::before { content: "\f7c1"; } +.bi-8-square-fill::before { content: "\f7c2"; } +.bi-8-square::before { content: "\f7c3"; } +.bi-9-circle-fill::before { content: "\f7c6"; } +.bi-9-circle::before { content: "\f7c7"; } +.bi-9-square-fill::before { content: "\f7c8"; } +.bi-9-square::before { content: "\f7c9"; } +.bi-airplane-engines-fill::before { content: "\f7ca"; } +.bi-airplane-engines::before { content: "\f7cb"; } +.bi-airplane-fill::before { content: "\f7cc"; } +.bi-airplane::before { content: "\f7cd"; } +.bi-alexa::before { content: "\f7ce"; } +.bi-alipay::before { content: "\f7cf"; } +.bi-android::before { content: "\f7d0"; } +.bi-android2::before { content: "\f7d1"; } +.bi-box-fill::before { content: "\f7d2"; } +.bi-box-seam-fill::before { content: "\f7d3"; } +.bi-browser-chrome::before { content: "\f7d4"; } +.bi-browser-edge::before { content: "\f7d5"; } +.bi-browser-firefox::before { content: "\f7d6"; } +.bi-browser-safari::before { content: "\f7d7"; } +.bi-c-circle-fill::before { content: "\f7da"; } +.bi-c-circle::before { content: "\f7db"; } +.bi-c-square-fill::before { content: "\f7dc"; } +.bi-c-square::before { content: "\f7dd"; } +.bi-capsule-pill::before { content: "\f7de"; } +.bi-capsule::before { content: "\f7df"; } +.bi-car-front-fill::before { content: "\f7e0"; } +.bi-car-front::before { content: "\f7e1"; } +.bi-cassette-fill::before { content: "\f7e2"; } +.bi-cassette::before { content: "\f7e3"; } +.bi-cc-circle-fill::before { content: "\f7e6"; } +.bi-cc-circle::before { content: "\f7e7"; } +.bi-cc-square-fill::before { content: "\f7e8"; } +.bi-cc-square::before { content: "\f7e9"; } +.bi-cup-hot-fill::before { content: "\f7ea"; } +.bi-cup-hot::before { content: "\f7eb"; } +.bi-currency-rupee::before { content: "\f7ec"; } +.bi-dropbox::before { content: "\f7ed"; } +.bi-escape::before { content: "\f7ee"; } +.bi-fast-forward-btn-fill::before { content: "\f7ef"; } +.bi-fast-forward-btn::before { content: "\f7f0"; } +.bi-fast-forward-circle-fill::before { content: "\f7f1"; } +.bi-fast-forward-circle::before { content: "\f7f2"; } +.bi-fast-forward-fill::before { content: "\f7f3"; } +.bi-fast-forward::before { content: "\f7f4"; } +.bi-filetype-sql::before { content: "\f7f5"; } +.bi-fire::before { content: "\f7f6"; } +.bi-google-play::before { content: "\f7f7"; } +.bi-h-circle-fill::before { content: "\f7fa"; } +.bi-h-circle::before { content: "\f7fb"; } +.bi-h-square-fill::before { content: "\f7fc"; } +.bi-h-square::before { content: "\f7fd"; } +.bi-indent::before { content: "\f7fe"; } +.bi-lungs-fill::before { content: "\f7ff"; } +.bi-lungs::before { content: "\f800"; } +.bi-microsoft-teams::before { content: "\f801"; } +.bi-p-circle-fill::before { content: "\f804"; } +.bi-p-circle::before { content: "\f805"; } +.bi-p-square-fill::before { content: "\f806"; } +.bi-p-square::before { content: "\f807"; } +.bi-pass-fill::before { content: "\f808"; } +.bi-pass::before { content: "\f809"; } +.bi-prescription::before { content: "\f80a"; } +.bi-prescription2::before { content: "\f80b"; } +.bi-r-circle-fill::before { content: "\f80e"; } +.bi-r-circle::before { content: "\f80f"; } +.bi-r-square-fill::before { content: "\f810"; } +.bi-r-square::before { content: "\f811"; } +.bi-repeat-1::before { content: "\f812"; } +.bi-repeat::before { content: "\f813"; } +.bi-rewind-btn-fill::before { content: "\f814"; } +.bi-rewind-btn::before { content: "\f815"; } +.bi-rewind-circle-fill::before { content: "\f816"; } +.bi-rewind-circle::before { content: "\f817"; } +.bi-rewind-fill::before { content: "\f818"; } +.bi-rewind::before { content: "\f819"; } +.bi-train-freight-front-fill::before { content: "\f81a"; } +.bi-train-freight-front::before { content: "\f81b"; } +.bi-train-front-fill::before { content: "\f81c"; } +.bi-train-front::before { content: "\f81d"; } +.bi-train-lightrail-front-fill::before { content: "\f81e"; } +.bi-train-lightrail-front::before { content: "\f81f"; } +.bi-truck-front-fill::before { content: "\f820"; } +.bi-truck-front::before { content: "\f821"; } +.bi-ubuntu::before { content: "\f822"; } +.bi-unindent::before { content: "\f823"; } +.bi-unity::before { content: "\f824"; } +.bi-universal-access-circle::before { content: "\f825"; } +.bi-universal-access::before { content: "\f826"; } +.bi-virus::before { content: "\f827"; } +.bi-virus2::before { content: "\f828"; } +.bi-wechat::before { content: "\f829"; } +.bi-yelp::before { content: "\f82a"; } +.bi-sign-stop-fill::before { content: "\f82b"; } +.bi-sign-stop-lights-fill::before { content: "\f82c"; } +.bi-sign-stop-lights::before { content: "\f82d"; } +.bi-sign-stop::before { content: "\f82e"; } +.bi-sign-turn-left-fill::before { content: "\f82f"; } +.bi-sign-turn-left::before { content: "\f830"; } +.bi-sign-turn-right-fill::before { content: "\f831"; } +.bi-sign-turn-right::before { content: "\f832"; } +.bi-sign-turn-slight-left-fill::before { content: "\f833"; } +.bi-sign-turn-slight-left::before { content: "\f834"; } +.bi-sign-turn-slight-right-fill::before { content: "\f835"; } +.bi-sign-turn-slight-right::before { content: "\f836"; } +.bi-sign-yield-fill::before { content: "\f837"; } +.bi-sign-yield::before { content: "\f838"; } +.bi-ev-station-fill::before { content: "\f839"; } +.bi-ev-station::before { content: "\f83a"; } +.bi-fuel-pump-diesel-fill::before { content: "\f83b"; } +.bi-fuel-pump-diesel::before { content: "\f83c"; } +.bi-fuel-pump-fill::before { content: "\f83d"; } +.bi-fuel-pump::before { content: "\f83e"; } +.bi-0-circle-fill::before { content: "\f83f"; } +.bi-0-circle::before { content: "\f840"; } +.bi-0-square-fill::before { content: "\f841"; } +.bi-0-square::before { content: "\f842"; } +.bi-rocket-fill::before { content: "\f843"; } +.bi-rocket-takeoff-fill::before { content: "\f844"; } +.bi-rocket-takeoff::before { content: "\f845"; } +.bi-rocket::before { content: "\f846"; } +.bi-stripe::before { content: "\f847"; } +.bi-subscript::before { content: "\f848"; } +.bi-superscript::before { content: "\f849"; } +.bi-trello::before { content: "\f84a"; } +.bi-envelope-at-fill::before { content: "\f84b"; } +.bi-envelope-at::before { content: "\f84c"; } +.bi-regex::before { content: "\f84d"; } +.bi-text-wrap::before { content: "\f84e"; } +.bi-sign-dead-end-fill::before { content: "\f84f"; } +.bi-sign-dead-end::before { content: "\f850"; } +.bi-sign-do-not-enter-fill::before { content: "\f851"; } +.bi-sign-do-not-enter::before { content: "\f852"; } +.bi-sign-intersection-fill::before { content: "\f853"; } +.bi-sign-intersection-side-fill::before { content: "\f854"; } +.bi-sign-intersection-side::before { content: "\f855"; } +.bi-sign-intersection-t-fill::before { content: "\f856"; } +.bi-sign-intersection-t::before { content: "\f857"; } +.bi-sign-intersection-y-fill::before { content: "\f858"; } +.bi-sign-intersection-y::before { content: "\f859"; } +.bi-sign-intersection::before { content: "\f85a"; } +.bi-sign-merge-left-fill::before { content: "\f85b"; } +.bi-sign-merge-left::before { content: "\f85c"; } +.bi-sign-merge-right-fill::before { content: "\f85d"; } +.bi-sign-merge-right::before { content: "\f85e"; } +.bi-sign-no-left-turn-fill::before { content: "\f85f"; } +.bi-sign-no-left-turn::before { content: "\f860"; } +.bi-sign-no-parking-fill::before { content: "\f861"; } +.bi-sign-no-parking::before { content: "\f862"; } +.bi-sign-no-right-turn-fill::before { content: "\f863"; } +.bi-sign-no-right-turn::before { content: "\f864"; } +.bi-sign-railroad-fill::before { content: "\f865"; } +.bi-sign-railroad::before { content: "\f866"; } +.bi-building-add::before { content: "\f867"; } +.bi-building-check::before { content: "\f868"; } +.bi-building-dash::before { content: "\f869"; } +.bi-building-down::before { content: "\f86a"; } +.bi-building-exclamation::before { content: "\f86b"; } +.bi-building-fill-add::before { content: "\f86c"; } +.bi-building-fill-check::before { content: "\f86d"; } +.bi-building-fill-dash::before { content: "\f86e"; } +.bi-building-fill-down::before { content: "\f86f"; } +.bi-building-fill-exclamation::before { content: "\f870"; } +.bi-building-fill-gear::before { content: "\f871"; } +.bi-building-fill-lock::before { content: "\f872"; } +.bi-building-fill-slash::before { content: "\f873"; } +.bi-building-fill-up::before { content: "\f874"; } +.bi-building-fill-x::before { content: "\f875"; } +.bi-building-fill::before { content: "\f876"; } +.bi-building-gear::before { content: "\f877"; } +.bi-building-lock::before { content: "\f878"; } +.bi-building-slash::before { content: "\f879"; } +.bi-building-up::before { content: "\f87a"; } +.bi-building-x::before { content: "\f87b"; } +.bi-buildings-fill::before { content: "\f87c"; } +.bi-buildings::before { content: "\f87d"; } +.bi-bus-front-fill::before { content: "\f87e"; } +.bi-bus-front::before { content: "\f87f"; } +.bi-ev-front-fill::before { content: "\f880"; } +.bi-ev-front::before { content: "\f881"; } +.bi-globe-americas::before { content: "\f882"; } +.bi-globe-asia-australia::before { content: "\f883"; } +.bi-globe-central-south-asia::before { content: "\f884"; } +.bi-globe-europe-africa::before { content: "\f885"; } +.bi-house-add-fill::before { content: "\f886"; } +.bi-house-add::before { content: "\f887"; } +.bi-house-check-fill::before { content: "\f888"; } +.bi-house-check::before { content: "\f889"; } +.bi-house-dash-fill::before { content: "\f88a"; } +.bi-house-dash::before { content: "\f88b"; } +.bi-house-down-fill::before { content: "\f88c"; } +.bi-house-down::before { content: "\f88d"; } +.bi-house-exclamation-fill::before { content: "\f88e"; } +.bi-house-exclamation::before { content: "\f88f"; } +.bi-house-gear-fill::before { content: "\f890"; } +.bi-house-gear::before { content: "\f891"; } +.bi-house-lock-fill::before { content: "\f892"; } +.bi-house-lock::before { content: "\f893"; } +.bi-house-slash-fill::before { content: "\f894"; } +.bi-house-slash::before { content: "\f895"; } +.bi-house-up-fill::before { content: "\f896"; } +.bi-house-up::before { content: "\f897"; } +.bi-house-x-fill::before { content: "\f898"; } +.bi-house-x::before { content: "\f899"; } +.bi-person-add::before { content: "\f89a"; } +.bi-person-down::before { content: "\f89b"; } +.bi-person-exclamation::before { content: "\f89c"; } +.bi-person-fill-add::before { content: "\f89d"; } +.bi-person-fill-check::before { content: "\f89e"; } +.bi-person-fill-dash::before { content: "\f89f"; } +.bi-person-fill-down::before { content: "\f8a0"; } +.bi-person-fill-exclamation::before { content: "\f8a1"; } +.bi-person-fill-gear::before { content: "\f8a2"; } +.bi-person-fill-lock::before { content: "\f8a3"; } +.bi-person-fill-slash::before { content: "\f8a4"; } +.bi-person-fill-up::before { content: "\f8a5"; } +.bi-person-fill-x::before { content: "\f8a6"; } +.bi-person-gear::before { content: "\f8a7"; } +.bi-person-lock::before { content: "\f8a8"; } +.bi-person-slash::before { content: "\f8a9"; } +.bi-person-up::before { content: "\f8aa"; } +.bi-scooter::before { content: "\f8ab"; } +.bi-taxi-front-fill::before { content: "\f8ac"; } +.bi-taxi-front::before { content: "\f8ad"; } +.bi-amd::before { content: "\f8ae"; } +.bi-database-add::before { content: "\f8af"; } +.bi-database-check::before { content: "\f8b0"; } +.bi-database-dash::before { content: "\f8b1"; } +.bi-database-down::before { content: "\f8b2"; } +.bi-database-exclamation::before { content: "\f8b3"; } +.bi-database-fill-add::before { content: "\f8b4"; } +.bi-database-fill-check::before { content: "\f8b5"; } +.bi-database-fill-dash::before { content: "\f8b6"; } +.bi-database-fill-down::before { content: "\f8b7"; } +.bi-database-fill-exclamation::before { content: "\f8b8"; } +.bi-database-fill-gear::before { content: "\f8b9"; } +.bi-database-fill-lock::before { content: "\f8ba"; } +.bi-database-fill-slash::before { content: "\f8bb"; } +.bi-database-fill-up::before { content: "\f8bc"; } +.bi-database-fill-x::before { content: "\f8bd"; } +.bi-database-fill::before { content: "\f8be"; } +.bi-database-gear::before { content: "\f8bf"; } +.bi-database-lock::before { content: "\f8c0"; } +.bi-database-slash::before { content: "\f8c1"; } +.bi-database-up::before { content: "\f8c2"; } +.bi-database-x::before { content: "\f8c3"; } +.bi-database::before { content: "\f8c4"; } +.bi-houses-fill::before { content: "\f8c5"; } +.bi-houses::before { content: "\f8c6"; } +.bi-nvidia::before { content: "\f8c7"; } +.bi-person-vcard-fill::before { content: "\f8c8"; } +.bi-person-vcard::before { content: "\f8c9"; } +.bi-sina-weibo::before { content: "\f8ca"; } +.bi-tencent-qq::before { content: "\f8cb"; } +.bi-wikipedia::before { content: "\f8cc"; } +.bi-alphabet-uppercase::before { content: "\f2a5"; } +.bi-alphabet::before { content: "\f68a"; } +.bi-amazon::before { content: "\f68d"; } +.bi-arrows-collapse-vertical::before { content: "\f690"; } +.bi-arrows-expand-vertical::before { content: "\f695"; } +.bi-arrows-vertical::before { content: "\f698"; } +.bi-arrows::before { content: "\f6a2"; } +.bi-ban-fill::before { content: "\f6a3"; } +.bi-ban::before { content: "\f6b6"; } +.bi-bing::before { content: "\f6c2"; } +.bi-cake::before { content: "\f6e0"; } +.bi-cake2::before { content: "\f6ed"; } +.bi-cookie::before { content: "\f6ee"; } +.bi-copy::before { content: "\f759"; } +.bi-crosshair::before { content: "\f769"; } +.bi-crosshair2::before { content: "\f794"; } +.bi-emoji-astonished-fill::before { content: "\f795"; } +.bi-emoji-astonished::before { content: "\f79a"; } +.bi-emoji-grimace-fill::before { content: "\f79b"; } +.bi-emoji-grimace::before { content: "\f7a0"; } +.bi-emoji-grin-fill::before { content: "\f7a1"; } +.bi-emoji-grin::before { content: "\f7a6"; } +.bi-emoji-surprise-fill::before { content: "\f7a7"; } +.bi-emoji-surprise::before { content: "\f7ac"; } +.bi-emoji-tear-fill::before { content: "\f7ad"; } +.bi-emoji-tear::before { content: "\f7b2"; } +.bi-envelope-arrow-down-fill::before { content: "\f7b3"; } +.bi-envelope-arrow-down::before { content: "\f7b8"; } +.bi-envelope-arrow-up-fill::before { content: "\f7b9"; } +.bi-envelope-arrow-up::before { content: "\f7be"; } +.bi-feather::before { content: "\f7bf"; } +.bi-feather2::before { content: "\f7c4"; } +.bi-floppy-fill::before { content: "\f7c5"; } +.bi-floppy::before { content: "\f7d8"; } +.bi-floppy2-fill::before { content: "\f7d9"; } +.bi-floppy2::before { content: "\f7e4"; } +.bi-gitlab::before { content: "\f7e5"; } +.bi-highlighter::before { content: "\f7f8"; } +.bi-marker-tip::before { content: "\f802"; } +.bi-nvme-fill::before { content: "\f803"; } +.bi-nvme::before { content: "\f80c"; } +.bi-opencollective::before { content: "\f80d"; } +.bi-pci-card-network::before { content: "\f8cd"; } +.bi-pci-card-sound::before { content: "\f8ce"; } +.bi-radar::before { content: "\f8cf"; } +.bi-send-arrow-down-fill::before { content: "\f8d0"; } +.bi-send-arrow-down::before { content: "\f8d1"; } +.bi-send-arrow-up-fill::before { content: "\f8d2"; } +.bi-send-arrow-up::before { content: "\f8d3"; } +.bi-sim-slash-fill::before { content: "\f8d4"; } +.bi-sim-slash::before { content: "\f8d5"; } +.bi-sourceforge::before { content: "\f8d6"; } +.bi-substack::before { content: "\f8d7"; } +.bi-threads-fill::before { content: "\f8d8"; } +.bi-threads::before { content: "\f8d9"; } +.bi-transparency::before { content: "\f8da"; } +.bi-twitter-x::before { content: "\f8db"; } +.bi-type-h4::before { content: "\f8dc"; } +.bi-type-h5::before { content: "\f8dd"; } +.bi-type-h6::before { content: "\f8de"; } +.bi-backpack-fill::before { content: "\f8df"; } +.bi-backpack::before { content: "\f8e0"; } +.bi-backpack2-fill::before { content: "\f8e1"; } +.bi-backpack2::before { content: "\f8e2"; } +.bi-backpack3-fill::before { content: "\f8e3"; } +.bi-backpack3::before { content: "\f8e4"; } +.bi-backpack4-fill::before { content: "\f8e5"; } +.bi-backpack4::before { content: "\f8e6"; } +.bi-brilliance::before { content: "\f8e7"; } +.bi-cake-fill::before { content: "\f8e8"; } +.bi-cake2-fill::before { content: "\f8e9"; } +.bi-duffle-fill::before { content: "\f8ea"; } +.bi-duffle::before { content: "\f8eb"; } +.bi-exposure::before { content: "\f8ec"; } +.bi-gender-neuter::before { content: "\f8ed"; } +.bi-highlights::before { content: "\f8ee"; } +.bi-luggage-fill::before { content: "\f8ef"; } +.bi-luggage::before { content: "\f8f0"; } +.bi-mailbox-flag::before { content: "\f8f1"; } +.bi-mailbox2-flag::before { content: "\f8f2"; } +.bi-noise-reduction::before { content: "\f8f3"; } +.bi-passport-fill::before { content: "\f8f4"; } +.bi-passport::before { content: "\f8f5"; } +.bi-person-arms-up::before { content: "\f8f6"; } +.bi-person-raised-hand::before { content: "\f8f7"; } +.bi-person-standing-dress::before { content: "\f8f8"; } +.bi-person-standing::before { content: "\f8f9"; } +.bi-person-walking::before { content: "\f8fa"; } +.bi-person-wheelchair::before { content: "\f8fb"; } +.bi-shadows::before { content: "\f8fc"; } +.bi-suitcase-fill::before { content: "\f8fd"; } +.bi-suitcase-lg-fill::before { content: "\f8fe"; } +.bi-suitcase-lg::before { content: "\f8ff"; } +.bi-suitcase::before { content: "\f900"; } +.bi-suitcase2-fill::before { content: "\f901"; } +.bi-suitcase2::before { content: "\f902"; } +.bi-vignette::before { content: "\f903"; } diff --git a/src/main/resources/static/css/bootstrap.min.css b/src/main/resources/static/css/bootstrap.min.css new file mode 100644 index 0000000..b23c3e7 --- /dev/null +++ b/src/main/resources/static/css/bootstrap.min.css @@ -0,0 +1,6 @@ +@charset "UTF-8";/*! + * Bootstrap v5.3.0 (https://getbootstrap.com/) + * Copyright 2011-2023 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:#052c65;--bs-secondary-text-emphasis:#2b2f32;--bs-success-text-emphasis:#0a3622;--bs-info-text-emphasis:#055160;--bs-warning-text-emphasis:#664d03;--bs-danger-text-emphasis:#58151c;--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:#cfe2ff;--bs-secondary-bg-subtle:#e2e3e5;--bs-success-bg-subtle:#d1e7dd;--bs-info-bg-subtle:#cff4fc;--bs-warning-bg-subtle:#fff3cd;--bs-danger-bg-subtle:#f8d7da;--bs-light-bg-subtle:#fcfcfd;--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:#9ec5fe;--bs-secondary-border-subtle:#c4c8cb;--bs-success-border-subtle:#a3cfbb;--bs-info-border-subtle:#9eeaf9;--bs-warning-border-subtle:#ffe69c;--bs-danger-border-subtle:#f1aeb5;--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:#0a58ca;--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-bg:#fff3cd;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#adb5bd;--bs-body-color-rgb:173,181,189;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(173, 181, 189, 0.75);--bs-secondary-color-rgb:173,181,189;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(173, 181, 189, 0.5);--bs-tertiary-color-rgb:173,181,189;--bs-tertiary-bg:#2b3035;--bs-tertiary-bg-rgb:43,48,53;--bs-primary-text-emphasis:#6ea8fe;--bs-secondary-text-emphasis:#a7acb1;--bs-success-text-emphasis:#75b798;--bs-info-text-emphasis:#6edff6;--bs-warning-text-emphasis:#ffda6a;--bs-danger-text-emphasis:#ea868f;--bs-light-text-emphasis:#f8f9fa;--bs-dark-text-emphasis:#dee2e6;--bs-primary-bg-subtle:#031633;--bs-secondary-bg-subtle:#161719;--bs-success-bg-subtle:#051b11;--bs-info-bg-subtle:#032830;--bs-warning-bg-subtle:#332701;--bs-danger-bg-subtle:#2c0b0e;--bs-light-bg-subtle:#343a40;--bs-dark-bg-subtle:#1a1d20;--bs-primary-border-subtle:#084298;--bs-secondary-border-subtle:#41464b;--bs-success-border-subtle:#0f5132;--bs-info-border-subtle:#087990;--bs-warning-border-subtle:#997404;--bs-danger-border-subtle:#842029;--bs-light-border-subtle:#495057;--bs-dark-border-subtle:#343a40;--bs-heading-color:inherit;--bs-link-color:#6ea8fe;--bs-link-hover-color:#8bb9fe;--bs-link-color-rgb:110,168,254;--bs-link-hover-color-rgb:139,185,254;--bs-code-color:#e685b5;--bs-border-color:#495057;--bs-border-color-translucent:rgba(255, 255, 255, 0.15);--bs-form-valid-color:#75b798;--bs-form-valid-border-color:#75b798;--bs-form-invalid-color:#ea868f;--bs-form-invalid-border-color:#ea868f}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;border:0;border-top:var(--bs-border-width) solid;opacity:.25}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2;color:var(--bs-heading-color)}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}.small,small{font-size:.875em}.mark,mark{padding:.1875em;background-color:var(--bs-highlight-bg)}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,1));text-decoration:underline}a:hover{--bs-link-color-rgb:var(--bs-link-hover-color-rgb)}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:var(--bs-code-color);word-wrap:break-word}a>code{color:inherit}kbd{padding:.1875rem .375rem;font-size:.875em;color:var(--bs-body-bg);background-color:var(--bs-body-color);border-radius:.25rem}kbd kbd{padding:0;font-size:1em}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-secondary-color);text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator{display:none!important}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}::file-selector-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:calc(1.625rem + 4.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-1{font-size:5rem}}.display-2{font-size:calc(1.575rem + 3.9vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-2{font-size:4.5rem}}.display-3{font-size:calc(1.525rem + 3.3vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-3{font-size:4rem}}.display-4{font-size:calc(1.475rem + 2.7vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-4{font-size:3.5rem}}.display-5{font-size:calc(1.425rem + 2.1vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-5{font-size:3rem}}.display-6{font-size:calc(1.375rem + 1.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-6{font-size:2.5rem}}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:.875em;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:.875em;color:#6c757d}.blockquote-footer::before{content:"— "}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:var(--bs-body-bg);border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:.875em;color:var(--bs-secondary-color)}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{--bs-gutter-x:1.5rem;--bs-gutter-y:0;width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}:root{--bs-breakpoint-xs:0;--bs-breakpoint-sm:576px;--bs-breakpoint-md:768px;--bs-breakpoint-lg:992px;--bs-breakpoint-xl:1200px;--bs-breakpoint-xxl:1400px}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-right:calc(-.5 * var(--bs-gutter-x));margin-left:calc(-.5 * var(--bs-gutter-x))}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0%}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.6666666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0%}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.6666666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0%}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.6666666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0%}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.6666666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0%}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.6666666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0%}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.6666666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333333%}.offset-xxl-2{margin-left:16.66666667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333333%}.offset-xxl-5{margin-left:41.66666667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333333%}.offset-xxl-8{margin-left:66.66666667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333333%}.offset-xxl-11{margin-left:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.table{--bs-table-color-type:initial;--bs-table-bg-type:initial;--bs-table-color-state:initial;--bs-table-bg-state:initial;--bs-table-color:var(--bs-body-color);--bs-table-bg:var(--bs-body-bg);--bs-table-border-color:var(--bs-border-color);--bs-table-accent-bg:transparent;--bs-table-striped-color:var(--bs-body-color);--bs-table-striped-bg:rgba(0, 0, 0, 0.05);--bs-table-active-color:var(--bs-body-color);--bs-table-active-bg:rgba(0, 0, 0, 0.1);--bs-table-hover-color:var(--bs-body-color);--bs-table-hover-bg:rgba(0, 0, 0, 0.075);width:100%;margin-bottom:1rem;vertical-align:top;border-color:var(--bs-table-border-color)}.table>:not(caption)>*>*{padding:.5rem .5rem;color:var(--bs-table-color-state,var(--bs-table-color-type,var(--bs-table-color)));background-color:var(--bs-table-bg);border-bottom-width:var(--bs-border-width);box-shadow:inset 0 0 0 9999px var(--bs-table-bg-state,var(--bs-table-bg-type,var(--bs-table-accent-bg)))}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table-group-divider{border-top:calc(var(--bs-border-width) * 2) solid currentcolor}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem .25rem}.table-bordered>:not(caption)>*{border-width:var(--bs-border-width) 0}.table-bordered>:not(caption)>*>*{border-width:0 var(--bs-border-width)}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-borderless>:not(:first-child){border-top-width:0}.table-striped>tbody>tr:nth-of-type(odd)>*{--bs-table-color-type:var(--bs-table-striped-color);--bs-table-bg-type:var(--bs-table-striped-bg)}.table-striped-columns>:not(caption)>tr>:nth-child(2n){--bs-table-color-type:var(--bs-table-striped-color);--bs-table-bg-type:var(--bs-table-striped-bg)}.table-active{--bs-table-color-state:var(--bs-table-active-color);--bs-table-bg-state:var(--bs-table-active-bg)}.table-hover>tbody>tr:hover>*{--bs-table-color-state:var(--bs-table-hover-color);--bs-table-bg-state:var(--bs-table-hover-bg)}.table-primary{--bs-table-color:#000;--bs-table-bg:#cfe2ff;--bs-table-border-color:#bacbe6;--bs-table-striped-bg:#c5d7f2;--bs-table-striped-color:#000;--bs-table-active-bg:#bacbe6;--bs-table-active-color:#000;--bs-table-hover-bg:#bfd1ec;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-secondary{--bs-table-color:#000;--bs-table-bg:#e2e3e5;--bs-table-border-color:#cbccce;--bs-table-striped-bg:#d7d8da;--bs-table-striped-color:#000;--bs-table-active-bg:#cbccce;--bs-table-active-color:#000;--bs-table-hover-bg:#d1d2d4;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-success{--bs-table-color:#000;--bs-table-bg:#d1e7dd;--bs-table-border-color:#bcd0c7;--bs-table-striped-bg:#c7dbd2;--bs-table-striped-color:#000;--bs-table-active-bg:#bcd0c7;--bs-table-active-color:#000;--bs-table-hover-bg:#c1d6cc;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-info{--bs-table-color:#000;--bs-table-bg:#cff4fc;--bs-table-border-color:#badce3;--bs-table-striped-bg:#c5e8ef;--bs-table-striped-color:#000;--bs-table-active-bg:#badce3;--bs-table-active-color:#000;--bs-table-hover-bg:#bfe2e9;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-warning{--bs-table-color:#000;--bs-table-bg:#fff3cd;--bs-table-border-color:#e6dbb9;--bs-table-striped-bg:#f2e7c3;--bs-table-striped-color:#000;--bs-table-active-bg:#e6dbb9;--bs-table-active-color:#000;--bs-table-hover-bg:#ece1be;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-danger{--bs-table-color:#000;--bs-table-bg:#f8d7da;--bs-table-border-color:#dfc2c4;--bs-table-striped-bg:#eccccf;--bs-table-striped-color:#000;--bs-table-active-bg:#dfc2c4;--bs-table-active-color:#000;--bs-table-hover-bg:#e5c7ca;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-light{--bs-table-color:#000;--bs-table-bg:#f8f9fa;--bs-table-border-color:#dfe0e1;--bs-table-striped-bg:#ecedee;--bs-table-striped-color:#000;--bs-table-active-bg:#dfe0e1;--bs-table-active-color:#000;--bs-table-hover-bg:#e5e6e7;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-dark{--bs-table-color:#fff;--bs-table-bg:#212529;--bs-table-border-color:#373b3e;--bs-table-striped-bg:#2c3034;--bs-table-striped-color:#fff;--bs-table-active-bg:#373b3e;--bs-table-active-color:#fff;--bs-table-hover-bg:#323539;--bs-table-hover-color:#fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}@media (max-width:575.98px){.table-responsive-sm{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:767.98px){.table-responsive-md{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:991.98px){.table-responsive-lg{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1199.98px){.table-responsive-xl{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1399.98px){.table-responsive-xxl{overflow-x:auto;-webkit-overflow-scrolling:touch}}.form-label{margin-bottom:.5rem}.col-form-label{padding-top:calc(.375rem + var(--bs-border-width));padding-bottom:calc(.375rem + var(--bs-border-width));margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + var(--bs-border-width));padding-bottom:calc(.5rem + var(--bs-border-width));font-size:1.25rem}.col-form-label-sm{padding-top:calc(.25rem + var(--bs-border-width));padding-bottom:calc(.25rem + var(--bs-border-width));font-size:.875rem}.form-text{margin-top:.25rem;font-size:.875em;color:var(--bs-secondary-color)}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);background-color:var(--bs-body-bg);background-clip:padding-box;border:var(--bs-border-width) solid var(--bs-border-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;border-radius:var(--bs-border-radius);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:var(--bs-body-color);background-color:var(--bs-body-bg);border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-control::-webkit-date-and-time-value{min-width:85px;height:1.5em;margin:0}.form-control::-webkit-datetime-edit{display:block;padding:0}.form-control::-moz-placeholder{color:var(--bs-secondary-color);opacity:1}.form-control::placeholder{color:var(--bs-secondary-color);opacity:1}.form-control:disabled{background-color:var(--bs-secondary-bg);opacity:1}.form-control::-webkit-file-upload-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:var(--bs-secondary-bg)}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:var(--bs-secondary-bg)}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;line-height:1.5;color:var(--bs-body-color);background-color:transparent;border:solid transparent;border-width:var(--bs-border-width) 0}.form-control-plaintext:focus{outline:0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{min-height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2));padding:.25rem .5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.form-control-sm::-webkit-file-upload-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-sm::file-selector-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-lg{min-height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2));padding:.5rem 1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}.form-control-lg::-webkit-file-upload-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}textarea.form-control{min-height:calc(1.5em + .75rem + calc(var(--bs-border-width) * 2))}textarea.form-control-sm{min-height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2))}textarea.form-control-lg{min-height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2))}.form-control-color{width:3rem;height:calc(1.5em + .75rem + calc(var(--bs-border-width) * 2));padding:.375rem}.form-control-color:not(:disabled):not([readonly]){cursor:pointer}.form-control-color::-moz-color-swatch{border:0!important;border-radius:var(--bs-border-radius)}.form-control-color::-webkit-color-swatch{border:0!important;border-radius:var(--bs-border-radius)}.form-control-color.form-control-sm{height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2))}.form-control-color.form-control-lg{height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2))}.form-select{--bs-form-select-bg-img:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);background-color:var(--bs-body-bg);background-image:var(--bs-form-select-bg-img),var(--bs-form-select-bg-icon,none);background-repeat:no-repeat;background-position:right .75rem center;background-size:16px 12px;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-select{transition:none}}.form-select:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:.75rem;background-image:none}.form-select:disabled{background-color:var(--bs-secondary-bg)}.form-select:-moz-focusring{color:transparent;text-shadow:0 0 0 var(--bs-body-color)}.form-select-sm{padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.form-select-lg{padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}[data-bs-theme=dark] .form-select{--bs-form-select-bg-img:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23adb5bd' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e")}.form-check{display:block;min-height:1.5rem;padding-left:1.5em;margin-bottom:.125rem}.form-check .form-check-input{float:left;margin-left:-1.5em}.form-check-reverse{padding-right:1.5em;padding-left:0;text-align:right}.form-check-reverse .form-check-input{float:right;margin-right:-1.5em;margin-left:0}.form-check-input{--bs-form-check-bg:var(--bs-body-bg);width:1em;height:1em;margin-top:.25em;vertical-align:top;background-color:var(--bs-form-check-bg);background-image:var(--bs-form-check-bg-image);background-repeat:no-repeat;background-position:center;background-size:contain;border:var(--bs-border-width) solid var(--bs-border-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;-webkit-print-color-adjust:exact;color-adjust:exact;print-color-adjust:exact}.form-check-input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio]{border-radius:50%}.form-check-input:active{filter:brightness(90%)}.form-check-input:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-check-input:checked{background-color:#0d6efd;border-color:#0d6efd}.form-check-input:checked[type=checkbox]{--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e")}.form-check-input:checked[type=radio]{--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}.form-check-input[type=checkbox]:indeterminate{background-color:#0d6efd;border-color:#0d6efd;--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}.form-check-input:disabled{pointer-events:none;filter:none;opacity:.5}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{cursor:default;opacity:.5}.form-switch{padding-left:2.5em}.form-switch .form-check-input{--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");width:2em;margin-left:-2.5em;background-image:var(--bs-form-switch-bg);background-position:left center;border-radius:2em;transition:background-position .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-switch .form-check-input{transition:none}}.form-switch .form-check-input:focus{--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2386b7fe'/%3e%3c/svg%3e")}.form-switch .form-check-input:checked{background-position:right center;--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.form-switch.form-check-reverse{padding-right:2.5em;padding-left:0}.form-switch.form-check-reverse .form-check-input{margin-right:-2.5em;margin-left:0}.form-check-inline{display:inline-block;margin-right:1rem}.btn-check{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.btn-check:disabled+.btn,.btn-check[disabled]+.btn{pointer-events:none;filter:none;opacity:.65}[data-bs-theme=dark] .form-switch .form-check-input:not(:checked):not(:focus){--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%28255, 255, 255, 0.25%29'/%3e%3c/svg%3e")}.form-range{width:100%;height:1.5rem;padding:0;background-color:transparent;-webkit-appearance:none;-moz-appearance:none;appearance:none}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;background-color:#0d6efd;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.form-range::-webkit-slider-thumb:active{background-color:#b6d4fe}.form-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:var(--bs-tertiary-bg);border-color:transparent;border-radius:1rem}.form-range::-moz-range-thumb{width:1rem;height:1rem;background-color:#0d6efd;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-moz-range-thumb{-moz-transition:none;transition:none}}.form-range::-moz-range-thumb:active{background-color:#b6d4fe}.form-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:var(--bs-tertiary-bg);border-color:transparent;border-radius:1rem}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:var(--bs-secondary-color)}.form-range:disabled::-moz-range-thumb{background-color:var(--bs-secondary-color)}.form-floating{position:relative}.form-floating>.form-control,.form-floating>.form-control-plaintext,.form-floating>.form-select{height:calc(3.5rem + calc(var(--bs-border-width) * 2));min-height:calc(3.5rem + calc(var(--bs-border-width) * 2));line-height:1.25}.form-floating>label{position:absolute;top:0;left:0;z-index:2;height:100%;padding:1rem .75rem;overflow:hidden;text-align:start;text-overflow:ellipsis;white-space:nowrap;pointer-events:none;border:var(--bs-border-width) solid transparent;transform-origin:0 0;transition:opacity .1s ease-in-out,transform .1s ease-in-out}@media (prefers-reduced-motion:reduce){.form-floating>label{transition:none}}.form-floating>.form-control,.form-floating>.form-control-plaintext{padding:1rem .75rem}.form-floating>.form-control-plaintext::-moz-placeholder,.form-floating>.form-control::-moz-placeholder{color:transparent}.form-floating>.form-control-plaintext::placeholder,.form-floating>.form-control::placeholder{color:transparent}.form-floating>.form-control-plaintext:not(:-moz-placeholder-shown),.form-floating>.form-control:not(:-moz-placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:focus,.form-floating>.form-control-plaintext:not(:placeholder-shown),.form-floating>.form-control:focus,.form-floating>.form-control:not(:placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:-webkit-autofill,.form-floating>.form-control:-webkit-autofill{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-select{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:not(:-moz-placeholder-shown)~label{color:rgba(var(--bs-body-color-rgb),.65);transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control-plaintext~label,.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label,.form-floating>.form-select~label{color:rgba(var(--bs-body-color-rgb),.65);transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:not(:-moz-placeholder-shown)~label::after{position:absolute;inset:1rem 0.375rem;z-index:-1;height:1.5em;content:"";background-color:var(--bs-body-bg);border-radius:var(--bs-border-radius)}.form-floating>.form-control-plaintext~label::after,.form-floating>.form-control:focus~label::after,.form-floating>.form-control:not(:placeholder-shown)~label::after,.form-floating>.form-select~label::after{position:absolute;inset:1rem 0.375rem;z-index:-1;height:1.5em;content:"";background-color:var(--bs-body-bg);border-radius:var(--bs-border-radius)}.form-floating>.form-control:-webkit-autofill~label{color:rgba(var(--bs-body-color-rgb),.65);transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control-plaintext~label{border-width:var(--bs-border-width) 0}.form-floating>:disabled~label{color:#6c757d}.form-floating>:disabled~label::after{background-color:var(--bs-secondary-bg)}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-floating,.input-group>.form-select{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group>.form-control:focus,.input-group>.form-floating:focus-within,.input-group>.form-select:focus{z-index:5}.input-group .btn{position:relative;z-index:2}.input-group .btn:focus{z-index:5}.input-group-text{display:flex;align-items:center;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);text-align:center;white-space:nowrap;background-color:var(--bs-tertiary-bg);border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius)}.input-group-lg>.btn,.input-group-lg>.form-control,.input-group-lg>.form-select,.input-group-lg>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}.input-group-sm>.btn,.input-group-sm>.form-control,.input-group-sm>.form-select,.input-group-sm>.input-group-text{padding:.25rem .5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.input-group-lg>.form-select,.input-group-sm>.form-select{padding-right:3rem}.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3),.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-control,.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-select,.input-group:not(.has-validation)>:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>.dropdown-toggle:nth-last-child(n+4),.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-control,.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-select,.input-group.has-validation>:nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback){margin-left:calc(var(--bs-border-width) * -1);border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.form-floating:not(:first-child)>.form-control,.input-group>.form-floating:not(:first-child)>.form-select{border-top-left-radius:0;border-bottom-left-radius:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:var(--bs-form-valid-color)}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:var(--bs-success);border-radius:var(--bs-border-radius)}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:var(--bs-form-valid-border-color);padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:var(--bs-form-valid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-valid,.was-validated .form-select:valid{border-color:var(--bs-form-valid-border-color)}.form-select.is-valid:not([multiple]):not([size]),.form-select.is-valid:not([multiple])[size="1"],.was-validated .form-select:valid:not([multiple]):not([size]),.was-validated .form-select:valid:not([multiple])[size="1"]{--bs-form-select-bg-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-valid:focus,.was-validated .form-select:valid:focus{border-color:var(--bs-form-valid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.form-control-color.is-valid,.was-validated .form-control-color:valid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-valid,.was-validated .form-check-input:valid{border-color:var(--bs-form-valid-border-color)}.form-check-input.is-valid:checked,.was-validated .form-check-input:valid:checked{background-color:var(--bs-form-valid-color)}.form-check-input.is-valid:focus,.was-validated .form-check-input:valid:focus{box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:var(--bs-form-valid-color)}.form-check-inline .form-check-input~.valid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-valid,.input-group>.form-floating:not(:focus-within).is-valid,.input-group>.form-select:not(:focus).is-valid,.was-validated .input-group>.form-control:not(:focus):valid,.was-validated .input-group>.form-floating:not(:focus-within):valid,.was-validated .input-group>.form-select:not(:focus):valid{z-index:3}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:var(--bs-form-invalid-color)}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:var(--bs-danger);border-radius:var(--bs-border-radius)}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:var(--bs-form-invalid-border-color);padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:var(--bs-form-invalid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-invalid,.was-validated .form-select:invalid{border-color:var(--bs-form-invalid-border-color)}.form-select.is-invalid:not([multiple]):not([size]),.form-select.is-invalid:not([multiple])[size="1"],.was-validated .form-select:invalid:not([multiple]):not([size]),.was-validated .form-select:invalid:not([multiple])[size="1"]{--bs-form-select-bg-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-invalid:focus,.was-validated .form-select:invalid:focus{border-color:var(--bs-form-invalid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.form-control-color.is-invalid,.was-validated .form-control-color:invalid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-invalid,.was-validated .form-check-input:invalid{border-color:var(--bs-form-invalid-border-color)}.form-check-input.is-invalid:checked,.was-validated .form-check-input:invalid:checked{background-color:var(--bs-form-invalid-color)}.form-check-input.is-invalid:focus,.was-validated .form-check-input:invalid:focus{box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:var(--bs-form-invalid-color)}.form-check-inline .form-check-input~.invalid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-invalid,.input-group>.form-floating:not(:focus-within).is-invalid,.input-group>.form-select:not(:focus).is-invalid,.was-validated .input-group>.form-control:not(:focus):invalid,.was-validated .input-group>.form-floating:not(:focus-within):invalid,.was-validated .input-group>.form-select:not(:focus):invalid{z-index:4}.btn{--bs-btn-padding-x:0.75rem;--bs-btn-padding-y:0.375rem;--bs-btn-font-family: ;--bs-btn-font-size:1rem;--bs-btn-font-weight:400;--bs-btn-line-height:1.5;--bs-btn-color:var(--bs-body-color);--bs-btn-bg:transparent;--bs-btn-border-width:var(--bs-border-width);--bs-btn-border-color:transparent;--bs-btn-border-radius:var(--bs-border-radius);--bs-btn-hover-border-color:transparent;--bs-btn-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.15),0 1px 1px rgba(0, 0, 0, 0.075);--bs-btn-disabled-opacity:0.65;--bs-btn-focus-box-shadow:0 0 0 0.25rem rgba(var(--bs-btn-focus-shadow-rgb), .5);display:inline-block;padding:var(--bs-btn-padding-y) var(--bs-btn-padding-x);font-family:var(--bs-btn-font-family);font-size:var(--bs-btn-font-size);font-weight:var(--bs-btn-font-weight);line-height:var(--bs-btn-line-height);color:var(--bs-btn-color);text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;border:var(--bs-btn-border-width) solid var(--bs-btn-border-color);border-radius:var(--bs-btn-border-radius);background-color:var(--bs-btn-bg);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color)}.btn-check+.btn:hover{color:var(--bs-btn-color);background-color:var(--bs-btn-bg);border-color:var(--bs-btn-border-color)}.btn:focus-visible{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:focus-visible+.btn{border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked+.btn,.btn.active,.btn.show,.btn:first-child:active,:not(.btn-check)+.btn:active{color:var(--bs-btn-active-color);background-color:var(--bs-btn-active-bg);border-color:var(--bs-btn-active-border-color)}.btn-check:checked+.btn:focus-visible,.btn.active:focus-visible,.btn.show:focus-visible,.btn:first-child:active:focus-visible,:not(.btn-check)+.btn:active:focus-visible{box-shadow:var(--bs-btn-focus-box-shadow)}.btn.disabled,.btn:disabled,fieldset:disabled .btn{color:var(--bs-btn-disabled-color);pointer-events:none;background-color:var(--bs-btn-disabled-bg);border-color:var(--bs-btn-disabled-border-color);opacity:var(--bs-btn-disabled-opacity)}.btn-primary{--bs-btn-color:#fff;--bs-btn-bg:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0b5ed7;--bs-btn-hover-border-color:#0a58ca;--bs-btn-focus-shadow-rgb:49,132,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0a58ca;--bs-btn-active-border-color:#0a53be;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#0d6efd;--bs-btn-disabled-border-color:#0d6efd}.btn-secondary{--bs-btn-color:#fff;--bs-btn-bg:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#5c636a;--bs-btn-hover-border-color:#565e64;--bs-btn-focus-shadow-rgb:130,138,145;--bs-btn-active-color:#fff;--bs-btn-active-bg:#565e64;--bs-btn-active-border-color:#51585e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#6c757d;--bs-btn-disabled-border-color:#6c757d}.btn-success{--bs-btn-color:#fff;--bs-btn-bg:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#157347;--bs-btn-hover-border-color:#146c43;--bs-btn-focus-shadow-rgb:60,153,110;--bs-btn-active-color:#fff;--bs-btn-active-bg:#146c43;--bs-btn-active-border-color:#13653f;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#198754;--bs-btn-disabled-border-color:#198754}.btn-info{--bs-btn-color:#000;--bs-btn-bg:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#31d2f2;--bs-btn-hover-border-color:#25cff2;--bs-btn-focus-shadow-rgb:11,172,204;--bs-btn-active-color:#000;--bs-btn-active-bg:#3dd5f3;--bs-btn-active-border-color:#25cff2;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#0dcaf0;--bs-btn-disabled-border-color:#0dcaf0}.btn-warning{--bs-btn-color:#000;--bs-btn-bg:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffca2c;--bs-btn-hover-border-color:#ffc720;--bs-btn-focus-shadow-rgb:217,164,6;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffcd39;--bs-btn-active-border-color:#ffc720;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#ffc107;--bs-btn-disabled-border-color:#ffc107}.btn-danger{--bs-btn-color:#fff;--bs-btn-bg:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#bb2d3b;--bs-btn-hover-border-color:#b02a37;--bs-btn-focus-shadow-rgb:225,83,97;--bs-btn-active-color:#fff;--bs-btn-active-bg:#b02a37;--bs-btn-active-border-color:#a52834;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#dc3545;--bs-btn-disabled-border-color:#dc3545}.btn-light{--bs-btn-color:#000;--bs-btn-bg:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#d3d4d5;--bs-btn-hover-border-color:#c6c7c8;--bs-btn-focus-shadow-rgb:211,212,213;--bs-btn-active-color:#000;--bs-btn-active-bg:#c6c7c8;--bs-btn-active-border-color:#babbbc;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#f8f9fa;--bs-btn-disabled-border-color:#f8f9fa}.btn-dark{--bs-btn-color:#fff;--bs-btn-bg:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#424649;--bs-btn-hover-border-color:#373b3e;--bs-btn-focus-shadow-rgb:66,70,73;--bs-btn-active-color:#fff;--bs-btn-active-bg:#4d5154;--bs-btn-active-border-color:#373b3e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#212529;--bs-btn-disabled-border-color:#212529}.btn-outline-primary{--bs-btn-color:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0d6efd;--bs-btn-hover-border-color:#0d6efd;--bs-btn-focus-shadow-rgb:13,110,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0d6efd;--bs-btn-active-border-color:#0d6efd;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0d6efd;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0d6efd;--bs-gradient:none}.btn-outline-secondary{--bs-btn-color:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#6c757d;--bs-btn-hover-border-color:#6c757d;--bs-btn-focus-shadow-rgb:108,117,125;--bs-btn-active-color:#fff;--bs-btn-active-bg:#6c757d;--bs-btn-active-border-color:#6c757d;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#6c757d;--bs-gradient:none}.btn-outline-success{--bs-btn-color:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#198754;--bs-btn-hover-border-color:#198754;--bs-btn-focus-shadow-rgb:25,135,84;--bs-btn-active-color:#fff;--bs-btn-active-bg:#198754;--bs-btn-active-border-color:#198754;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#198754;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#198754;--bs-gradient:none}.btn-outline-info{--bs-btn-color:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#0dcaf0;--bs-btn-hover-border-color:#0dcaf0;--bs-btn-focus-shadow-rgb:13,202,240;--bs-btn-active-color:#000;--bs-btn-active-bg:#0dcaf0;--bs-btn-active-border-color:#0dcaf0;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0dcaf0;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0dcaf0;--bs-gradient:none}.btn-outline-warning{--bs-btn-color:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffc107;--bs-btn-hover-border-color:#ffc107;--bs-btn-focus-shadow-rgb:255,193,7;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffc107;--bs-btn-active-border-color:#ffc107;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#ffc107;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#ffc107;--bs-gradient:none}.btn-outline-danger{--bs-btn-color:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#dc3545;--bs-btn-hover-border-color:#dc3545;--bs-btn-focus-shadow-rgb:220,53,69;--bs-btn-active-color:#fff;--bs-btn-active-bg:#dc3545;--bs-btn-active-border-color:#dc3545;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#dc3545;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#dc3545;--bs-gradient:none}.btn-outline-light{--bs-btn-color:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#f8f9fa;--bs-btn-hover-border-color:#f8f9fa;--bs-btn-focus-shadow-rgb:248,249,250;--bs-btn-active-color:#000;--bs-btn-active-bg:#f8f9fa;--bs-btn-active-border-color:#f8f9fa;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#f8f9fa;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#f8f9fa;--bs-gradient:none}.btn-outline-dark{--bs-btn-color:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#212529;--bs-btn-hover-border-color:#212529;--bs-btn-focus-shadow-rgb:33,37,41;--bs-btn-active-color:#fff;--bs-btn-active-bg:#212529;--bs-btn-active-border-color:#212529;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#212529;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#212529;--bs-gradient:none}.btn-link{--bs-btn-font-weight:400;--bs-btn-color:var(--bs-link-color);--bs-btn-bg:transparent;--bs-btn-border-color:transparent;--bs-btn-hover-color:var(--bs-link-hover-color);--bs-btn-hover-border-color:transparent;--bs-btn-active-color:var(--bs-link-hover-color);--bs-btn-active-border-color:transparent;--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-border-color:transparent;--bs-btn-box-shadow:0 0 0 #000;--bs-btn-focus-shadow-rgb:49,132,253;text-decoration:underline}.btn-link:focus-visible{color:var(--bs-btn-color)}.btn-link:hover{color:var(--bs-btn-hover-color)}.btn-group-lg>.btn,.btn-lg{--bs-btn-padding-y:0.5rem;--bs-btn-padding-x:1rem;--bs-btn-font-size:1.25rem;--bs-btn-border-radius:var(--bs-border-radius-lg)}.btn-group-sm>.btn,.btn-sm{--bs-btn-padding-y:0.25rem;--bs-btn-padding-x:0.5rem;--bs-btn-font-size:0.875rem;--bs-btn-border-radius:var(--bs-border-radius-sm)}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media (prefers-reduced-motion:reduce){.collapsing.collapse-horizontal{transition:none}}.dropdown,.dropdown-center,.dropend,.dropstart,.dropup,.dropup-center{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{--bs-dropdown-zindex:1000;--bs-dropdown-min-width:10rem;--bs-dropdown-padding-x:0;--bs-dropdown-padding-y:0.5rem;--bs-dropdown-spacer:0.125rem;--bs-dropdown-font-size:1rem;--bs-dropdown-color:var(--bs-body-color);--bs-dropdown-bg:var(--bs-body-bg);--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-border-radius:var(--bs-border-radius);--bs-dropdown-border-width:var(--bs-border-width);--bs-dropdown-inner-border-radius:calc(var(--bs-border-radius) - var(--bs-border-width));--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-divider-margin-y:0.5rem;--bs-dropdown-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-dropdown-link-color:var(--bs-body-color);--bs-dropdown-link-hover-color:var(--bs-body-color);--bs-dropdown-link-hover-bg:var(--bs-tertiary-bg);--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:var(--bs-tertiary-color);--bs-dropdown-item-padding-x:1rem;--bs-dropdown-item-padding-y:0.25rem;--bs-dropdown-header-color:#6c757d;--bs-dropdown-header-padding-x:1rem;--bs-dropdown-header-padding-y:0.5rem;position:absolute;z-index:var(--bs-dropdown-zindex);display:none;min-width:var(--bs-dropdown-min-width);padding:var(--bs-dropdown-padding-y) var(--bs-dropdown-padding-x);margin:0;font-size:var(--bs-dropdown-font-size);color:var(--bs-dropdown-color);text-align:left;list-style:none;background-color:var(--bs-dropdown-bg);background-clip:padding-box;border:var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color);border-radius:var(--bs-dropdown-border-radius)}.dropdown-menu[data-bs-popper]{top:100%;left:0;margin-top:var(--bs-dropdown-spacer)}.dropdown-menu-start{--bs-position:start}.dropdown-menu-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-end{--bs-position:end}.dropdown-menu-end[data-bs-popper]{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-start{--bs-position:start}.dropdown-menu-sm-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-sm-end{--bs-position:end}.dropdown-menu-sm-end[data-bs-popper]{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-start{--bs-position:start}.dropdown-menu-md-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-md-end{--bs-position:end}.dropdown-menu-md-end[data-bs-popper]{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-start{--bs-position:start}.dropdown-menu-lg-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-lg-end{--bs-position:end}.dropdown-menu-lg-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-start{--bs-position:start}.dropdown-menu-xl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xl-end{--bs-position:end}.dropdown-menu-xl-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1400px){.dropdown-menu-xxl-start{--bs-position:start}.dropdown-menu-xxl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xxl-end{--bs-position:end}.dropdown-menu-xxl-end[data-bs-popper]{right:0;left:auto}}.dropup .dropdown-menu[data-bs-popper]{top:auto;bottom:100%;margin-top:0;margin-bottom:var(--bs-dropdown-spacer)}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-menu[data-bs-popper]{top:0;right:auto;left:100%;margin-top:0;margin-left:var(--bs-dropdown-spacer)}.dropend .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropend .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-toggle::after{vertical-align:0}.dropstart .dropdown-menu[data-bs-popper]{top:0;right:100%;left:auto;margin-top:0;margin-right:var(--bs-dropdown-spacer)}.dropstart .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropstart .dropdown-toggle::after{display:none}.dropstart .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropstart .dropdown-toggle:empty::after{margin-left:0}.dropstart .dropdown-toggle::before{vertical-align:0}.dropdown-divider{height:0;margin:var(--bs-dropdown-divider-margin-y) 0;overflow:hidden;border-top:1px solid var(--bs-dropdown-divider-bg);opacity:1}.dropdown-item{display:block;width:100%;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);clear:both;font-weight:400;color:var(--bs-dropdown-link-color);text-align:inherit;text-decoration:none;white-space:nowrap;background-color:transparent;border:0;border-radius:var(--bs-dropdown-item-border-radius,0)}.dropdown-item:focus,.dropdown-item:hover{color:var(--bs-dropdown-link-hover-color);background-color:var(--bs-dropdown-link-hover-bg)}.dropdown-item.active,.dropdown-item:active{color:var(--bs-dropdown-link-active-color);text-decoration:none;background-color:var(--bs-dropdown-link-active-bg)}.dropdown-item.disabled,.dropdown-item:disabled{color:var(--bs-dropdown-link-disabled-color);pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:var(--bs-dropdown-header-padding-y) var(--bs-dropdown-header-padding-x);margin-bottom:0;font-size:.875rem;color:var(--bs-dropdown-header-color);white-space:nowrap}.dropdown-item-text{display:block;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);color:var(--bs-dropdown-link-color)}.dropdown-menu-dark{--bs-dropdown-color:#dee2e6;--bs-dropdown-bg:#343a40;--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-box-shadow: ;--bs-dropdown-link-color:#dee2e6;--bs-dropdown-link-hover-color:#fff;--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-link-hover-bg:rgba(255, 255, 255, 0.15);--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:#adb5bd;--bs-dropdown-header-color:#adb5bd}.btn-group,.btn-group-vertical{position:relative;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;flex:1 1 auto}.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn-check:checked+.btn,.btn-group>.btn-check:focus+.btn,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group{border-radius:var(--bs-border-radius)}.btn-group>.btn-group:not(:first-child),.btn-group>:not(.btn-check:first-child)+.btn{margin-left:calc(var(--bs-border-width) * -1)}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn.dropdown-toggle-split:first-child,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:nth-child(n+3),.btn-group>:not(.btn-check)+.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropend .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropstart .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{flex-direction:column;align-items:flex-start;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:calc(var(--bs-border-width) * -1)}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn~.btn{border-top-left-radius:0;border-top-right-radius:0}.nav{--bs-nav-link-padding-x:1rem;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-link-color);--bs-nav-link-hover-color:var(--bs-link-hover-color);--bs-nav-link-disabled-color:var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x);font-size:var(--bs-nav-link-font-size);font-weight:var(--bs-nav-link-font-weight);color:var(--bs-nav-link-color);text-decoration:none;background:0 0;border:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{color:var(--bs-nav-link-hover-color)}.nav-link:focus-visible{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.nav-link.disabled{color:var(--bs-nav-link-disabled-color);pointer-events:none;cursor:default}.nav-tabs{--bs-nav-tabs-border-width:var(--bs-border-width);--bs-nav-tabs-border-color:var(--bs-border-color);--bs-nav-tabs-border-radius:var(--bs-border-radius);--bs-nav-tabs-link-hover-border-color:var(--bs-secondary-bg) var(--bs-secondary-bg) var(--bs-border-color);--bs-nav-tabs-link-active-color:var(--bs-emphasis-color);--bs-nav-tabs-link-active-bg:var(--bs-body-bg);--bs-nav-tabs-link-active-border-color:var(--bs-border-color) var(--bs-border-color) var(--bs-body-bg);border-bottom:var(--bs-nav-tabs-border-width) solid var(--bs-nav-tabs-border-color)}.nav-tabs .nav-link{margin-bottom:calc(-1 * var(--bs-nav-tabs-border-width));border:var(--bs-nav-tabs-border-width) solid transparent;border-top-left-radius:var(--bs-nav-tabs-border-radius);border-top-right-radius:var(--bs-nav-tabs-border-radius)}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{isolation:isolate;border-color:var(--bs-nav-tabs-link-hover-border-color)}.nav-tabs .nav-link.disabled,.nav-tabs .nav-link:disabled{color:var(--bs-nav-link-disabled-color);background-color:transparent;border-color:transparent}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:var(--bs-nav-tabs-link-active-color);background-color:var(--bs-nav-tabs-link-active-bg);border-color:var(--bs-nav-tabs-link-active-border-color)}.nav-tabs .dropdown-menu{margin-top:calc(-1 * var(--bs-nav-tabs-border-width));border-top-left-radius:0;border-top-right-radius:0}.nav-pills{--bs-nav-pills-border-radius:var(--bs-border-radius);--bs-nav-pills-link-active-color:#fff;--bs-nav-pills-link-active-bg:#0d6efd}.nav-pills .nav-link{border-radius:var(--bs-nav-pills-border-radius)}.nav-pills .nav-link:disabled{color:var(--bs-nav-link-disabled-color);background-color:transparent;border-color:transparent}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:var(--bs-nav-pills-link-active-color);background-color:var(--bs-nav-pills-link-active-bg)}.nav-underline{--bs-nav-underline-gap:1rem;--bs-nav-underline-border-width:0.125rem;--bs-nav-underline-link-active-color:var(--bs-emphasis-color);gap:var(--bs-nav-underline-gap)}.nav-underline .nav-link{padding-right:0;padding-left:0;border-bottom:var(--bs-nav-underline-border-width) solid transparent}.nav-underline .nav-link:focus,.nav-underline .nav-link:hover{border-bottom-color:currentcolor}.nav-underline .nav-link.active,.nav-underline .show>.nav-link{font-weight:700;color:var(--bs-nav-underline-link-active-color);border-bottom-color:currentcolor}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-basis:0;flex-grow:1;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{--bs-navbar-padding-x:0;--bs-navbar-padding-y:0.5rem;--bs-navbar-color:rgba(var(--bs-emphasis-color-rgb), 0.65);--bs-navbar-hover-color:rgba(var(--bs-emphasis-color-rgb), 0.8);--bs-navbar-disabled-color:rgba(var(--bs-emphasis-color-rgb), 0.3);--bs-navbar-active-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-padding-y:0.3125rem;--bs-navbar-brand-margin-end:1rem;--bs-navbar-brand-font-size:1.25rem;--bs-navbar-brand-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-hover-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-nav-link-padding-x:0.5rem;--bs-navbar-toggler-padding-y:0.25rem;--bs-navbar-toggler-padding-x:0.75rem;--bs-navbar-toggler-font-size:1.25rem;--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%2833, 37, 41, 0.75%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");--bs-navbar-toggler-border-color:rgba(var(--bs-emphasis-color-rgb), 0.15);--bs-navbar-toggler-border-radius:var(--bs-border-radius);--bs-navbar-toggler-focus-width:0.25rem;--bs-navbar-toggler-transition:box-shadow 0.15s ease-in-out;position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding:var(--bs-navbar-padding-y) var(--bs-navbar-padding-x)}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg,.navbar>.container-md,.navbar>.container-sm,.navbar>.container-xl,.navbar>.container-xxl{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:var(--bs-navbar-brand-padding-y);padding-bottom:var(--bs-navbar-brand-padding-y);margin-right:var(--bs-navbar-brand-margin-end);font-size:var(--bs-navbar-brand-font-size);color:var(--bs-navbar-brand-color);text-decoration:none;white-space:nowrap}.navbar-brand:focus,.navbar-brand:hover{color:var(--bs-navbar-brand-hover-color)}.navbar-nav{--bs-nav-link-padding-x:0;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-navbar-color);--bs-nav-link-hover-color:var(--bs-navbar-hover-color);--bs-nav-link-disabled-color:var(--bs-navbar-disabled-color);display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link.active,.navbar-nav .nav-link.show{color:var(--bs-navbar-active-color)}.navbar-nav .dropdown-menu{position:static}.navbar-text{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-navbar-color)}.navbar-text a,.navbar-text a:focus,.navbar-text a:hover{color:var(--bs-navbar-active-color)}.navbar-collapse{flex-basis:100%;flex-grow:1;align-items:center}.navbar-toggler{padding:var(--bs-navbar-toggler-padding-y) var(--bs-navbar-toggler-padding-x);font-size:var(--bs-navbar-toggler-font-size);line-height:1;color:var(--bs-navbar-color);background-color:transparent;border:var(--bs-border-width) solid var(--bs-navbar-toggler-border-color);border-radius:var(--bs-navbar-toggler-border-radius);transition:var(--bs-navbar-toggler-transition)}@media (prefers-reduced-motion:reduce){.navbar-toggler{transition:none}}.navbar-toggler:hover{text-decoration:none}.navbar-toggler:focus{text-decoration:none;outline:0;box-shadow:0 0 0 var(--bs-navbar-toggler-focus-width)}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;background-image:var(--bs-navbar-toggler-icon-bg);background-repeat:no-repeat;background-position:center;background-size:100%}.navbar-nav-scroll{max-height:var(--bs-scroll-height,75vh);overflow-y:auto}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-sm .offcanvas .offcanvas-header{display:none}.navbar-expand-sm .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:768px){.navbar-expand-md{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-md .offcanvas .offcanvas-header{display:none}.navbar-expand-md .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-lg .offcanvas .offcanvas-header{display:none}.navbar-expand-lg .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1200px){.navbar-expand-xl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xl .offcanvas .offcanvas-header{display:none}.navbar-expand-xl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1400px){.navbar-expand-xxl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xxl .navbar-nav{flex-direction:row}.navbar-expand-xxl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xxl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xxl .navbar-nav-scroll{overflow:visible}.navbar-expand-xxl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}.navbar-expand-xxl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xxl .offcanvas .offcanvas-header{display:none}.navbar-expand-xxl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar-expand{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand .offcanvas .offcanvas-header{display:none}.navbar-expand .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}.navbar-dark,.navbar[data-bs-theme=dark]{--bs-navbar-color:rgba(255, 255, 255, 0.55);--bs-navbar-hover-color:rgba(255, 255, 255, 0.75);--bs-navbar-disabled-color:rgba(255, 255, 255, 0.25);--bs-navbar-active-color:#fff;--bs-navbar-brand-color:#fff;--bs-navbar-brand-hover-color:#fff;--bs-navbar-toggler-border-color:rgba(255, 255, 255, 0.1);--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}[data-bs-theme=dark] .navbar-toggler-icon{--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.card{--bs-card-spacer-y:1rem;--bs-card-spacer-x:1rem;--bs-card-title-spacer-y:0.5rem;--bs-card-title-color: ;--bs-card-subtitle-color: ;--bs-card-border-width:var(--bs-border-width);--bs-card-border-color:var(--bs-border-color-translucent);--bs-card-border-radius:var(--bs-border-radius);--bs-card-box-shadow: ;--bs-card-inner-border-radius:calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-card-cap-padding-y:0.5rem;--bs-card-cap-padding-x:1rem;--bs-card-cap-bg:rgba(var(--bs-body-color-rgb), 0.03);--bs-card-cap-color: ;--bs-card-height: ;--bs-card-color: ;--bs-card-bg:var(--bs-body-bg);--bs-card-img-overlay-padding:1rem;--bs-card-group-margin:0.75rem;position:relative;display:flex;flex-direction:column;min-width:0;height:var(--bs-card-height);color:var(--bs-body-color);word-wrap:break-word;background-color:var(--bs-card-bg);background-clip:border-box;border:var(--bs-card-border-width) solid var(--bs-card-border-color);border-radius:var(--bs-card-border-radius)}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;padding:var(--bs-card-spacer-y) var(--bs-card-spacer-x);color:var(--bs-card-color)}.card-title{margin-bottom:var(--bs-card-title-spacer-y);color:var(--bs-card-title-color)}.card-subtitle{margin-top:calc(-.5 * var(--bs-card-title-spacer-y));margin-bottom:0;color:var(--bs-card-subtitle-color)}.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:var(--bs-card-spacer-x)}.card-header{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);margin-bottom:0;color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-bottom:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-header:first-child{border-radius:var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius) 0 0}.card-footer{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-top:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-footer:last-child{border-radius:0 0 var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius)}.card-header-tabs{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-bottom:calc(-1 * var(--bs-card-cap-padding-y));margin-left:calc(-.5 * var(--bs-card-cap-padding-x));border-bottom:0}.card-header-tabs .nav-link.active{background-color:var(--bs-card-bg);border-bottom-color:var(--bs-card-bg)}.card-header-pills{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-left:calc(-.5 * var(--bs-card-cap-padding-x))}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:var(--bs-card-img-overlay-padding);border-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom,.card-img-top{width:100%}.card-img,.card-img-top{border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom{border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card-group>.card{margin-bottom:var(--bs-card-group-margin)}@media (min-width:576px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.accordion{--bs-accordion-color:var(--bs-body-color);--bs-accordion-bg:var(--bs-body-bg);--bs-accordion-transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out,border-radius 0.15s ease;--bs-accordion-border-color:var(--bs-border-color);--bs-accordion-border-width:var(--bs-border-width);--bs-accordion-border-radius:var(--bs-border-radius);--bs-accordion-inner-border-radius:calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-accordion-btn-padding-x:1.25rem;--bs-accordion-btn-padding-y:1rem;--bs-accordion-btn-color:var(--bs-body-color);--bs-accordion-btn-bg:var(--bs-accordion-bg);--bs-accordion-btn-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23212529'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");--bs-accordion-btn-icon-width:1.25rem;--bs-accordion-btn-icon-transform:rotate(-180deg);--bs-accordion-btn-icon-transition:transform 0.2s ease-in-out;--bs-accordion-btn-active-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23052c65'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");--bs-accordion-btn-focus-border-color:#86b7fe;--bs-accordion-btn-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-accordion-body-padding-x:1.25rem;--bs-accordion-body-padding-y:1rem;--bs-accordion-active-color:var(--bs-primary-text-emphasis);--bs-accordion-active-bg:var(--bs-primary-bg-subtle)}.accordion-button{position:relative;display:flex;align-items:center;width:100%;padding:var(--bs-accordion-btn-padding-y) var(--bs-accordion-btn-padding-x);font-size:1rem;color:var(--bs-accordion-btn-color);text-align:left;background-color:var(--bs-accordion-btn-bg);border:0;border-radius:0;overflow-anchor:none;transition:var(--bs-accordion-transition)}@media (prefers-reduced-motion:reduce){.accordion-button{transition:none}}.accordion-button:not(.collapsed){color:var(--bs-accordion-active-color);background-color:var(--bs-accordion-active-bg);box-shadow:inset 0 calc(-1 * var(--bs-accordion-border-width)) 0 var(--bs-accordion-border-color)}.accordion-button:not(.collapsed)::after{background-image:var(--bs-accordion-btn-active-icon);transform:var(--bs-accordion-btn-icon-transform)}.accordion-button::after{flex-shrink:0;width:var(--bs-accordion-btn-icon-width);height:var(--bs-accordion-btn-icon-width);margin-left:auto;content:"";background-image:var(--bs-accordion-btn-icon);background-repeat:no-repeat;background-size:var(--bs-accordion-btn-icon-width);transition:var(--bs-accordion-btn-icon-transition)}@media (prefers-reduced-motion:reduce){.accordion-button::after{transition:none}}.accordion-button:hover{z-index:2}.accordion-button:focus{z-index:3;border-color:var(--bs-accordion-btn-focus-border-color);outline:0;box-shadow:var(--bs-accordion-btn-focus-box-shadow)}.accordion-header{margin-bottom:0}.accordion-item{color:var(--bs-accordion-color);background-color:var(--bs-accordion-bg);border:var(--bs-accordion-border-width) solid var(--bs-accordion-border-color)}.accordion-item:first-of-type{border-top-left-radius:var(--bs-accordion-border-radius);border-top-right-radius:var(--bs-accordion-border-radius)}.accordion-item:first-of-type .accordion-button{border-top-left-radius:var(--bs-accordion-inner-border-radius);border-top-right-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:not(:first-of-type){border-top:0}.accordion-item:last-of-type{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-item:last-of-type .accordion-button.collapsed{border-bottom-right-radius:var(--bs-accordion-inner-border-radius);border-bottom-left-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:last-of-type .accordion-collapse{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-body{padding:var(--bs-accordion-body-padding-y) var(--bs-accordion-body-padding-x)}.accordion-flush .accordion-collapse{border-width:0}.accordion-flush .accordion-item{border-right:0;border-left:0;border-radius:0}.accordion-flush .accordion-item:first-child{border-top:0}.accordion-flush .accordion-item:last-child{border-bottom:0}.accordion-flush .accordion-item .accordion-button,.accordion-flush .accordion-item .accordion-button.collapsed{border-radius:0}[data-bs-theme=dark] .accordion-button::after{--bs-accordion-btn-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");--bs-accordion-btn-active-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.breadcrumb{--bs-breadcrumb-padding-x:0;--bs-breadcrumb-padding-y:0;--bs-breadcrumb-margin-bottom:1rem;--bs-breadcrumb-bg: ;--bs-breadcrumb-border-radius: ;--bs-breadcrumb-divider-color:var(--bs-secondary-color);--bs-breadcrumb-item-padding-x:0.5rem;--bs-breadcrumb-item-active-color:var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding:var(--bs-breadcrumb-padding-y) var(--bs-breadcrumb-padding-x);margin-bottom:var(--bs-breadcrumb-margin-bottom);font-size:var(--bs-breadcrumb-font-size);list-style:none;background-color:var(--bs-breadcrumb-bg);border-radius:var(--bs-breadcrumb-border-radius)}.breadcrumb-item+.breadcrumb-item{padding-left:var(--bs-breadcrumb-item-padding-x)}.breadcrumb-item+.breadcrumb-item::before{float:left;padding-right:var(--bs-breadcrumb-item-padding-x);color:var(--bs-breadcrumb-divider-color);content:var(--bs-breadcrumb-divider, "/")}.breadcrumb-item.active{color:var(--bs-breadcrumb-item-active-color)}.pagination{--bs-pagination-padding-x:0.75rem;--bs-pagination-padding-y:0.375rem;--bs-pagination-font-size:1rem;--bs-pagination-color:var(--bs-link-color);--bs-pagination-bg:var(--bs-body-bg);--bs-pagination-border-width:var(--bs-border-width);--bs-pagination-border-color:var(--bs-border-color);--bs-pagination-border-radius:var(--bs-border-radius);--bs-pagination-hover-color:var(--bs-link-hover-color);--bs-pagination-hover-bg:var(--bs-tertiary-bg);--bs-pagination-hover-border-color:var(--bs-border-color);--bs-pagination-focus-color:var(--bs-link-hover-color);--bs-pagination-focus-bg:var(--bs-secondary-bg);--bs-pagination-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-pagination-active-color:#fff;--bs-pagination-active-bg:#0d6efd;--bs-pagination-active-border-color:#0d6efd;--bs-pagination-disabled-color:var(--bs-secondary-color);--bs-pagination-disabled-bg:var(--bs-secondary-bg);--bs-pagination-disabled-border-color:var(--bs-border-color);display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;padding:var(--bs-pagination-padding-y) var(--bs-pagination-padding-x);font-size:var(--bs-pagination-font-size);color:var(--bs-pagination-color);text-decoration:none;background-color:var(--bs-pagination-bg);border:var(--bs-pagination-border-width) solid var(--bs-pagination-border-color);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:var(--bs-pagination-hover-color);background-color:var(--bs-pagination-hover-bg);border-color:var(--bs-pagination-hover-border-color)}.page-link:focus{z-index:3;color:var(--bs-pagination-focus-color);background-color:var(--bs-pagination-focus-bg);outline:0;box-shadow:var(--bs-pagination-focus-box-shadow)}.active>.page-link,.page-link.active{z-index:3;color:var(--bs-pagination-active-color);background-color:var(--bs-pagination-active-bg);border-color:var(--bs-pagination-active-border-color)}.disabled>.page-link,.page-link.disabled{color:var(--bs-pagination-disabled-color);pointer-events:none;background-color:var(--bs-pagination-disabled-bg);border-color:var(--bs-pagination-disabled-border-color)}.page-item:not(:first-child) .page-link{margin-left:calc(var(--bs-border-width) * -1)}.page-item:first-child .page-link{border-top-left-radius:var(--bs-pagination-border-radius);border-bottom-left-radius:var(--bs-pagination-border-radius)}.page-item:last-child .page-link{border-top-right-radius:var(--bs-pagination-border-radius);border-bottom-right-radius:var(--bs-pagination-border-radius)}.pagination-lg{--bs-pagination-padding-x:1.5rem;--bs-pagination-padding-y:0.75rem;--bs-pagination-font-size:1.25rem;--bs-pagination-border-radius:var(--bs-border-radius-lg)}.pagination-sm{--bs-pagination-padding-x:0.5rem;--bs-pagination-padding-y:0.25rem;--bs-pagination-font-size:0.875rem;--bs-pagination-border-radius:var(--bs-border-radius-sm)}.badge{--bs-badge-padding-x:0.65em;--bs-badge-padding-y:0.35em;--bs-badge-font-size:0.75em;--bs-badge-font-weight:700;--bs-badge-color:#fff;--bs-badge-border-radius:var(--bs-border-radius);display:inline-block;padding:var(--bs-badge-padding-y) var(--bs-badge-padding-x);font-size:var(--bs-badge-font-size);font-weight:var(--bs-badge-font-weight);line-height:1;color:var(--bs-badge-color);text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:var(--bs-badge-border-radius)}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.alert{--bs-alert-bg:transparent;--bs-alert-padding-x:1rem;--bs-alert-padding-y:1rem;--bs-alert-margin-bottom:1rem;--bs-alert-color:inherit;--bs-alert-border-color:transparent;--bs-alert-border:var(--bs-border-width) solid var(--bs-alert-border-color);--bs-alert-border-radius:var(--bs-border-radius);--bs-alert-link-color:inherit;position:relative;padding:var(--bs-alert-padding-y) var(--bs-alert-padding-x);margin-bottom:var(--bs-alert-margin-bottom);color:var(--bs-alert-color);background-color:var(--bs-alert-bg);border:var(--bs-alert-border);border-radius:var(--bs-alert-border-radius)}.alert-heading{color:inherit}.alert-link{font-weight:700;color:var(--bs-alert-link-color)}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;z-index:2;padding:1.25rem 1rem}.alert-primary{--bs-alert-color:var(--bs-primary-text-emphasis);--bs-alert-bg:var(--bs-primary-bg-subtle);--bs-alert-border-color:var(--bs-primary-border-subtle);--bs-alert-link-color:var(--bs-primary-text-emphasis)}.alert-secondary{--bs-alert-color:var(--bs-secondary-text-emphasis);--bs-alert-bg:var(--bs-secondary-bg-subtle);--bs-alert-border-color:var(--bs-secondary-border-subtle);--bs-alert-link-color:var(--bs-secondary-text-emphasis)}.alert-success{--bs-alert-color:var(--bs-success-text-emphasis);--bs-alert-bg:var(--bs-success-bg-subtle);--bs-alert-border-color:var(--bs-success-border-subtle);--bs-alert-link-color:var(--bs-success-text-emphasis)}.alert-info{--bs-alert-color:var(--bs-info-text-emphasis);--bs-alert-bg:var(--bs-info-bg-subtle);--bs-alert-border-color:var(--bs-info-border-subtle);--bs-alert-link-color:var(--bs-info-text-emphasis)}.alert-warning{--bs-alert-color:var(--bs-warning-text-emphasis);--bs-alert-bg:var(--bs-warning-bg-subtle);--bs-alert-border-color:var(--bs-warning-border-subtle);--bs-alert-link-color:var(--bs-warning-text-emphasis)}.alert-danger{--bs-alert-color:var(--bs-danger-text-emphasis);--bs-alert-bg:var(--bs-danger-bg-subtle);--bs-alert-border-color:var(--bs-danger-border-subtle);--bs-alert-link-color:var(--bs-danger-text-emphasis)}.alert-light{--bs-alert-color:var(--bs-light-text-emphasis);--bs-alert-bg:var(--bs-light-bg-subtle);--bs-alert-border-color:var(--bs-light-border-subtle);--bs-alert-link-color:var(--bs-light-text-emphasis)}.alert-dark{--bs-alert-color:var(--bs-dark-text-emphasis);--bs-alert-bg:var(--bs-dark-bg-subtle);--bs-alert-border-color:var(--bs-dark-border-subtle);--bs-alert-link-color:var(--bs-dark-text-emphasis)}@keyframes progress-bar-stripes{0%{background-position-x:1rem}}.progress,.progress-stacked{--bs-progress-height:1rem;--bs-progress-font-size:0.75rem;--bs-progress-bg:var(--bs-secondary-bg);--bs-progress-border-radius:var(--bs-border-radius);--bs-progress-box-shadow:var(--bs-box-shadow-inset);--bs-progress-bar-color:#fff;--bs-progress-bar-bg:#0d6efd;--bs-progress-bar-transition:width 0.6s ease;display:flex;height:var(--bs-progress-height);overflow:hidden;font-size:var(--bs-progress-font-size);background-color:var(--bs-progress-bg);border-radius:var(--bs-progress-border-radius)}.progress-bar{display:flex;flex-direction:column;justify-content:center;overflow:hidden;color:var(--bs-progress-bar-color);text-align:center;white-space:nowrap;background-color:var(--bs-progress-bar-bg);transition:var(--bs-progress-bar-transition)}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:var(--bs-progress-height) var(--bs-progress-height)}.progress-stacked>.progress{overflow:visible}.progress-stacked>.progress>.progress-bar{width:100%}.progress-bar-animated{animation:1s linear infinite progress-bar-stripes}@media (prefers-reduced-motion:reduce){.progress-bar-animated{animation:none}}.list-group{--bs-list-group-color:var(--bs-body-color);--bs-list-group-bg:var(--bs-body-bg);--bs-list-group-border-color:var(--bs-border-color);--bs-list-group-border-width:var(--bs-border-width);--bs-list-group-border-radius:var(--bs-border-radius);--bs-list-group-item-padding-x:1rem;--bs-list-group-item-padding-y:0.5rem;--bs-list-group-action-color:var(--bs-secondary-color);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-tertiary-bg);--bs-list-group-action-active-color:var(--bs-body-color);--bs-list-group-action-active-bg:var(--bs-secondary-bg);--bs-list-group-disabled-color:var(--bs-secondary-color);--bs-list-group-disabled-bg:var(--bs-body-bg);--bs-list-group-active-color:#fff;--bs-list-group-active-bg:#0d6efd;--bs-list-group-active-border-color:#0d6efd;display:flex;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:var(--bs-list-group-border-radius)}.list-group-numbered{list-style-type:none;counter-reset:section}.list-group-numbered>.list-group-item::before{content:counters(section, ".") ". ";counter-increment:section}.list-group-item-action{width:100%;color:var(--bs-list-group-action-color);text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{z-index:1;color:var(--bs-list-group-action-hover-color);text-decoration:none;background-color:var(--bs-list-group-action-hover-bg)}.list-group-item-action:active{color:var(--bs-list-group-action-active-color);background-color:var(--bs-list-group-action-active-bg)}.list-group-item{position:relative;display:block;padding:var(--bs-list-group-item-padding-y) var(--bs-list-group-item-padding-x);color:var(--bs-list-group-color);text-decoration:none;background-color:var(--bs-list-group-bg);border:var(--bs-list-group-border-width) solid var(--bs-list-group-border-color)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:var(--bs-list-group-disabled-color);pointer-events:none;background-color:var(--bs-list-group-disabled-bg)}.list-group-item.active{z-index:2;color:var(--bs-list-group-active-color);background-color:var(--bs-list-group-active-bg);border-color:var(--bs-list-group-active-border-color)}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:calc(-1 * var(--bs-list-group-border-width));border-top-width:var(--bs-list-group-border-width)}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}@media (min-width:576px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:768px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:992px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1200px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1400px){.list-group-horizontal-xxl{flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 var(--bs-list-group-border-width)}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{--bs-list-group-color:var(--bs-primary-text-emphasis);--bs-list-group-bg:var(--bs-primary-bg-subtle);--bs-list-group-border-color:var(--bs-primary-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-primary-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-primary-border-subtle);--bs-list-group-active-color:var(--bs-primary-bg-subtle);--bs-list-group-active-bg:var(--bs-primary-text-emphasis);--bs-list-group-active-border-color:var(--bs-primary-text-emphasis)}.list-group-item-secondary{--bs-list-group-color:var(--bs-secondary-text-emphasis);--bs-list-group-bg:var(--bs-secondary-bg-subtle);--bs-list-group-border-color:var(--bs-secondary-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-secondary-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-secondary-border-subtle);--bs-list-group-active-color:var(--bs-secondary-bg-subtle);--bs-list-group-active-bg:var(--bs-secondary-text-emphasis);--bs-list-group-active-border-color:var(--bs-secondary-text-emphasis)}.list-group-item-success{--bs-list-group-color:var(--bs-success-text-emphasis);--bs-list-group-bg:var(--bs-success-bg-subtle);--bs-list-group-border-color:var(--bs-success-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-success-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-success-border-subtle);--bs-list-group-active-color:var(--bs-success-bg-subtle);--bs-list-group-active-bg:var(--bs-success-text-emphasis);--bs-list-group-active-border-color:var(--bs-success-text-emphasis)}.list-group-item-info{--bs-list-group-color:var(--bs-info-text-emphasis);--bs-list-group-bg:var(--bs-info-bg-subtle);--bs-list-group-border-color:var(--bs-info-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-info-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-info-border-subtle);--bs-list-group-active-color:var(--bs-info-bg-subtle);--bs-list-group-active-bg:var(--bs-info-text-emphasis);--bs-list-group-active-border-color:var(--bs-info-text-emphasis)}.list-group-item-warning{--bs-list-group-color:var(--bs-warning-text-emphasis);--bs-list-group-bg:var(--bs-warning-bg-subtle);--bs-list-group-border-color:var(--bs-warning-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-warning-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-warning-border-subtle);--bs-list-group-active-color:var(--bs-warning-bg-subtle);--bs-list-group-active-bg:var(--bs-warning-text-emphasis);--bs-list-group-active-border-color:var(--bs-warning-text-emphasis)}.list-group-item-danger{--bs-list-group-color:var(--bs-danger-text-emphasis);--bs-list-group-bg:var(--bs-danger-bg-subtle);--bs-list-group-border-color:var(--bs-danger-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-danger-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-danger-border-subtle);--bs-list-group-active-color:var(--bs-danger-bg-subtle);--bs-list-group-active-bg:var(--bs-danger-text-emphasis);--bs-list-group-active-border-color:var(--bs-danger-text-emphasis)}.list-group-item-light{--bs-list-group-color:var(--bs-light-text-emphasis);--bs-list-group-bg:var(--bs-light-bg-subtle);--bs-list-group-border-color:var(--bs-light-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-light-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-light-border-subtle);--bs-list-group-active-color:var(--bs-light-bg-subtle);--bs-list-group-active-bg:var(--bs-light-text-emphasis);--bs-list-group-active-border-color:var(--bs-light-text-emphasis)}.list-group-item-dark{--bs-list-group-color:var(--bs-dark-text-emphasis);--bs-list-group-bg:var(--bs-dark-bg-subtle);--bs-list-group-border-color:var(--bs-dark-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-dark-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-dark-border-subtle);--bs-list-group-active-color:var(--bs-dark-bg-subtle);--bs-list-group-active-bg:var(--bs-dark-text-emphasis);--bs-list-group-active-border-color:var(--bs-dark-text-emphasis)}.btn-close{--bs-btn-close-color:#000;--bs-btn-close-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e");--bs-btn-close-opacity:0.5;--bs-btn-close-hover-opacity:0.75;--bs-btn-close-focus-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-btn-close-focus-opacity:1;--bs-btn-close-disabled-opacity:0.25;--bs-btn-close-white-filter:invert(1) grayscale(100%) brightness(200%);box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:var(--bs-btn-close-color);background:transparent var(--bs-btn-close-bg) center/1em auto no-repeat;border:0;border-radius:.375rem;opacity:var(--bs-btn-close-opacity)}.btn-close:hover{color:var(--bs-btn-close-color);text-decoration:none;opacity:var(--bs-btn-close-hover-opacity)}.btn-close:focus{outline:0;box-shadow:var(--bs-btn-close-focus-shadow);opacity:var(--bs-btn-close-focus-opacity)}.btn-close.disabled,.btn-close:disabled{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;opacity:var(--bs-btn-close-disabled-opacity)}.btn-close-white{filter:var(--bs-btn-close-white-filter)}[data-bs-theme=dark] .btn-close{filter:var(--bs-btn-close-white-filter)}.toast{--bs-toast-zindex:1090;--bs-toast-padding-x:0.75rem;--bs-toast-padding-y:0.5rem;--bs-toast-spacing:1.5rem;--bs-toast-max-width:350px;--bs-toast-font-size:0.875rem;--bs-toast-color: ;--bs-toast-bg:rgba(var(--bs-body-bg-rgb), 0.85);--bs-toast-border-width:var(--bs-border-width);--bs-toast-border-color:var(--bs-border-color-translucent);--bs-toast-border-radius:var(--bs-border-radius);--bs-toast-box-shadow:var(--bs-box-shadow);--bs-toast-header-color:var(--bs-secondary-color);--bs-toast-header-bg:rgba(var(--bs-body-bg-rgb), 0.85);--bs-toast-header-border-color:var(--bs-border-color-translucent);width:var(--bs-toast-max-width);max-width:100%;font-size:var(--bs-toast-font-size);color:var(--bs-toast-color);pointer-events:auto;background-color:var(--bs-toast-bg);background-clip:padding-box;border:var(--bs-toast-border-width) solid var(--bs-toast-border-color);box-shadow:var(--bs-toast-box-shadow);border-radius:var(--bs-toast-border-radius)}.toast.showing{opacity:0}.toast:not(.show){display:none}.toast-container{--bs-toast-zindex:1090;position:absolute;z-index:var(--bs-toast-zindex);width:-webkit-max-content;width:-moz-max-content;width:max-content;max-width:100%;pointer-events:none}.toast-container>:not(:last-child){margin-bottom:var(--bs-toast-spacing)}.toast-header{display:flex;align-items:center;padding:var(--bs-toast-padding-y) var(--bs-toast-padding-x);color:var(--bs-toast-header-color);background-color:var(--bs-toast-header-bg);background-clip:padding-box;border-bottom:var(--bs-toast-border-width) solid var(--bs-toast-header-border-color);border-top-left-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width));border-top-right-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width))}.toast-header .btn-close{margin-right:calc(-.5 * var(--bs-toast-padding-x));margin-left:var(--bs-toast-padding-x)}.toast-body{padding:var(--bs-toast-padding-x);word-wrap:break-word}.modal{--bs-modal-zindex:1055;--bs-modal-width:500px;--bs-modal-padding:1rem;--bs-modal-margin:0.5rem;--bs-modal-color: ;--bs-modal-bg:var(--bs-body-bg);--bs-modal-border-color:var(--bs-border-color-translucent);--bs-modal-border-width:var(--bs-border-width);--bs-modal-border-radius:var(--bs-border-radius-lg);--bs-modal-box-shadow:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-modal-inner-border-radius:calc(var(--bs-border-radius-lg) - (var(--bs-border-width)));--bs-modal-header-padding-x:1rem;--bs-modal-header-padding-y:1rem;--bs-modal-header-padding:1rem 1rem;--bs-modal-header-border-color:var(--bs-border-color);--bs-modal-header-border-width:var(--bs-border-width);--bs-modal-title-line-height:1.5;--bs-modal-footer-gap:0.5rem;--bs-modal-footer-bg: ;--bs-modal-footer-border-color:var(--bs-border-color);--bs-modal-footer-border-width:var(--bs-border-width);position:fixed;top:0;left:0;z-index:var(--bs-modal-zindex);display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:var(--bs-modal-margin);pointer-events:none}.modal.fade .modal-dialog{transition:transform .3s ease-out;transform:translate(0,-50px)}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{height:calc(100% - var(--bs-modal-margin) * 2)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:flex;align-items:center;min-height:calc(100% - var(--bs-modal-margin) * 2)}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;color:var(--bs-modal-color);pointer-events:auto;background-color:var(--bs-modal-bg);background-clip:padding-box;border:var(--bs-modal-border-width) solid var(--bs-modal-border-color);border-radius:var(--bs-modal-border-radius);outline:0}.modal-backdrop{--bs-backdrop-zindex:1050;--bs-backdrop-bg:#000;--bs-backdrop-opacity:0.5;position:fixed;top:0;left:0;z-index:var(--bs-backdrop-zindex);width:100vw;height:100vh;background-color:var(--bs-backdrop-bg)}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:var(--bs-backdrop-opacity)}.modal-header{display:flex;flex-shrink:0;align-items:center;justify-content:space-between;padding:var(--bs-modal-header-padding);border-bottom:var(--bs-modal-header-border-width) solid var(--bs-modal-header-border-color);border-top-left-radius:var(--bs-modal-inner-border-radius);border-top-right-radius:var(--bs-modal-inner-border-radius)}.modal-header .btn-close{padding:calc(var(--bs-modal-header-padding-y) * .5) calc(var(--bs-modal-header-padding-x) * .5);margin:calc(-.5 * var(--bs-modal-header-padding-y)) calc(-.5 * var(--bs-modal-header-padding-x)) calc(-.5 * var(--bs-modal-header-padding-y)) auto}.modal-title{margin-bottom:0;line-height:var(--bs-modal-title-line-height)}.modal-body{position:relative;flex:1 1 auto;padding:var(--bs-modal-padding)}.modal-footer{display:flex;flex-shrink:0;flex-wrap:wrap;align-items:center;justify-content:flex-end;padding:calc(var(--bs-modal-padding) - var(--bs-modal-footer-gap) * .5);background-color:var(--bs-modal-footer-bg);border-top:var(--bs-modal-footer-border-width) solid var(--bs-modal-footer-border-color);border-bottom-right-radius:var(--bs-modal-inner-border-radius);border-bottom-left-radius:var(--bs-modal-inner-border-radius)}.modal-footer>*{margin:calc(var(--bs-modal-footer-gap) * .5)}@media (min-width:576px){.modal{--bs-modal-margin:1.75rem;--bs-modal-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15)}.modal-dialog{max-width:var(--bs-modal-width);margin-right:auto;margin-left:auto}.modal-sm{--bs-modal-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{--bs-modal-width:800px}}@media (min-width:1200px){.modal-xl{--bs-modal-width:1140px}}.modal-fullscreen{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen .modal-footer,.modal-fullscreen .modal-header{border-radius:0}.modal-fullscreen .modal-body{overflow-y:auto}@media (max-width:575.98px){.modal-fullscreen-sm-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-sm-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-sm-down .modal-footer,.modal-fullscreen-sm-down .modal-header{border-radius:0}.modal-fullscreen-sm-down .modal-body{overflow-y:auto}}@media (max-width:767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-footer,.modal-fullscreen-md-down .modal-header{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}}@media (max-width:991.98px){.modal-fullscreen-lg-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-lg-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-lg-down .modal-footer,.modal-fullscreen-lg-down .modal-header{border-radius:0}.modal-fullscreen-lg-down .modal-body{overflow-y:auto}}@media (max-width:1199.98px){.modal-fullscreen-xl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xl-down .modal-footer,.modal-fullscreen-xl-down .modal-header{border-radius:0}.modal-fullscreen-xl-down .modal-body{overflow-y:auto}}@media (max-width:1399.98px){.modal-fullscreen-xxl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xxl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xxl-down .modal-footer,.modal-fullscreen-xxl-down .modal-header{border-radius:0}.modal-fullscreen-xxl-down .modal-body{overflow-y:auto}}.tooltip{--bs-tooltip-zindex:1080;--bs-tooltip-max-width:200px;--bs-tooltip-padding-x:0.5rem;--bs-tooltip-padding-y:0.25rem;--bs-tooltip-margin: ;--bs-tooltip-font-size:0.875rem;--bs-tooltip-color:var(--bs-body-bg);--bs-tooltip-bg:var(--bs-emphasis-color);--bs-tooltip-border-radius:var(--bs-border-radius);--bs-tooltip-opacity:0.9;--bs-tooltip-arrow-width:0.8rem;--bs-tooltip-arrow-height:0.4rem;z-index:var(--bs-tooltip-zindex);display:block;margin:var(--bs-tooltip-margin);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-tooltip-font-size);word-wrap:break-word;opacity:0}.tooltip.show{opacity:var(--bs-tooltip-opacity)}.tooltip .tooltip-arrow{display:block;width:var(--bs-tooltip-arrow-width);height:var(--bs-tooltip-arrow-height)}.tooltip .tooltip-arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow,.bs-tooltip-top .tooltip-arrow{bottom:calc(-1 * var(--bs-tooltip-arrow-height))}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,.bs-tooltip-top .tooltip-arrow::before{top:-1px;border-width:var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-top-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow,.bs-tooltip-end .tooltip-arrow{left:calc(-1 * var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before,.bs-tooltip-end .tooltip-arrow::before{right:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-right-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow,.bs-tooltip-bottom .tooltip-arrow{top:calc(-1 * var(--bs-tooltip-arrow-height))}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before,.bs-tooltip-bottom .tooltip-arrow::before{bottom:-1px;border-width:0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-bottom-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow,.bs-tooltip-start .tooltip-arrow{right:calc(-1 * var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before,.bs-tooltip-start .tooltip-arrow::before{left:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) 0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-left-color:var(--bs-tooltip-bg)}.tooltip-inner{max-width:var(--bs-tooltip-max-width);padding:var(--bs-tooltip-padding-y) var(--bs-tooltip-padding-x);color:var(--bs-tooltip-color);text-align:center;background-color:var(--bs-tooltip-bg);border-radius:var(--bs-tooltip-border-radius)}.popover{--bs-popover-zindex:1070;--bs-popover-max-width:276px;--bs-popover-font-size:0.875rem;--bs-popover-bg:var(--bs-body-bg);--bs-popover-border-width:var(--bs-border-width);--bs-popover-border-color:var(--bs-border-color-translucent);--bs-popover-border-radius:var(--bs-border-radius-lg);--bs-popover-inner-border-radius:calc(var(--bs-border-radius-lg) - var(--bs-border-width));--bs-popover-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-popover-header-padding-x:1rem;--bs-popover-header-padding-y:0.5rem;--bs-popover-header-font-size:1rem;--bs-popover-header-color:inherit;--bs-popover-header-bg:var(--bs-secondary-bg);--bs-popover-body-padding-x:1rem;--bs-popover-body-padding-y:1rem;--bs-popover-body-color:var(--bs-body-color);--bs-popover-arrow-width:1rem;--bs-popover-arrow-height:0.5rem;--bs-popover-arrow-border:var(--bs-popover-border-color);z-index:var(--bs-popover-zindex);display:block;max-width:var(--bs-popover-max-width);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-popover-font-size);word-wrap:break-word;background-color:var(--bs-popover-bg);background-clip:padding-box;border:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-radius:var(--bs-popover-border-radius)}.popover .popover-arrow{display:block;width:var(--bs-popover-arrow-width);height:var(--bs-popover-arrow-height)}.popover .popover-arrow::after,.popover .popover-arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid;border-width:0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow,.bs-popover-top>.popover-arrow{bottom:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::after,.bs-popover-top>.popover-arrow::before{border-width:var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::before{bottom:0;border-top-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-top>.popover-arrow::after{bottom:var(--bs-popover-border-width);border-top-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow,.bs-popover-end>.popover-arrow{left:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::after,.bs-popover-end>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::before{left:0;border-right-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-end>.popover-arrow::after{left:var(--bs-popover-border-width);border-right-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow,.bs-popover-bottom>.popover-arrow{top:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::before{border-width:0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::before{top:0;border-bottom-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::after{top:var(--bs-popover-border-width);border-bottom-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:var(--bs-popover-arrow-width);margin-left:calc(-.5 * var(--bs-popover-arrow-width));content:"";border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-header-bg)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow,.bs-popover-start>.popover-arrow{right:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::after,.bs-popover-start>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) 0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::before{right:0;border-left-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-start>.popover-arrow::after{right:var(--bs-popover-border-width);border-left-color:var(--bs-popover-bg)}.popover-header{padding:var(--bs-popover-header-padding-y) var(--bs-popover-header-padding-x);margin-bottom:0;font-size:var(--bs-popover-header-font-size);color:var(--bs-popover-header-color);background-color:var(--bs-popover-header-bg);border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-top-left-radius:var(--bs-popover-inner-border-radius);border-top-right-radius:var(--bs-popover-inner-border-radius)}.popover-header:empty{display:none}.popover-body{padding:var(--bs-popover-body-padding-y) var(--bs-popover-body-padding-x);color:var(--bs-popover-body-color)}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-end,.carousel-item-next:not(.carousel-item-start){transform:translateX(100%)}.active.carousel-item-start,.carousel-item-prev:not(.carousel-item-end){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;transform:none}.carousel-fade .carousel-item-next.carousel-item-start,.carousel-fade .carousel-item-prev.carousel-item-end,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:flex;align-items:center;justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:0 0;border:0;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:2rem;height:2rem;background-repeat:no-repeat;background-position:50%;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:2;display:flex;justify-content:center;padding:0;margin-right:15%;margin-bottom:1rem;margin-left:15%}.carousel-indicators [data-bs-target]{box-sizing:content-box;flex:0 1 auto;width:30px;height:3px;padding:0;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border:0;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators [data-bs-target]{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:1.25rem;left:15%;padding-top:1.25rem;padding-bottom:1.25rem;color:#fff;text-align:center}.carousel-dark .carousel-control-next-icon,.carousel-dark .carousel-control-prev-icon{filter:invert(1) grayscale(100)}.carousel-dark .carousel-indicators [data-bs-target]{background-color:#000}.carousel-dark .carousel-caption{color:#000}[data-bs-theme=dark] .carousel .carousel-control-next-icon,[data-bs-theme=dark] .carousel .carousel-control-prev-icon,[data-bs-theme=dark].carousel .carousel-control-next-icon,[data-bs-theme=dark].carousel .carousel-control-prev-icon{filter:invert(1) grayscale(100)}[data-bs-theme=dark] .carousel .carousel-indicators [data-bs-target],[data-bs-theme=dark].carousel .carousel-indicators [data-bs-target]{background-color:#000}[data-bs-theme=dark] .carousel .carousel-caption,[data-bs-theme=dark].carousel .carousel-caption{color:#000}.spinner-border,.spinner-grow{display:inline-block;width:var(--bs-spinner-width);height:var(--bs-spinner-height);vertical-align:var(--bs-spinner-vertical-align);border-radius:50%;animation:var(--bs-spinner-animation-speed) linear infinite var(--bs-spinner-animation-name)}@keyframes spinner-border{to{transform:rotate(360deg)}}.spinner-border{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-border-width:0.25em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-border;border:var(--bs-spinner-border-width) solid currentcolor;border-right-color:transparent}.spinner-border-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem;--bs-spinner-border-width:0.2em}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-grow;background-color:currentcolor;opacity:0}.spinner-grow-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{--bs-spinner-animation-speed:1.5s}}.offcanvas,.offcanvas-lg,.offcanvas-md,.offcanvas-sm,.offcanvas-xl,.offcanvas-xxl{--bs-offcanvas-zindex:1045;--bs-offcanvas-width:400px;--bs-offcanvas-height:30vh;--bs-offcanvas-padding-x:1rem;--bs-offcanvas-padding-y:1rem;--bs-offcanvas-color:var(--bs-body-color);--bs-offcanvas-bg:var(--bs-body-bg);--bs-offcanvas-border-width:var(--bs-border-width);--bs-offcanvas-border-color:var(--bs-border-color-translucent);--bs-offcanvas-box-shadow:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-offcanvas-transition:transform 0.3s ease-in-out;--bs-offcanvas-title-line-height:1.5}@media (max-width:575.98px){.offcanvas-sm{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:575.98px) and (prefers-reduced-motion:reduce){.offcanvas-sm{transition:none}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-sm.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-sm.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-sm.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-sm.show:not(.hiding),.offcanvas-sm.showing{transform:none}.offcanvas-sm.hiding,.offcanvas-sm.show,.offcanvas-sm.showing{visibility:visible}}@media (min-width:576px){.offcanvas-sm{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-sm .offcanvas-header{display:none}.offcanvas-sm .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:767.98px){.offcanvas-md{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:767.98px) and (prefers-reduced-motion:reduce){.offcanvas-md{transition:none}}@media (max-width:767.98px){.offcanvas-md.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-md.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-md.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-md.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-md.show:not(.hiding),.offcanvas-md.showing{transform:none}.offcanvas-md.hiding,.offcanvas-md.show,.offcanvas-md.showing{visibility:visible}}@media (min-width:768px){.offcanvas-md{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-md .offcanvas-header{display:none}.offcanvas-md .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:991.98px){.offcanvas-lg{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:991.98px) and (prefers-reduced-motion:reduce){.offcanvas-lg{transition:none}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-lg.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-lg.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-lg.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-lg.show:not(.hiding),.offcanvas-lg.showing{transform:none}.offcanvas-lg.hiding,.offcanvas-lg.show,.offcanvas-lg.showing{visibility:visible}}@media (min-width:992px){.offcanvas-lg{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-lg .offcanvas-header{display:none}.offcanvas-lg .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1199.98px){.offcanvas-xl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:1199.98px) and (prefers-reduced-motion:reduce){.offcanvas-xl{transition:none}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xl.show:not(.hiding),.offcanvas-xl.showing{transform:none}.offcanvas-xl.hiding,.offcanvas-xl.show,.offcanvas-xl.showing{visibility:visible}}@media (min-width:1200px){.offcanvas-xl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xl .offcanvas-header{display:none}.offcanvas-xl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1399.98px){.offcanvas-xxl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:1399.98px) and (prefers-reduced-motion:reduce){.offcanvas-xxl{transition:none}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xxl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xxl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xxl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xxl.show:not(.hiding),.offcanvas-xxl.showing{transform:none}.offcanvas-xxl.hiding,.offcanvas-xxl.show,.offcanvas-xxl.showing{visibility:visible}}@media (min-width:1400px){.offcanvas-xxl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xxl .offcanvas-header{display:none}.offcanvas-xxl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}.offcanvas{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}@media (prefers-reduced-motion:reduce){.offcanvas{transition:none}}.offcanvas.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas.show:not(.hiding),.offcanvas.showing{transform:none}.offcanvas.hiding,.offcanvas.show,.offcanvas.showing{visibility:visible}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;align-items:center;justify-content:space-between;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x)}.offcanvas-header .btn-close{padding:calc(var(--bs-offcanvas-padding-y) * .5) calc(var(--bs-offcanvas-padding-x) * .5);margin-top:calc(-.5 * var(--bs-offcanvas-padding-y));margin-right:calc(-.5 * var(--bs-offcanvas-padding-x));margin-bottom:calc(-.5 * var(--bs-offcanvas-padding-y))}.offcanvas-title{margin-bottom:0;line-height:var(--bs-offcanvas-title-line-height)}.offcanvas-body{flex-grow:1;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x);overflow-y:auto}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentcolor;opacity:.5}.placeholder.btn::before{display:inline-block;content:""}.placeholder-xs{min-height:.6em}.placeholder-sm{min-height:.8em}.placeholder-lg{min-height:1.2em}.placeholder-glow .placeholder{animation:placeholder-glow 2s ease-in-out infinite}@keyframes placeholder-glow{50%{opacity:.2}}.placeholder-wave{-webkit-mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);-webkit-mask-size:200% 100%;mask-size:200% 100%;animation:placeholder-wave 2s linear infinite}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.clearfix::after{display:block;clear:both;content:""}.text-bg-primary{color:#fff!important;background-color:RGBA(13,110,253,var(--bs-bg-opacity,1))!important}.text-bg-secondary{color:#fff!important;background-color:RGBA(108,117,125,var(--bs-bg-opacity,1))!important}.text-bg-success{color:#fff!important;background-color:RGBA(25,135,84,var(--bs-bg-opacity,1))!important}.text-bg-info{color:#000!important;background-color:RGBA(13,202,240,var(--bs-bg-opacity,1))!important}.text-bg-warning{color:#000!important;background-color:RGBA(255,193,7,var(--bs-bg-opacity,1))!important}.text-bg-danger{color:#fff!important;background-color:RGBA(220,53,69,var(--bs-bg-opacity,1))!important}.text-bg-light{color:#000!important;background-color:RGBA(248,249,250,var(--bs-bg-opacity,1))!important}.text-bg-dark{color:#fff!important;background-color:RGBA(33,37,41,var(--bs-bg-opacity,1))!important}.link-primary{color:RGBA(var(--bs-primary-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-primary-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-primary-rgb),var(--bs-link-underline-opacity,1))!important}.link-primary:focus,.link-primary:hover{color:RGBA(10,88,202,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(10,88,202,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(10,88,202,var(--bs-link-underline-opacity,1))!important}.link-secondary{color:RGBA(var(--bs-secondary-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-secondary-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-secondary-rgb),var(--bs-link-underline-opacity,1))!important}.link-secondary:focus,.link-secondary:hover{color:RGBA(86,94,100,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(86,94,100,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(86,94,100,var(--bs-link-underline-opacity,1))!important}.link-success{color:RGBA(var(--bs-success-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-success-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-success-rgb),var(--bs-link-underline-opacity,1))!important}.link-success:focus,.link-success:hover{color:RGBA(20,108,67,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(20,108,67,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(20,108,67,var(--bs-link-underline-opacity,1))!important}.link-info{color:RGBA(var(--bs-info-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-info-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-info-rgb),var(--bs-link-underline-opacity,1))!important}.link-info:focus,.link-info:hover{color:RGBA(61,213,243,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(61,213,243,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(61,213,243,var(--bs-link-underline-opacity,1))!important}.link-warning{color:RGBA(var(--bs-warning-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-warning-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-warning-rgb),var(--bs-link-underline-opacity,1))!important}.link-warning:focus,.link-warning:hover{color:RGBA(255,205,57,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(255,205,57,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(255,205,57,var(--bs-link-underline-opacity,1))!important}.link-danger{color:RGBA(var(--bs-danger-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-danger-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-danger-rgb),var(--bs-link-underline-opacity,1))!important}.link-danger:focus,.link-danger:hover{color:RGBA(176,42,55,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(176,42,55,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(176,42,55,var(--bs-link-underline-opacity,1))!important}.link-light{color:RGBA(var(--bs-light-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-light-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-light-rgb),var(--bs-link-underline-opacity,1))!important}.link-light:focus,.link-light:hover{color:RGBA(249,250,251,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(249,250,251,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(249,250,251,var(--bs-link-underline-opacity,1))!important}.link-dark{color:RGBA(var(--bs-dark-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-dark-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-dark-rgb),var(--bs-link-underline-opacity,1))!important}.link-dark:focus,.link-dark:hover{color:RGBA(26,30,33,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(26,30,33,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(26,30,33,var(--bs-link-underline-opacity,1))!important}.link-body-emphasis{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,1))!important}.link-body-emphasis:focus,.link-body-emphasis:hover{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity,.75))!important;-webkit-text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,0.75))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,0.75))!important}.focus-ring:focus{outline:0;box-shadow:var(--bs-focus-ring-x,0) var(--bs-focus-ring-y,0) var(--bs-focus-ring-blur,0) var(--bs-focus-ring-width) var(--bs-focus-ring-color)}.icon-link{display:inline-flex;gap:.375rem;align-items:center;-webkit-text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,0.5));text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,0.5));text-underline-offset:0.25em;-webkit-backface-visibility:hidden;backface-visibility:hidden}.icon-link>.bi{flex-shrink:0;width:1em;height:1em;fill:currentcolor;transition:.2s ease-in-out transform}@media (prefers-reduced-motion:reduce){.icon-link>.bi{transition:none}}.icon-link-hover:focus-visible>.bi,.icon-link-hover:hover>.bi{transform:var(--bs-icon-link-transform,translate3d(.25em,0,0))}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio:100%}.ratio-4x3{--bs-aspect-ratio:75%}.ratio-16x9{--bs-aspect-ratio:56.25%}.ratio-21x9{--bs-aspect-ratio:42.8571428571%}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}@media (min-width:576px){.sticky-sm-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-sm-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:768px){.sticky-md-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-md-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:992px){.sticky-lg-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-lg-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1200px){.sticky-xl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1400px){.sticky-xxl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xxl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}.hstack{display:flex;flex-direction:row;align-items:center;align-self:stretch}.vstack{display:flex;flex:1 1 auto;flex-direction:column;align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.visually-hidden-focusable:not(:focus):not(:focus-within):not(caption),.visually-hidden:not(caption){position:absolute!important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;width:1px;min-height:1em;background-color:currentcolor;opacity:.25}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.float-start{float:left!important}.float-end{float:right!important}.float-none{float:none!important}.object-fit-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-none{-o-object-fit:none!important;object-fit:none!important}.opacity-0{opacity:0!important}.opacity-25{opacity:.25!important}.opacity-50{opacity:.5!important}.opacity-75{opacity:.75!important}.opacity-100{opacity:1!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.overflow-visible{overflow:visible!important}.overflow-scroll{overflow:scroll!important}.overflow-x-auto{overflow-x:auto!important}.overflow-x-hidden{overflow-x:hidden!important}.overflow-x-visible{overflow-x:visible!important}.overflow-x-scroll{overflow-x:scroll!important}.overflow-y-auto{overflow-y:auto!important}.overflow-y-hidden{overflow-y:hidden!important}.overflow-y-visible{overflow-y:visible!important}.overflow-y-scroll{overflow-y:scroll!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-inline-grid{display:inline-grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.shadow{box-shadow:0 .5rem 1rem rgba(0,0,0,.15)!important}.shadow-sm{box-shadow:0 .125rem .25rem rgba(0,0,0,.075)!important}.shadow-lg{box-shadow:0 1rem 3rem rgba(0,0,0,.175)!important}.shadow-none{box-shadow:none!important}.focus-ring-primary{--bs-focus-ring-color:rgba(var(--bs-primary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-secondary{--bs-focus-ring-color:rgba(var(--bs-secondary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-success{--bs-focus-ring-color:rgba(var(--bs-success-rgb), var(--bs-focus-ring-opacity))}.focus-ring-info{--bs-focus-ring-color:rgba(var(--bs-info-rgb), var(--bs-focus-ring-opacity))}.focus-ring-warning{--bs-focus-ring-color:rgba(var(--bs-warning-rgb), var(--bs-focus-ring-opacity))}.focus-ring-danger{--bs-focus-ring-color:rgba(var(--bs-danger-rgb), var(--bs-focus-ring-opacity))}.focus-ring-light{--bs-focus-ring-color:rgba(var(--bs-light-rgb), var(--bs-focus-ring-opacity))}.focus-ring-dark{--bs-focus-ring-color:rgba(var(--bs-dark-rgb), var(--bs-focus-ring-opacity))}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.top-0{top:0!important}.top-50{top:50%!important}.top-100{top:100%!important}.bottom-0{bottom:0!important}.bottom-50{bottom:50%!important}.bottom-100{bottom:100%!important}.start-0{left:0!important}.start-50{left:50%!important}.start-100{left:100%!important}.end-0{right:0!important}.end-50{right:50%!important}.end-100{right:100%!important}.translate-middle{transform:translate(-50%,-50%)!important}.translate-middle-x{transform:translateX(-50%)!important}.translate-middle-y{transform:translateY(-50%)!important}.border{border:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-0{border:0!important}.border-top{border-top:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-top-0{border-top:0!important}.border-end{border-right:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-end-0{border-right:0!important}.border-bottom{border-bottom:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-bottom-0{border-bottom:0!important}.border-start{border-left:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-start-0{border-left:0!important}.border-primary{--bs-border-opacity:1;border-color:rgba(var(--bs-primary-rgb),var(--bs-border-opacity))!important}.border-secondary{--bs-border-opacity:1;border-color:rgba(var(--bs-secondary-rgb),var(--bs-border-opacity))!important}.border-success{--bs-border-opacity:1;border-color:rgba(var(--bs-success-rgb),var(--bs-border-opacity))!important}.border-info{--bs-border-opacity:1;border-color:rgba(var(--bs-info-rgb),var(--bs-border-opacity))!important}.border-warning{--bs-border-opacity:1;border-color:rgba(var(--bs-warning-rgb),var(--bs-border-opacity))!important}.border-danger{--bs-border-opacity:1;border-color:rgba(var(--bs-danger-rgb),var(--bs-border-opacity))!important}.border-light{--bs-border-opacity:1;border-color:rgba(var(--bs-light-rgb),var(--bs-border-opacity))!important}.border-dark{--bs-border-opacity:1;border-color:rgba(var(--bs-dark-rgb),var(--bs-border-opacity))!important}.border-black{--bs-border-opacity:1;border-color:rgba(var(--bs-black-rgb),var(--bs-border-opacity))!important}.border-white{--bs-border-opacity:1;border-color:rgba(var(--bs-white-rgb),var(--bs-border-opacity))!important}.border-primary-subtle{border-color:var(--bs-primary-border-subtle)!important}.border-secondary-subtle{border-color:var(--bs-secondary-border-subtle)!important}.border-success-subtle{border-color:var(--bs-success-border-subtle)!important}.border-info-subtle{border-color:var(--bs-info-border-subtle)!important}.border-warning-subtle{border-color:var(--bs-warning-border-subtle)!important}.border-danger-subtle{border-color:var(--bs-danger-border-subtle)!important}.border-light-subtle{border-color:var(--bs-light-border-subtle)!important}.border-dark-subtle{border-color:var(--bs-dark-border-subtle)!important}.border-1{border-width:1px!important}.border-2{border-width:2px!important}.border-3{border-width:3px!important}.border-4{border-width:4px!important}.border-5{border-width:5px!important}.border-opacity-10{--bs-border-opacity:0.1}.border-opacity-25{--bs-border-opacity:0.25}.border-opacity-50{--bs-border-opacity:0.5}.border-opacity-75{--bs-border-opacity:0.75}.border-opacity-100{--bs-border-opacity:1}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.mw-100{max-width:100%!important}.vw-100{width:100vw!important}.min-vw-100{min-width:100vw!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mh-100{max-height:100%!important}.vh-100{height:100vh!important}.min-vh-100{min-height:100vh!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:3rem!important;margin-left:3rem!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important}.ms-auto{margin-left:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-right:0!important;padding-left:0!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-3{padding-right:1rem!important;padding-left:1rem!important}.px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-5{padding-right:3rem!important;padding-left:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-right:0!important}.pe-1{padding-right:.25rem!important}.pe-2{padding-right:.5rem!important}.pe-3{padding-right:1rem!important}.pe-4{padding-right:1.5rem!important}.pe-5{padding-right:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-left:0!important}.ps-1{padding-left:.25rem!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.ps-4{padding-left:1.5rem!important}.ps-5{padding-left:3rem!important}.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important}.row-gap-0{row-gap:0!important}.row-gap-1{row-gap:.25rem!important}.row-gap-2{row-gap:.5rem!important}.row-gap-3{row-gap:1rem!important}.row-gap-4{row-gap:1.5rem!important}.row-gap-5{row-gap:3rem!important}.column-gap-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.font-monospace{font-family:var(--bs-font-monospace)!important}.fs-1{font-size:calc(1.375rem + 1.5vw)!important}.fs-2{font-size:calc(1.325rem + .9vw)!important}.fs-3{font-size:calc(1.3rem + .6vw)!important}.fs-4{font-size:calc(1.275rem + .3vw)!important}.fs-5{font-size:1.25rem!important}.fs-6{font-size:1rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-lighter{font-weight:lighter!important}.fw-light{font-weight:300!important}.fw-normal{font-weight:400!important}.fw-medium{font-weight:500!important}.fw-semibold{font-weight:600!important}.fw-bold{font-weight:700!important}.fw-bolder{font-weight:bolder!important}.lh-1{line-height:1!important}.lh-sm{line-height:1.25!important}.lh-base{line-height:1.5!important}.lh-lg{line-height:2!important}.text-start{text-align:left!important}.text-end{text-align:right!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-decoration-underline{text-decoration:underline!important}.text-decoration-line-through{text-decoration:line-through!important}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-primary{--bs-text-opacity:1;color:rgba(var(--bs-primary-rgb),var(--bs-text-opacity))!important}.text-secondary{--bs-text-opacity:1;color:rgba(var(--bs-secondary-rgb),var(--bs-text-opacity))!important}.text-success{--bs-text-opacity:1;color:rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important}.text-info{--bs-text-opacity:1;color:rgba(var(--bs-info-rgb),var(--bs-text-opacity))!important}.text-warning{--bs-text-opacity:1;color:rgba(var(--bs-warning-rgb),var(--bs-text-opacity))!important}.text-danger{--bs-text-opacity:1;color:rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important}.text-light{--bs-text-opacity:1;color:rgba(var(--bs-light-rgb),var(--bs-text-opacity))!important}.text-dark{--bs-text-opacity:1;color:rgba(var(--bs-dark-rgb),var(--bs-text-opacity))!important}.text-black{--bs-text-opacity:1;color:rgba(var(--bs-black-rgb),var(--bs-text-opacity))!important}.text-white{--bs-text-opacity:1;color:rgba(var(--bs-white-rgb),var(--bs-text-opacity))!important}.text-body{--bs-text-opacity:1;color:rgba(var(--bs-body-color-rgb),var(--bs-text-opacity))!important}.text-muted{--bs-text-opacity:1;color:var(--bs-secondary-color)!important}.text-black-50{--bs-text-opacity:1;color:rgba(0,0,0,.5)!important}.text-white-50{--bs-text-opacity:1;color:rgba(255,255,255,.5)!important}.text-body-secondary{--bs-text-opacity:1;color:var(--bs-secondary-color)!important}.text-body-tertiary{--bs-text-opacity:1;color:var(--bs-tertiary-color)!important}.text-body-emphasis{--bs-text-opacity:1;color:var(--bs-emphasis-color)!important}.text-reset{--bs-text-opacity:1;color:inherit!important}.text-opacity-25{--bs-text-opacity:0.25}.text-opacity-50{--bs-text-opacity:0.5}.text-opacity-75{--bs-text-opacity:0.75}.text-opacity-100{--bs-text-opacity:1}.text-primary-emphasis{color:var(--bs-primary-text-emphasis)!important}.text-secondary-emphasis{color:var(--bs-secondary-text-emphasis)!important}.text-success-emphasis{color:var(--bs-success-text-emphasis)!important}.text-info-emphasis{color:var(--bs-info-text-emphasis)!important}.text-warning-emphasis{color:var(--bs-warning-text-emphasis)!important}.text-danger-emphasis{color:var(--bs-danger-text-emphasis)!important}.text-light-emphasis{color:var(--bs-light-text-emphasis)!important}.text-dark-emphasis{color:var(--bs-dark-text-emphasis)!important}.link-opacity-10{--bs-link-opacity:0.1}.link-opacity-10-hover:hover{--bs-link-opacity:0.1}.link-opacity-25{--bs-link-opacity:0.25}.link-opacity-25-hover:hover{--bs-link-opacity:0.25}.link-opacity-50{--bs-link-opacity:0.5}.link-opacity-50-hover:hover{--bs-link-opacity:0.5}.link-opacity-75{--bs-link-opacity:0.75}.link-opacity-75-hover:hover{--bs-link-opacity:0.75}.link-opacity-100{--bs-link-opacity:1}.link-opacity-100-hover:hover{--bs-link-opacity:1}.link-offset-1{text-underline-offset:0.125em!important}.link-offset-1-hover:hover{text-underline-offset:0.125em!important}.link-offset-2{text-underline-offset:0.25em!important}.link-offset-2-hover:hover{text-underline-offset:0.25em!important}.link-offset-3{text-underline-offset:0.375em!important}.link-offset-3-hover:hover{text-underline-offset:0.375em!important}.link-underline-primary{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-primary-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-primary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-secondary{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-secondary-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-secondary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-success{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-success-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-success-rgb),var(--bs-link-underline-opacity))!important}.link-underline-info{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-info-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-info-rgb),var(--bs-link-underline-opacity))!important}.link-underline-warning{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-warning-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-warning-rgb),var(--bs-link-underline-opacity))!important}.link-underline-danger{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-danger-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-danger-rgb),var(--bs-link-underline-opacity))!important}.link-underline-light{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-light-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-light-rgb),var(--bs-link-underline-opacity))!important}.link-underline-dark{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-dark-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-dark-rgb),var(--bs-link-underline-opacity))!important}.link-underline{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-underline-opacity,1))!important}.link-underline-opacity-0{--bs-link-underline-opacity:0}.link-underline-opacity-0-hover:hover{--bs-link-underline-opacity:0}.link-underline-opacity-10{--bs-link-underline-opacity:0.1}.link-underline-opacity-10-hover:hover{--bs-link-underline-opacity:0.1}.link-underline-opacity-25{--bs-link-underline-opacity:0.25}.link-underline-opacity-25-hover:hover{--bs-link-underline-opacity:0.25}.link-underline-opacity-50{--bs-link-underline-opacity:0.5}.link-underline-opacity-50-hover:hover{--bs-link-underline-opacity:0.5}.link-underline-opacity-75{--bs-link-underline-opacity:0.75}.link-underline-opacity-75-hover:hover{--bs-link-underline-opacity:0.75}.link-underline-opacity-100{--bs-link-underline-opacity:1}.link-underline-opacity-100-hover:hover{--bs-link-underline-opacity:1}.bg-primary{--bs-bg-opacity:1;background-color:rgba(var(--bs-primary-rgb),var(--bs-bg-opacity))!important}.bg-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-rgb),var(--bs-bg-opacity))!important}.bg-success{--bs-bg-opacity:1;background-color:rgba(var(--bs-success-rgb),var(--bs-bg-opacity))!important}.bg-info{--bs-bg-opacity:1;background-color:rgba(var(--bs-info-rgb),var(--bs-bg-opacity))!important}.bg-warning{--bs-bg-opacity:1;background-color:rgba(var(--bs-warning-rgb),var(--bs-bg-opacity))!important}.bg-danger{--bs-bg-opacity:1;background-color:rgba(var(--bs-danger-rgb),var(--bs-bg-opacity))!important}.bg-light{--bs-bg-opacity:1;background-color:rgba(var(--bs-light-rgb),var(--bs-bg-opacity))!important}.bg-dark{--bs-bg-opacity:1;background-color:rgba(var(--bs-dark-rgb),var(--bs-bg-opacity))!important}.bg-black{--bs-bg-opacity:1;background-color:rgba(var(--bs-black-rgb),var(--bs-bg-opacity))!important}.bg-white{--bs-bg-opacity:1;background-color:rgba(var(--bs-white-rgb),var(--bs-bg-opacity))!important}.bg-body{--bs-bg-opacity:1;background-color:rgba(var(--bs-body-bg-rgb),var(--bs-bg-opacity))!important}.bg-transparent{--bs-bg-opacity:1;background-color:transparent!important}.bg-body-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-bg-rgb),var(--bs-bg-opacity))!important}.bg-body-tertiary{--bs-bg-opacity:1;background-color:rgba(var(--bs-tertiary-bg-rgb),var(--bs-bg-opacity))!important}.bg-opacity-10{--bs-bg-opacity:0.1}.bg-opacity-25{--bs-bg-opacity:0.25}.bg-opacity-50{--bs-bg-opacity:0.5}.bg-opacity-75{--bs-bg-opacity:0.75}.bg-opacity-100{--bs-bg-opacity:1}.bg-primary-subtle{background-color:var(--bs-primary-bg-subtle)!important}.bg-secondary-subtle{background-color:var(--bs-secondary-bg-subtle)!important}.bg-success-subtle{background-color:var(--bs-success-bg-subtle)!important}.bg-info-subtle{background-color:var(--bs-info-bg-subtle)!important}.bg-warning-subtle{background-color:var(--bs-warning-bg-subtle)!important}.bg-danger-subtle{background-color:var(--bs-danger-bg-subtle)!important}.bg-light-subtle{background-color:var(--bs-light-bg-subtle)!important}.bg-dark-subtle{background-color:var(--bs-dark-bg-subtle)!important}.bg-gradient{background-image:var(--bs-gradient)!important}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.pe-none{pointer-events:none!important}.pe-auto{pointer-events:auto!important}.rounded{border-radius:var(--bs-border-radius)!important}.rounded-0{border-radius:0!important}.rounded-1{border-radius:var(--bs-border-radius-sm)!important}.rounded-2{border-radius:var(--bs-border-radius)!important}.rounded-3{border-radius:var(--bs-border-radius-lg)!important}.rounded-4{border-radius:var(--bs-border-radius-xl)!important}.rounded-5{border-radius:var(--bs-border-radius-xxl)!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:var(--bs-border-radius-pill)!important}.rounded-top{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-top-0{border-top-left-radius:0!important;border-top-right-radius:0!important}.rounded-top-1{border-top-left-radius:var(--bs-border-radius-sm)!important;border-top-right-radius:var(--bs-border-radius-sm)!important}.rounded-top-2{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-top-3{border-top-left-radius:var(--bs-border-radius-lg)!important;border-top-right-radius:var(--bs-border-radius-lg)!important}.rounded-top-4{border-top-left-radius:var(--bs-border-radius-xl)!important;border-top-right-radius:var(--bs-border-radius-xl)!important}.rounded-top-5{border-top-left-radius:var(--bs-border-radius-xxl)!important;border-top-right-radius:var(--bs-border-radius-xxl)!important}.rounded-top-circle{border-top-left-radius:50%!important;border-top-right-radius:50%!important}.rounded-top-pill{border-top-left-radius:var(--bs-border-radius-pill)!important;border-top-right-radius:var(--bs-border-radius-pill)!important}.rounded-end{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-end-0{border-top-right-radius:0!important;border-bottom-right-radius:0!important}.rounded-end-1{border-top-right-radius:var(--bs-border-radius-sm)!important;border-bottom-right-radius:var(--bs-border-radius-sm)!important}.rounded-end-2{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-end-3{border-top-right-radius:var(--bs-border-radius-lg)!important;border-bottom-right-radius:var(--bs-border-radius-lg)!important}.rounded-end-4{border-top-right-radius:var(--bs-border-radius-xl)!important;border-bottom-right-radius:var(--bs-border-radius-xl)!important}.rounded-end-5{border-top-right-radius:var(--bs-border-radius-xxl)!important;border-bottom-right-radius:var(--bs-border-radius-xxl)!important}.rounded-end-circle{border-top-right-radius:50%!important;border-bottom-right-radius:50%!important}.rounded-end-pill{border-top-right-radius:var(--bs-border-radius-pill)!important;border-bottom-right-radius:var(--bs-border-radius-pill)!important}.rounded-bottom{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-bottom-0{border-bottom-right-radius:0!important;border-bottom-left-radius:0!important}.rounded-bottom-1{border-bottom-right-radius:var(--bs-border-radius-sm)!important;border-bottom-left-radius:var(--bs-border-radius-sm)!important}.rounded-bottom-2{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-bottom-3{border-bottom-right-radius:var(--bs-border-radius-lg)!important;border-bottom-left-radius:var(--bs-border-radius-lg)!important}.rounded-bottom-4{border-bottom-right-radius:var(--bs-border-radius-xl)!important;border-bottom-left-radius:var(--bs-border-radius-xl)!important}.rounded-bottom-5{border-bottom-right-radius:var(--bs-border-radius-xxl)!important;border-bottom-left-radius:var(--bs-border-radius-xxl)!important}.rounded-bottom-circle{border-bottom-right-radius:50%!important;border-bottom-left-radius:50%!important}.rounded-bottom-pill{border-bottom-right-radius:var(--bs-border-radius-pill)!important;border-bottom-left-radius:var(--bs-border-radius-pill)!important}.rounded-start{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-start-0{border-bottom-left-radius:0!important;border-top-left-radius:0!important}.rounded-start-1{border-bottom-left-radius:var(--bs-border-radius-sm)!important;border-top-left-radius:var(--bs-border-radius-sm)!important}.rounded-start-2{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-start-3{border-bottom-left-radius:var(--bs-border-radius-lg)!important;border-top-left-radius:var(--bs-border-radius-lg)!important}.rounded-start-4{border-bottom-left-radius:var(--bs-border-radius-xl)!important;border-top-left-radius:var(--bs-border-radius-xl)!important}.rounded-start-5{border-bottom-left-radius:var(--bs-border-radius-xxl)!important;border-top-left-radius:var(--bs-border-radius-xxl)!important}.rounded-start-circle{border-bottom-left-radius:50%!important;border-top-left-radius:50%!important}.rounded-start-pill{border-bottom-left-radius:var(--bs-border-radius-pill)!important;border-top-left-radius:var(--bs-border-radius-pill)!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}.z-n1{z-index:-1!important}.z-0{z-index:0!important}.z-1{z-index:1!important}.z-2{z-index:2!important}.z-3{z-index:3!important}@media (min-width:576px){.float-sm-start{float:left!important}.float-sm-end{float:right!important}.float-sm-none{float:none!important}.object-fit-sm-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-sm-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-sm-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-sm-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-sm-none{-o-object-fit:none!important;object-fit:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-inline-grid{display:inline-grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-right:0!important;margin-left:0!important}.mx-sm-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-sm-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-sm-3{margin-right:1rem!important;margin-left:1rem!important}.mx-sm-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-sm-5{margin-right:3rem!important;margin-left:3rem!important}.mx-sm-auto{margin-right:auto!important;margin-left:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-right:0!important}.me-sm-1{margin-right:.25rem!important}.me-sm-2{margin-right:.5rem!important}.me-sm-3{margin-right:1rem!important}.me-sm-4{margin-right:1.5rem!important}.me-sm-5{margin-right:3rem!important}.me-sm-auto{margin-right:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-left:0!important}.ms-sm-1{margin-left:.25rem!important}.ms-sm-2{margin-left:.5rem!important}.ms-sm-3{margin-left:1rem!important}.ms-sm-4{margin-left:1.5rem!important}.ms-sm-5{margin-left:3rem!important}.ms-sm-auto{margin-left:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-right:0!important;padding-left:0!important}.px-sm-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-sm-3{padding-right:1rem!important;padding-left:1rem!important}.px-sm-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-sm-5{padding-right:3rem!important;padding-left:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-right:0!important}.pe-sm-1{padding-right:.25rem!important}.pe-sm-2{padding-right:.5rem!important}.pe-sm-3{padding-right:1rem!important}.pe-sm-4{padding-right:1.5rem!important}.pe-sm-5{padding-right:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-left:0!important}.ps-sm-1{padding-left:.25rem!important}.ps-sm-2{padding-left:.5rem!important}.ps-sm-3{padding-left:1rem!important}.ps-sm-4{padding-left:1.5rem!important}.ps-sm-5{padding-left:3rem!important}.gap-sm-0{gap:0!important}.gap-sm-1{gap:.25rem!important}.gap-sm-2{gap:.5rem!important}.gap-sm-3{gap:1rem!important}.gap-sm-4{gap:1.5rem!important}.gap-sm-5{gap:3rem!important}.row-gap-sm-0{row-gap:0!important}.row-gap-sm-1{row-gap:.25rem!important}.row-gap-sm-2{row-gap:.5rem!important}.row-gap-sm-3{row-gap:1rem!important}.row-gap-sm-4{row-gap:1.5rem!important}.row-gap-sm-5{row-gap:3rem!important}.column-gap-sm-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-sm-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-sm-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-sm-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-sm-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-sm-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-sm-start{text-align:left!important}.text-sm-end{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.float-md-start{float:left!important}.float-md-end{float:right!important}.float-md-none{float:none!important}.object-fit-md-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-md-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-md-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-md-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-md-none{-o-object-fit:none!important;object-fit:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-inline-grid{display:inline-grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-right:0!important;margin-left:0!important}.mx-md-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-md-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-md-3{margin-right:1rem!important;margin-left:1rem!important}.mx-md-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-md-5{margin-right:3rem!important;margin-left:3rem!important}.mx-md-auto{margin-right:auto!important;margin-left:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-right:0!important}.me-md-1{margin-right:.25rem!important}.me-md-2{margin-right:.5rem!important}.me-md-3{margin-right:1rem!important}.me-md-4{margin-right:1.5rem!important}.me-md-5{margin-right:3rem!important}.me-md-auto{margin-right:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-left:0!important}.ms-md-1{margin-left:.25rem!important}.ms-md-2{margin-left:.5rem!important}.ms-md-3{margin-left:1rem!important}.ms-md-4{margin-left:1.5rem!important}.ms-md-5{margin-left:3rem!important}.ms-md-auto{margin-left:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-right:0!important;padding-left:0!important}.px-md-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-md-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-md-5{padding-right:3rem!important;padding-left:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-right:0!important}.pe-md-1{padding-right:.25rem!important}.pe-md-2{padding-right:.5rem!important}.pe-md-3{padding-right:1rem!important}.pe-md-4{padding-right:1.5rem!important}.pe-md-5{padding-right:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-left:0!important}.ps-md-1{padding-left:.25rem!important}.ps-md-2{padding-left:.5rem!important}.ps-md-3{padding-left:1rem!important}.ps-md-4{padding-left:1.5rem!important}.ps-md-5{padding-left:3rem!important}.gap-md-0{gap:0!important}.gap-md-1{gap:.25rem!important}.gap-md-2{gap:.5rem!important}.gap-md-3{gap:1rem!important}.gap-md-4{gap:1.5rem!important}.gap-md-5{gap:3rem!important}.row-gap-md-0{row-gap:0!important}.row-gap-md-1{row-gap:.25rem!important}.row-gap-md-2{row-gap:.5rem!important}.row-gap-md-3{row-gap:1rem!important}.row-gap-md-4{row-gap:1.5rem!important}.row-gap-md-5{row-gap:3rem!important}.column-gap-md-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-md-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-md-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-md-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-md-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-md-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-md-start{text-align:left!important}.text-md-end{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.float-lg-start{float:left!important}.float-lg-end{float:right!important}.float-lg-none{float:none!important}.object-fit-lg-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-lg-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-lg-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-lg-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-lg-none{-o-object-fit:none!important;object-fit:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-inline-grid{display:inline-grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-right:0!important;margin-left:0!important}.mx-lg-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-lg-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-lg-3{margin-right:1rem!important;margin-left:1rem!important}.mx-lg-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-lg-5{margin-right:3rem!important;margin-left:3rem!important}.mx-lg-auto{margin-right:auto!important;margin-left:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-right:0!important}.me-lg-1{margin-right:.25rem!important}.me-lg-2{margin-right:.5rem!important}.me-lg-3{margin-right:1rem!important}.me-lg-4{margin-right:1.5rem!important}.me-lg-5{margin-right:3rem!important}.me-lg-auto{margin-right:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-left:0!important}.ms-lg-1{margin-left:.25rem!important}.ms-lg-2{margin-left:.5rem!important}.ms-lg-3{margin-left:1rem!important}.ms-lg-4{margin-left:1.5rem!important}.ms-lg-5{margin-left:3rem!important}.ms-lg-auto{margin-left:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-right:0!important;padding-left:0!important}.px-lg-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-lg-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-lg-5{padding-right:3rem!important;padding-left:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-right:0!important}.pe-lg-1{padding-right:.25rem!important}.pe-lg-2{padding-right:.5rem!important}.pe-lg-3{padding-right:1rem!important}.pe-lg-4{padding-right:1.5rem!important}.pe-lg-5{padding-right:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-left:0!important}.ps-lg-1{padding-left:.25rem!important}.ps-lg-2{padding-left:.5rem!important}.ps-lg-3{padding-left:1rem!important}.ps-lg-4{padding-left:1.5rem!important}.ps-lg-5{padding-left:3rem!important}.gap-lg-0{gap:0!important}.gap-lg-1{gap:.25rem!important}.gap-lg-2{gap:.5rem!important}.gap-lg-3{gap:1rem!important}.gap-lg-4{gap:1.5rem!important}.gap-lg-5{gap:3rem!important}.row-gap-lg-0{row-gap:0!important}.row-gap-lg-1{row-gap:.25rem!important}.row-gap-lg-2{row-gap:.5rem!important}.row-gap-lg-3{row-gap:1rem!important}.row-gap-lg-4{row-gap:1.5rem!important}.row-gap-lg-5{row-gap:3rem!important}.column-gap-lg-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-lg-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-lg-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-lg-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-lg-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-lg-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-lg-start{text-align:left!important}.text-lg-end{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.float-xl-start{float:left!important}.float-xl-end{float:right!important}.float-xl-none{float:none!important}.object-fit-xl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xl-none{-o-object-fit:none!important;object-fit:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-inline-grid{display:inline-grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-right:0!important;margin-left:0!important}.mx-xl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xl-auto{margin-right:auto!important;margin-left:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-right:0!important}.me-xl-1{margin-right:.25rem!important}.me-xl-2{margin-right:.5rem!important}.me-xl-3{margin-right:1rem!important}.me-xl-4{margin-right:1.5rem!important}.me-xl-5{margin-right:3rem!important}.me-xl-auto{margin-right:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-left:0!important}.ms-xl-1{margin-left:.25rem!important}.ms-xl-2{margin-left:.5rem!important}.ms-xl-3{margin-left:1rem!important}.ms-xl-4{margin-left:1.5rem!important}.ms-xl-5{margin-left:3rem!important}.ms-xl-auto{margin-left:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-right:0!important}.pe-xl-1{padding-right:.25rem!important}.pe-xl-2{padding-right:.5rem!important}.pe-xl-3{padding-right:1rem!important}.pe-xl-4{padding-right:1.5rem!important}.pe-xl-5{padding-right:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-left:0!important}.ps-xl-1{padding-left:.25rem!important}.ps-xl-2{padding-left:.5rem!important}.ps-xl-3{padding-left:1rem!important}.ps-xl-4{padding-left:1.5rem!important}.ps-xl-5{padding-left:3rem!important}.gap-xl-0{gap:0!important}.gap-xl-1{gap:.25rem!important}.gap-xl-2{gap:.5rem!important}.gap-xl-3{gap:1rem!important}.gap-xl-4{gap:1.5rem!important}.gap-xl-5{gap:3rem!important}.row-gap-xl-0{row-gap:0!important}.row-gap-xl-1{row-gap:.25rem!important}.row-gap-xl-2{row-gap:.5rem!important}.row-gap-xl-3{row-gap:1rem!important}.row-gap-xl-4{row-gap:1.5rem!important}.row-gap-xl-5{row-gap:3rem!important}.column-gap-xl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xl-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-xl-start{text-align:left!important}.text-xl-end{text-align:right!important}.text-xl-center{text-align:center!important}}@media (min-width:1400px){.float-xxl-start{float:left!important}.float-xxl-end{float:right!important}.float-xxl-none{float:none!important}.object-fit-xxl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xxl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xxl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xxl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xxl-none{-o-object-fit:none!important;object-fit:none!important}.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-inline-grid{display:inline-grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-right:0!important;margin-left:0!important}.mx-xxl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xxl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xxl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xxl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xxl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xxl-auto{margin-right:auto!important;margin-left:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-right:0!important}.me-xxl-1{margin-right:.25rem!important}.me-xxl-2{margin-right:.5rem!important}.me-xxl-3{margin-right:1rem!important}.me-xxl-4{margin-right:1.5rem!important}.me-xxl-5{margin-right:3rem!important}.me-xxl-auto{margin-right:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-left:0!important}.ms-xxl-1{margin-left:.25rem!important}.ms-xxl-2{margin-left:.5rem!important}.ms-xxl-3{margin-left:1rem!important}.ms-xxl-4{margin-left:1.5rem!important}.ms-xxl-5{margin-left:3rem!important}.ms-xxl-auto{margin-left:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-right:0!important;padding-left:0!important}.px-xxl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xxl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xxl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xxl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xxl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-right:0!important}.pe-xxl-1{padding-right:.25rem!important}.pe-xxl-2{padding-right:.5rem!important}.pe-xxl-3{padding-right:1rem!important}.pe-xxl-4{padding-right:1.5rem!important}.pe-xxl-5{padding-right:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-left:0!important}.ps-xxl-1{padding-left:.25rem!important}.ps-xxl-2{padding-left:.5rem!important}.ps-xxl-3{padding-left:1rem!important}.ps-xxl-4{padding-left:1.5rem!important}.ps-xxl-5{padding-left:3rem!important}.gap-xxl-0{gap:0!important}.gap-xxl-1{gap:.25rem!important}.gap-xxl-2{gap:.5rem!important}.gap-xxl-3{gap:1rem!important}.gap-xxl-4{gap:1.5rem!important}.gap-xxl-5{gap:3rem!important}.row-gap-xxl-0{row-gap:0!important}.row-gap-xxl-1{row-gap:.25rem!important}.row-gap-xxl-2{row-gap:.5rem!important}.row-gap-xxl-3{row-gap:1rem!important}.row-gap-xxl-4{row-gap:1.5rem!important}.row-gap-xxl-5{row-gap:3rem!important}.column-gap-xxl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xxl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xxl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xxl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xxl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xxl-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-xxl-start{text-align:left!important}.text-xxl-end{text-align:right!important}.text-xxl-center{text-align:center!important}}@media (min-width:1200px){.fs-1{font-size:2.5rem!important}.fs-2{font-size:2rem!important}.fs-3{font-size:1.75rem!important}.fs-4{font-size:1.5rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-inline-grid{display:inline-grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}} +/*# sourceMappingURL=bootstrap.min.css.map */ \ No newline at end of file diff --git a/src/main/resources/static/fonts/bootstrap-icons.woff b/src/main/resources/static/fonts/bootstrap-icons.woff new file mode 100644 index 0000000..ce6152b Binary files /dev/null and b/src/main/resources/static/fonts/bootstrap-icons.woff differ diff --git a/src/main/resources/static/fonts/bootstrap-icons.woff2 b/src/main/resources/static/fonts/bootstrap-icons.woff2 new file mode 100644 index 0000000..c1e0094 Binary files /dev/null and b/src/main/resources/static/fonts/bootstrap-icons.woff2 differ diff --git a/src/main/resources/static/js/bootstrap.bundle.min.js b/src/main/resources/static/js/bootstrap.bundle.min.js new file mode 100644 index 0000000..fe19d88 --- /dev/null +++ b/src/main/resources/static/js/bootstrap.bundle.min.js @@ -0,0 +1,7 @@ +/*! + * Bootstrap v5.3.0 (https://getbootstrap.com/) + * Copyright 2011-2023 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e()}(this,(function(){"use strict";const t=new Map,e={set(e,i,n){t.has(e)||t.set(e,new Map);const s=t.get(e);s.has(i)||0===s.size?s.set(i,n):console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(s.keys())[0]}.`)},get:(e,i)=>t.has(e)&&t.get(e).get(i)||null,remove(e,i){if(!t.has(e))return;const n=t.get(e);n.delete(i),0===n.size&&t.delete(e)}},i="transitionend",n=t=>(t&&window.CSS&&window.CSS.escape&&(t=t.replace(/#([^\s"#']+)/g,((t,e)=>`#${CSS.escape(e)}`))),t),s=t=>{t.dispatchEvent(new Event(i))},o=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),r=t=>o(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(n(t)):null,a=t=>{if(!o(t)||0===t.getClientRects().length)return!1;const e="visible"===getComputedStyle(t).getPropertyValue("visibility"),i=t.closest("details:not([open])");if(!i)return e;if(i!==t){const e=t.closest("summary");if(e&&e.parentNode!==i)return!1;if(null===e)return!1}return e},l=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),c=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?c(t.parentNode):null},h=()=>{},d=t=>{t.offsetHeight},u=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,f=[],p=()=>"rtl"===document.documentElement.dir,m=t=>{var e;e=()=>{const e=u();if(e){const i=t.NAME,n=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=n,t.jQueryInterface)}},"loading"===document.readyState?(f.length||document.addEventListener("DOMContentLoaded",(()=>{for(const t of f)t()})),f.push(e)):e()},g=(t,e=[],i=t)=>"function"==typeof t?t(...e):i,_=(t,e,n=!0)=>{if(!n)return void g(t);const o=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const n=Number.parseFloat(e),s=Number.parseFloat(i);return n||s?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(e)+5;let r=!1;const a=({target:n})=>{n===e&&(r=!0,e.removeEventListener(i,a),g(t))};e.addEventListener(i,a),setTimeout((()=>{r||s(e)}),o)},b=(t,e,i,n)=>{const s=t.length;let o=t.indexOf(e);return-1===o?!i&&n?t[s-1]:t[0]:(o+=i?1:-1,n&&(o=(o+s)%s),t[Math.max(0,Math.min(o,s-1))])},v=/[^.]*(?=\..*)\.|.*/,y=/\..*/,w=/::\d+$/,A={};let E=1;const T={mouseenter:"mouseover",mouseleave:"mouseout"},C=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function O(t,e){return e&&`${e}::${E++}`||t.uidEvent||E++}function x(t){const e=O(t);return t.uidEvent=e,A[e]=A[e]||{},A[e]}function k(t,e,i=null){return Object.values(t).find((t=>t.callable===e&&t.delegationSelector===i))}function L(t,e,i){const n="string"==typeof e,s=n?i:e||i;let o=N(t);return C.has(o)||(o=t),[n,s,o]}function S(t,e,i,n,s){if("string"!=typeof e||!t)return;let[o,r,a]=L(e,i,n);if(e in T){const t=t=>function(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};r=t(r)}const l=x(t),c=l[a]||(l[a]={}),h=k(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&s);const d=O(r,e.replace(v,"")),u=o?function(t,e,i){return function n(s){const o=t.querySelectorAll(e);for(let{target:r}=s;r&&r!==this;r=r.parentNode)for(const a of o)if(a===r)return M(s,{delegateTarget:r}),n.oneOff&&P.off(t,s.type,e,i),i.apply(r,[s])}}(t,i,r):function(t,e){return function i(n){return M(n,{delegateTarget:t}),i.oneOff&&P.off(t,n.type,e),e.apply(t,[n])}}(t,r);u.delegationSelector=o?i:null,u.callable=r,u.oneOff=s,u.uidEvent=d,c[d]=u,t.addEventListener(a,u,o)}function D(t,e,i,n,s){const o=k(e[i],n,s);o&&(t.removeEventListener(i,o,Boolean(s)),delete e[i][o.uidEvent])}function I(t,e,i,n){const s=e[i]||{};for(const[o,r]of Object.entries(s))o.includes(n)&&D(t,e,i,r.callable,r.delegationSelector)}function N(t){return t=t.replace(y,""),T[t]||t}const P={on(t,e,i,n){S(t,e,i,n,!1)},one(t,e,i,n){S(t,e,i,n,!0)},off(t,e,i,n){if("string"!=typeof e||!t)return;const[s,o,r]=L(e,i,n),a=r!==e,l=x(t),c=l[r]||{},h=e.startsWith(".");if(void 0===o){if(h)for(const i of Object.keys(l))I(t,l,i,e.slice(1));for(const[i,n]of Object.entries(c)){const s=i.replace(w,"");a&&!e.includes(s)||D(t,l,r,n.callable,n.delegationSelector)}}else{if(!Object.keys(c).length)return;D(t,l,r,o,s?i:null)}},trigger(t,e,i){if("string"!=typeof e||!t)return null;const n=u();let s=null,o=!0,r=!0,a=!1;e!==N(e)&&n&&(s=n.Event(e,i),n(t).trigger(s),o=!s.isPropagationStopped(),r=!s.isImmediatePropagationStopped(),a=s.isDefaultPrevented());const l=M(new Event(e,{bubbles:o,cancelable:!0}),i);return a&&l.preventDefault(),r&&t.dispatchEvent(l),l.defaultPrevented&&s&&s.preventDefault(),l}};function M(t,e={}){for(const[i,n]of Object.entries(e))try{t[i]=n}catch(e){Object.defineProperty(t,i,{configurable:!0,get:()=>n})}return t}function j(t){if("true"===t)return!0;if("false"===t)return!1;if(t===Number(t).toString())return Number(t);if(""===t||"null"===t)return null;if("string"!=typeof t)return t;try{return JSON.parse(decodeURIComponent(t))}catch(e){return t}}function F(t){return t.replace(/[A-Z]/g,(t=>`-${t.toLowerCase()}`))}const H={setDataAttribute(t,e,i){t.setAttribute(`data-bs-${F(e)}`,i)},removeDataAttribute(t,e){t.removeAttribute(`data-bs-${F(e)}`)},getDataAttributes(t){if(!t)return{};const e={},i=Object.keys(t.dataset).filter((t=>t.startsWith("bs")&&!t.startsWith("bsConfig")));for(const n of i){let i=n.replace(/^bs/,"");i=i.charAt(0).toLowerCase()+i.slice(1,i.length),e[i]=j(t.dataset[n])}return e},getDataAttribute:(t,e)=>j(t.getAttribute(`data-bs-${F(e)}`))};class ${static get Default(){return{}}static get DefaultType(){return{}}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}_getConfig(t){return t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t}_mergeConfigObj(t,e){const i=o(e)?H.getDataAttribute(e,"config"):{};return{...this.constructor.Default,..."object"==typeof i?i:{},...o(e)?H.getDataAttributes(e):{},..."object"==typeof t?t:{}}}_typeCheckConfig(t,e=this.constructor.DefaultType){for(const[n,s]of Object.entries(e)){const e=t[n],r=o(e)?"element":null==(i=e)?`${i}`:Object.prototype.toString.call(i).match(/\s([a-z]+)/i)[1].toLowerCase();if(!new RegExp(s).test(r))throw new TypeError(`${this.constructor.NAME.toUpperCase()}: Option "${n}" provided type "${r}" but expected type "${s}".`)}var i}}class W extends ${constructor(t,i){super(),(t=r(t))&&(this._element=t,this._config=this._getConfig(i),e.set(this._element,this.constructor.DATA_KEY,this))}dispose(){e.remove(this._element,this.constructor.DATA_KEY),P.off(this._element,this.constructor.EVENT_KEY);for(const t of Object.getOwnPropertyNames(this))this[t]=null}_queueCallback(t,e,i=!0){_(t,e,i)}_getConfig(t){return t=this._mergeConfigObj(t,this._element),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}static getInstance(t){return e.get(r(t),this.DATA_KEY)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,"object"==typeof e?e:null)}static get VERSION(){return"5.3.0"}static get DATA_KEY(){return`bs.${this.NAME}`}static get EVENT_KEY(){return`.${this.DATA_KEY}`}static eventName(t){return`${t}${this.EVENT_KEY}`}}const B=t=>{let e=t.getAttribute("data-bs-target");if(!e||"#"===e){let i=t.getAttribute("href");if(!i||!i.includes("#")&&!i.startsWith("."))return null;i.includes("#")&&!i.startsWith("#")&&(i=`#${i.split("#")[1]}`),e=i&&"#"!==i?i.trim():null}return n(e)},z={find:(t,e=document.documentElement)=>[].concat(...Element.prototype.querySelectorAll.call(e,t)),findOne:(t,e=document.documentElement)=>Element.prototype.querySelector.call(e,t),children:(t,e)=>[].concat(...t.children).filter((t=>t.matches(e))),parents(t,e){const i=[];let n=t.parentNode.closest(e);for(;n;)i.push(n),n=n.parentNode.closest(e);return i},prev(t,e){let i=t.previousElementSibling;for(;i;){if(i.matches(e))return[i];i=i.previousElementSibling}return[]},next(t,e){let i=t.nextElementSibling;for(;i;){if(i.matches(e))return[i];i=i.nextElementSibling}return[]},focusableChildren(t){const e=["a","button","input","textarea","select","details","[tabindex]",'[contenteditable="true"]'].map((t=>`${t}:not([tabindex^="-"])`)).join(",");return this.find(e,t).filter((t=>!l(t)&&a(t)))},getSelectorFromElement(t){const e=B(t);return e&&z.findOne(e)?e:null},getElementFromSelector(t){const e=B(t);return e?z.findOne(e):null},getMultipleElementsFromSelector(t){const e=B(t);return e?z.find(e):[]}},R=(t,e="hide")=>{const i=`click.dismiss${t.EVENT_KEY}`,n=t.NAME;P.on(document,i,`[data-bs-dismiss="${n}"]`,(function(i){if(["A","AREA"].includes(this.tagName)&&i.preventDefault(),l(this))return;const s=z.getElementFromSelector(this)||this.closest(`.${n}`);t.getOrCreateInstance(s)[e]()}))};class q extends W{static get NAME(){return"alert"}close(){if(P.trigger(this._element,"close.bs.alert").defaultPrevented)return;this._element.classList.remove("show");const t=this._element.classList.contains("fade");this._queueCallback((()=>this._destroyElement()),this._element,t)}_destroyElement(){this._element.remove(),P.trigger(this._element,"closed.bs.alert"),this.dispose()}static jQueryInterface(t){return this.each((function(){const e=q.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}R(q,"close"),m(q);const V='[data-bs-toggle="button"]';class K extends W{static get NAME(){return"button"}toggle(){this._element.setAttribute("aria-pressed",this._element.classList.toggle("active"))}static jQueryInterface(t){return this.each((function(){const e=K.getOrCreateInstance(this);"toggle"===t&&e[t]()}))}}P.on(document,"click.bs.button.data-api",V,(t=>{t.preventDefault();const e=t.target.closest(V);K.getOrCreateInstance(e).toggle()})),m(K);const Q={endCallback:null,leftCallback:null,rightCallback:null},X={endCallback:"(function|null)",leftCallback:"(function|null)",rightCallback:"(function|null)"};class Y extends ${constructor(t,e){super(),this._element=t,t&&Y.isSupported()&&(this._config=this._getConfig(e),this._deltaX=0,this._supportPointerEvents=Boolean(window.PointerEvent),this._initEvents())}static get Default(){return Q}static get DefaultType(){return X}static get NAME(){return"swipe"}dispose(){P.off(this._element,".bs.swipe")}_start(t){this._supportPointerEvents?this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX):this._deltaX=t.touches[0].clientX}_end(t){this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX-this._deltaX),this._handleSwipe(),g(this._config.endCallback)}_move(t){this._deltaX=t.touches&&t.touches.length>1?0:t.touches[0].clientX-this._deltaX}_handleSwipe(){const t=Math.abs(this._deltaX);if(t<=40)return;const e=t/this._deltaX;this._deltaX=0,e&&g(e>0?this._config.rightCallback:this._config.leftCallback)}_initEvents(){this._supportPointerEvents?(P.on(this._element,"pointerdown.bs.swipe",(t=>this._start(t))),P.on(this._element,"pointerup.bs.swipe",(t=>this._end(t))),this._element.classList.add("pointer-event")):(P.on(this._element,"touchstart.bs.swipe",(t=>this._start(t))),P.on(this._element,"touchmove.bs.swipe",(t=>this._move(t))),P.on(this._element,"touchend.bs.swipe",(t=>this._end(t))))}_eventIsPointerPenTouch(t){return this._supportPointerEvents&&("pen"===t.pointerType||"touch"===t.pointerType)}static isSupported(){return"ontouchstart"in document.documentElement||navigator.maxTouchPoints>0}}const U="next",G="prev",J="left",Z="right",tt="slid.bs.carousel",et="carousel",it="active",nt={ArrowLeft:Z,ArrowRight:J},st={interval:5e3,keyboard:!0,pause:"hover",ride:!1,touch:!0,wrap:!0},ot={interval:"(number|boolean)",keyboard:"boolean",pause:"(string|boolean)",ride:"(boolean|string)",touch:"boolean",wrap:"boolean"};class rt extends W{constructor(t,e){super(t,e),this._interval=null,this._activeElement=null,this._isSliding=!1,this.touchTimeout=null,this._swipeHelper=null,this._indicatorsElement=z.findOne(".carousel-indicators",this._element),this._addEventListeners(),this._config.ride===et&&this.cycle()}static get Default(){return st}static get DefaultType(){return ot}static get NAME(){return"carousel"}next(){this._slide(U)}nextWhenVisible(){!document.hidden&&a(this._element)&&this.next()}prev(){this._slide(G)}pause(){this._isSliding&&s(this._element),this._clearInterval()}cycle(){this._clearInterval(),this._updateInterval(),this._interval=setInterval((()=>this.nextWhenVisible()),this._config.interval)}_maybeEnableCycle(){this._config.ride&&(this._isSliding?P.one(this._element,tt,(()=>this.cycle())):this.cycle())}to(t){const e=this._getItems();if(t>e.length-1||t<0)return;if(this._isSliding)return void P.one(this._element,tt,(()=>this.to(t)));const i=this._getItemIndex(this._getActive());if(i===t)return;const n=t>i?U:G;this._slide(n,e[t])}dispose(){this._swipeHelper&&this._swipeHelper.dispose(),super.dispose()}_configAfterMerge(t){return t.defaultInterval=t.interval,t}_addEventListeners(){this._config.keyboard&&P.on(this._element,"keydown.bs.carousel",(t=>this._keydown(t))),"hover"===this._config.pause&&(P.on(this._element,"mouseenter.bs.carousel",(()=>this.pause())),P.on(this._element,"mouseleave.bs.carousel",(()=>this._maybeEnableCycle()))),this._config.touch&&Y.isSupported()&&this._addTouchEventListeners()}_addTouchEventListeners(){for(const t of z.find(".carousel-item img",this._element))P.on(t,"dragstart.bs.carousel",(t=>t.preventDefault()));const t={leftCallback:()=>this._slide(this._directionToOrder(J)),rightCallback:()=>this._slide(this._directionToOrder(Z)),endCallback:()=>{"hover"===this._config.pause&&(this.pause(),this.touchTimeout&&clearTimeout(this.touchTimeout),this.touchTimeout=setTimeout((()=>this._maybeEnableCycle()),500+this._config.interval))}};this._swipeHelper=new Y(this._element,t)}_keydown(t){if(/input|textarea/i.test(t.target.tagName))return;const e=nt[t.key];e&&(t.preventDefault(),this._slide(this._directionToOrder(e)))}_getItemIndex(t){return this._getItems().indexOf(t)}_setActiveIndicatorElement(t){if(!this._indicatorsElement)return;const e=z.findOne(".active",this._indicatorsElement);e.classList.remove(it),e.removeAttribute("aria-current");const i=z.findOne(`[data-bs-slide-to="${t}"]`,this._indicatorsElement);i&&(i.classList.add(it),i.setAttribute("aria-current","true"))}_updateInterval(){const t=this._activeElement||this._getActive();if(!t)return;const e=Number.parseInt(t.getAttribute("data-bs-interval"),10);this._config.interval=e||this._config.defaultInterval}_slide(t,e=null){if(this._isSliding)return;const i=this._getActive(),n=t===U,s=e||b(this._getItems(),i,n,this._config.wrap);if(s===i)return;const o=this._getItemIndex(s),r=e=>P.trigger(this._element,e,{relatedTarget:s,direction:this._orderToDirection(t),from:this._getItemIndex(i),to:o});if(r("slide.bs.carousel").defaultPrevented)return;if(!i||!s)return;const a=Boolean(this._interval);this.pause(),this._isSliding=!0,this._setActiveIndicatorElement(o),this._activeElement=s;const l=n?"carousel-item-start":"carousel-item-end",c=n?"carousel-item-next":"carousel-item-prev";s.classList.add(c),d(s),i.classList.add(l),s.classList.add(l),this._queueCallback((()=>{s.classList.remove(l,c),s.classList.add(it),i.classList.remove(it,c,l),this._isSliding=!1,r(tt)}),i,this._isAnimated()),a&&this.cycle()}_isAnimated(){return this._element.classList.contains("slide")}_getActive(){return z.findOne(".active.carousel-item",this._element)}_getItems(){return z.find(".carousel-item",this._element)}_clearInterval(){this._interval&&(clearInterval(this._interval),this._interval=null)}_directionToOrder(t){return p()?t===J?G:U:t===J?U:G}_orderToDirection(t){return p()?t===G?J:Z:t===G?Z:J}static jQueryInterface(t){return this.each((function(){const e=rt.getOrCreateInstance(this,t);if("number"!=typeof t){if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}else e.to(t)}))}}P.on(document,"click.bs.carousel.data-api","[data-bs-slide], [data-bs-slide-to]",(function(t){const e=z.getElementFromSelector(this);if(!e||!e.classList.contains(et))return;t.preventDefault();const i=rt.getOrCreateInstance(e),n=this.getAttribute("data-bs-slide-to");return n?(i.to(n),void i._maybeEnableCycle()):"next"===H.getDataAttribute(this,"slide")?(i.next(),void i._maybeEnableCycle()):(i.prev(),void i._maybeEnableCycle())})),P.on(window,"load.bs.carousel.data-api",(()=>{const t=z.find('[data-bs-ride="carousel"]');for(const e of t)rt.getOrCreateInstance(e)})),m(rt);const at="show",lt="collapse",ct="collapsing",ht='[data-bs-toggle="collapse"]',dt={parent:null,toggle:!0},ut={parent:"(null|element)",toggle:"boolean"};class ft extends W{constructor(t,e){super(t,e),this._isTransitioning=!1,this._triggerArray=[];const i=z.find(ht);for(const t of i){const e=z.getSelectorFromElement(t),i=z.find(e).filter((t=>t===this._element));null!==e&&i.length&&this._triggerArray.push(t)}this._initializeChildren(),this._config.parent||this._addAriaAndCollapsedClass(this._triggerArray,this._isShown()),this._config.toggle&&this.toggle()}static get Default(){return dt}static get DefaultType(){return ut}static get NAME(){return"collapse"}toggle(){this._isShown()?this.hide():this.show()}show(){if(this._isTransitioning||this._isShown())return;let t=[];if(this._config.parent&&(t=this._getFirstLevelChildren(".collapse.show, .collapse.collapsing").filter((t=>t!==this._element)).map((t=>ft.getOrCreateInstance(t,{toggle:!1})))),t.length&&t[0]._isTransitioning)return;if(P.trigger(this._element,"show.bs.collapse").defaultPrevented)return;for(const e of t)e.hide();const e=this._getDimension();this._element.classList.remove(lt),this._element.classList.add(ct),this._element.style[e]=0,this._addAriaAndCollapsedClass(this._triggerArray,!0),this._isTransitioning=!0;const i=`scroll${e[0].toUpperCase()+e.slice(1)}`;this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(ct),this._element.classList.add(lt,at),this._element.style[e]="",P.trigger(this._element,"shown.bs.collapse")}),this._element,!0),this._element.style[e]=`${this._element[i]}px`}hide(){if(this._isTransitioning||!this._isShown())return;if(P.trigger(this._element,"hide.bs.collapse").defaultPrevented)return;const t=this._getDimension();this._element.style[t]=`${this._element.getBoundingClientRect()[t]}px`,d(this._element),this._element.classList.add(ct),this._element.classList.remove(lt,at);for(const t of this._triggerArray){const e=z.getElementFromSelector(t);e&&!this._isShown(e)&&this._addAriaAndCollapsedClass([t],!1)}this._isTransitioning=!0,this._element.style[t]="",this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(ct),this._element.classList.add(lt),P.trigger(this._element,"hidden.bs.collapse")}),this._element,!0)}_isShown(t=this._element){return t.classList.contains(at)}_configAfterMerge(t){return t.toggle=Boolean(t.toggle),t.parent=r(t.parent),t}_getDimension(){return this._element.classList.contains("collapse-horizontal")?"width":"height"}_initializeChildren(){if(!this._config.parent)return;const t=this._getFirstLevelChildren(ht);for(const e of t){const t=z.getElementFromSelector(e);t&&this._addAriaAndCollapsedClass([e],this._isShown(t))}}_getFirstLevelChildren(t){const e=z.find(":scope .collapse .collapse",this._config.parent);return z.find(t,this._config.parent).filter((t=>!e.includes(t)))}_addAriaAndCollapsedClass(t,e){if(t.length)for(const i of t)i.classList.toggle("collapsed",!e),i.setAttribute("aria-expanded",e)}static jQueryInterface(t){const e={};return"string"==typeof t&&/show|hide/.test(t)&&(e.toggle=!1),this.each((function(){const i=ft.getOrCreateInstance(this,e);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t]()}}))}}P.on(document,"click.bs.collapse.data-api",ht,(function(t){("A"===t.target.tagName||t.delegateTarget&&"A"===t.delegateTarget.tagName)&&t.preventDefault();for(const t of z.getMultipleElementsFromSelector(this))ft.getOrCreateInstance(t,{toggle:!1}).toggle()})),m(ft);var pt="top",mt="bottom",gt="right",_t="left",bt="auto",vt=[pt,mt,gt,_t],yt="start",wt="end",At="clippingParents",Et="viewport",Tt="popper",Ct="reference",Ot=vt.reduce((function(t,e){return t.concat([e+"-"+yt,e+"-"+wt])}),[]),xt=[].concat(vt,[bt]).reduce((function(t,e){return t.concat([e,e+"-"+yt,e+"-"+wt])}),[]),kt="beforeRead",Lt="read",St="afterRead",Dt="beforeMain",It="main",Nt="afterMain",Pt="beforeWrite",Mt="write",jt="afterWrite",Ft=[kt,Lt,St,Dt,It,Nt,Pt,Mt,jt];function Ht(t){return t?(t.nodeName||"").toLowerCase():null}function $t(t){if(null==t)return window;if("[object Window]"!==t.toString()){var e=t.ownerDocument;return e&&e.defaultView||window}return t}function Wt(t){return t instanceof $t(t).Element||t instanceof Element}function Bt(t){return t instanceof $t(t).HTMLElement||t instanceof HTMLElement}function zt(t){return"undefined"!=typeof ShadowRoot&&(t instanceof $t(t).ShadowRoot||t instanceof ShadowRoot)}const Rt={name:"applyStyles",enabled:!0,phase:"write",fn:function(t){var e=t.state;Object.keys(e.elements).forEach((function(t){var i=e.styles[t]||{},n=e.attributes[t]||{},s=e.elements[t];Bt(s)&&Ht(s)&&(Object.assign(s.style,i),Object.keys(n).forEach((function(t){var e=n[t];!1===e?s.removeAttribute(t):s.setAttribute(t,!0===e?"":e)})))}))},effect:function(t){var e=t.state,i={popper:{position:e.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};return Object.assign(e.elements.popper.style,i.popper),e.styles=i,e.elements.arrow&&Object.assign(e.elements.arrow.style,i.arrow),function(){Object.keys(e.elements).forEach((function(t){var n=e.elements[t],s=e.attributes[t]||{},o=Object.keys(e.styles.hasOwnProperty(t)?e.styles[t]:i[t]).reduce((function(t,e){return t[e]="",t}),{});Bt(n)&&Ht(n)&&(Object.assign(n.style,o),Object.keys(s).forEach((function(t){n.removeAttribute(t)})))}))}},requires:["computeStyles"]};function qt(t){return t.split("-")[0]}var Vt=Math.max,Kt=Math.min,Qt=Math.round;function Xt(){var t=navigator.userAgentData;return null!=t&&t.brands&&Array.isArray(t.brands)?t.brands.map((function(t){return t.brand+"/"+t.version})).join(" "):navigator.userAgent}function Yt(){return!/^((?!chrome|android).)*safari/i.test(Xt())}function Ut(t,e,i){void 0===e&&(e=!1),void 0===i&&(i=!1);var n=t.getBoundingClientRect(),s=1,o=1;e&&Bt(t)&&(s=t.offsetWidth>0&&Qt(n.width)/t.offsetWidth||1,o=t.offsetHeight>0&&Qt(n.height)/t.offsetHeight||1);var r=(Wt(t)?$t(t):window).visualViewport,a=!Yt()&&i,l=(n.left+(a&&r?r.offsetLeft:0))/s,c=(n.top+(a&&r?r.offsetTop:0))/o,h=n.width/s,d=n.height/o;return{width:h,height:d,top:c,right:l+h,bottom:c+d,left:l,x:l,y:c}}function Gt(t){var e=Ut(t),i=t.offsetWidth,n=t.offsetHeight;return Math.abs(e.width-i)<=1&&(i=e.width),Math.abs(e.height-n)<=1&&(n=e.height),{x:t.offsetLeft,y:t.offsetTop,width:i,height:n}}function Jt(t,e){var i=e.getRootNode&&e.getRootNode();if(t.contains(e))return!0;if(i&&zt(i)){var n=e;do{if(n&&t.isSameNode(n))return!0;n=n.parentNode||n.host}while(n)}return!1}function Zt(t){return $t(t).getComputedStyle(t)}function te(t){return["table","td","th"].indexOf(Ht(t))>=0}function ee(t){return((Wt(t)?t.ownerDocument:t.document)||window.document).documentElement}function ie(t){return"html"===Ht(t)?t:t.assignedSlot||t.parentNode||(zt(t)?t.host:null)||ee(t)}function ne(t){return Bt(t)&&"fixed"!==Zt(t).position?t.offsetParent:null}function se(t){for(var e=$t(t),i=ne(t);i&&te(i)&&"static"===Zt(i).position;)i=ne(i);return i&&("html"===Ht(i)||"body"===Ht(i)&&"static"===Zt(i).position)?e:i||function(t){var e=/firefox/i.test(Xt());if(/Trident/i.test(Xt())&&Bt(t)&&"fixed"===Zt(t).position)return null;var i=ie(t);for(zt(i)&&(i=i.host);Bt(i)&&["html","body"].indexOf(Ht(i))<0;){var n=Zt(i);if("none"!==n.transform||"none"!==n.perspective||"paint"===n.contain||-1!==["transform","perspective"].indexOf(n.willChange)||e&&"filter"===n.willChange||e&&n.filter&&"none"!==n.filter)return i;i=i.parentNode}return null}(t)||e}function oe(t){return["top","bottom"].indexOf(t)>=0?"x":"y"}function re(t,e,i){return Vt(t,Kt(e,i))}function ae(t){return Object.assign({},{top:0,right:0,bottom:0,left:0},t)}function le(t,e){return e.reduce((function(e,i){return e[i]=t,e}),{})}const ce={name:"arrow",enabled:!0,phase:"main",fn:function(t){var e,i=t.state,n=t.name,s=t.options,o=i.elements.arrow,r=i.modifiersData.popperOffsets,a=qt(i.placement),l=oe(a),c=[_t,gt].indexOf(a)>=0?"height":"width";if(o&&r){var h=function(t,e){return ae("number"!=typeof(t="function"==typeof t?t(Object.assign({},e.rects,{placement:e.placement})):t)?t:le(t,vt))}(s.padding,i),d=Gt(o),u="y"===l?pt:_t,f="y"===l?mt:gt,p=i.rects.reference[c]+i.rects.reference[l]-r[l]-i.rects.popper[c],m=r[l]-i.rects.reference[l],g=se(o),_=g?"y"===l?g.clientHeight||0:g.clientWidth||0:0,b=p/2-m/2,v=h[u],y=_-d[c]-h[f],w=_/2-d[c]/2+b,A=re(v,w,y),E=l;i.modifiersData[n]=((e={})[E]=A,e.centerOffset=A-w,e)}},effect:function(t){var e=t.state,i=t.options.element,n=void 0===i?"[data-popper-arrow]":i;null!=n&&("string"!=typeof n||(n=e.elements.popper.querySelector(n)))&&Jt(e.elements.popper,n)&&(e.elements.arrow=n)},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]};function he(t){return t.split("-")[1]}var de={top:"auto",right:"auto",bottom:"auto",left:"auto"};function ue(t){var e,i=t.popper,n=t.popperRect,s=t.placement,o=t.variation,r=t.offsets,a=t.position,l=t.gpuAcceleration,c=t.adaptive,h=t.roundOffsets,d=t.isFixed,u=r.x,f=void 0===u?0:u,p=r.y,m=void 0===p?0:p,g="function"==typeof h?h({x:f,y:m}):{x:f,y:m};f=g.x,m=g.y;var _=r.hasOwnProperty("x"),b=r.hasOwnProperty("y"),v=_t,y=pt,w=window;if(c){var A=se(i),E="clientHeight",T="clientWidth";A===$t(i)&&"static"!==Zt(A=ee(i)).position&&"absolute"===a&&(E="scrollHeight",T="scrollWidth"),(s===pt||(s===_t||s===gt)&&o===wt)&&(y=mt,m-=(d&&A===w&&w.visualViewport?w.visualViewport.height:A[E])-n.height,m*=l?1:-1),s!==_t&&(s!==pt&&s!==mt||o!==wt)||(v=gt,f-=(d&&A===w&&w.visualViewport?w.visualViewport.width:A[T])-n.width,f*=l?1:-1)}var C,O=Object.assign({position:a},c&&de),x=!0===h?function(t,e){var i=t.x,n=t.y,s=e.devicePixelRatio||1;return{x:Qt(i*s)/s||0,y:Qt(n*s)/s||0}}({x:f,y:m},$t(i)):{x:f,y:m};return f=x.x,m=x.y,l?Object.assign({},O,((C={})[y]=b?"0":"",C[v]=_?"0":"",C.transform=(w.devicePixelRatio||1)<=1?"translate("+f+"px, "+m+"px)":"translate3d("+f+"px, "+m+"px, 0)",C)):Object.assign({},O,((e={})[y]=b?m+"px":"",e[v]=_?f+"px":"",e.transform="",e))}const fe={name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:function(t){var e=t.state,i=t.options,n=i.gpuAcceleration,s=void 0===n||n,o=i.adaptive,r=void 0===o||o,a=i.roundOffsets,l=void 0===a||a,c={placement:qt(e.placement),variation:he(e.placement),popper:e.elements.popper,popperRect:e.rects.popper,gpuAcceleration:s,isFixed:"fixed"===e.options.strategy};null!=e.modifiersData.popperOffsets&&(e.styles.popper=Object.assign({},e.styles.popper,ue(Object.assign({},c,{offsets:e.modifiersData.popperOffsets,position:e.options.strategy,adaptive:r,roundOffsets:l})))),null!=e.modifiersData.arrow&&(e.styles.arrow=Object.assign({},e.styles.arrow,ue(Object.assign({},c,{offsets:e.modifiersData.arrow,position:"absolute",adaptive:!1,roundOffsets:l})))),e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-placement":e.placement})},data:{}};var pe={passive:!0};const me={name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:function(t){var e=t.state,i=t.instance,n=t.options,s=n.scroll,o=void 0===s||s,r=n.resize,a=void 0===r||r,l=$t(e.elements.popper),c=[].concat(e.scrollParents.reference,e.scrollParents.popper);return o&&c.forEach((function(t){t.addEventListener("scroll",i.update,pe)})),a&&l.addEventListener("resize",i.update,pe),function(){o&&c.forEach((function(t){t.removeEventListener("scroll",i.update,pe)})),a&&l.removeEventListener("resize",i.update,pe)}},data:{}};var ge={left:"right",right:"left",bottom:"top",top:"bottom"};function _e(t){return t.replace(/left|right|bottom|top/g,(function(t){return ge[t]}))}var be={start:"end",end:"start"};function ve(t){return t.replace(/start|end/g,(function(t){return be[t]}))}function ye(t){var e=$t(t);return{scrollLeft:e.pageXOffset,scrollTop:e.pageYOffset}}function we(t){return Ut(ee(t)).left+ye(t).scrollLeft}function Ae(t){var e=Zt(t),i=e.overflow,n=e.overflowX,s=e.overflowY;return/auto|scroll|overlay|hidden/.test(i+s+n)}function Ee(t){return["html","body","#document"].indexOf(Ht(t))>=0?t.ownerDocument.body:Bt(t)&&Ae(t)?t:Ee(ie(t))}function Te(t,e){var i;void 0===e&&(e=[]);var n=Ee(t),s=n===(null==(i=t.ownerDocument)?void 0:i.body),o=$t(n),r=s?[o].concat(o.visualViewport||[],Ae(n)?n:[]):n,a=e.concat(r);return s?a:a.concat(Te(ie(r)))}function Ce(t){return Object.assign({},t,{left:t.x,top:t.y,right:t.x+t.width,bottom:t.y+t.height})}function Oe(t,e,i){return e===Et?Ce(function(t,e){var i=$t(t),n=ee(t),s=i.visualViewport,o=n.clientWidth,r=n.clientHeight,a=0,l=0;if(s){o=s.width,r=s.height;var c=Yt();(c||!c&&"fixed"===e)&&(a=s.offsetLeft,l=s.offsetTop)}return{width:o,height:r,x:a+we(t),y:l}}(t,i)):Wt(e)?function(t,e){var i=Ut(t,!1,"fixed"===e);return i.top=i.top+t.clientTop,i.left=i.left+t.clientLeft,i.bottom=i.top+t.clientHeight,i.right=i.left+t.clientWidth,i.width=t.clientWidth,i.height=t.clientHeight,i.x=i.left,i.y=i.top,i}(e,i):Ce(function(t){var e,i=ee(t),n=ye(t),s=null==(e=t.ownerDocument)?void 0:e.body,o=Vt(i.scrollWidth,i.clientWidth,s?s.scrollWidth:0,s?s.clientWidth:0),r=Vt(i.scrollHeight,i.clientHeight,s?s.scrollHeight:0,s?s.clientHeight:0),a=-n.scrollLeft+we(t),l=-n.scrollTop;return"rtl"===Zt(s||i).direction&&(a+=Vt(i.clientWidth,s?s.clientWidth:0)-o),{width:o,height:r,x:a,y:l}}(ee(t)))}function xe(t){var e,i=t.reference,n=t.element,s=t.placement,o=s?qt(s):null,r=s?he(s):null,a=i.x+i.width/2-n.width/2,l=i.y+i.height/2-n.height/2;switch(o){case pt:e={x:a,y:i.y-n.height};break;case mt:e={x:a,y:i.y+i.height};break;case gt:e={x:i.x+i.width,y:l};break;case _t:e={x:i.x-n.width,y:l};break;default:e={x:i.x,y:i.y}}var c=o?oe(o):null;if(null!=c){var h="y"===c?"height":"width";switch(r){case yt:e[c]=e[c]-(i[h]/2-n[h]/2);break;case wt:e[c]=e[c]+(i[h]/2-n[h]/2)}}return e}function ke(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=void 0===n?t.placement:n,o=i.strategy,r=void 0===o?t.strategy:o,a=i.boundary,l=void 0===a?At:a,c=i.rootBoundary,h=void 0===c?Et:c,d=i.elementContext,u=void 0===d?Tt:d,f=i.altBoundary,p=void 0!==f&&f,m=i.padding,g=void 0===m?0:m,_=ae("number"!=typeof g?g:le(g,vt)),b=u===Tt?Ct:Tt,v=t.rects.popper,y=t.elements[p?b:u],w=function(t,e,i,n){var s="clippingParents"===e?function(t){var e=Te(ie(t)),i=["absolute","fixed"].indexOf(Zt(t).position)>=0&&Bt(t)?se(t):t;return Wt(i)?e.filter((function(t){return Wt(t)&&Jt(t,i)&&"body"!==Ht(t)})):[]}(t):[].concat(e),o=[].concat(s,[i]),r=o[0],a=o.reduce((function(e,i){var s=Oe(t,i,n);return e.top=Vt(s.top,e.top),e.right=Kt(s.right,e.right),e.bottom=Kt(s.bottom,e.bottom),e.left=Vt(s.left,e.left),e}),Oe(t,r,n));return a.width=a.right-a.left,a.height=a.bottom-a.top,a.x=a.left,a.y=a.top,a}(Wt(y)?y:y.contextElement||ee(t.elements.popper),l,h,r),A=Ut(t.elements.reference),E=xe({reference:A,element:v,strategy:"absolute",placement:s}),T=Ce(Object.assign({},v,E)),C=u===Tt?T:A,O={top:w.top-C.top+_.top,bottom:C.bottom-w.bottom+_.bottom,left:w.left-C.left+_.left,right:C.right-w.right+_.right},x=t.modifiersData.offset;if(u===Tt&&x){var k=x[s];Object.keys(O).forEach((function(t){var e=[gt,mt].indexOf(t)>=0?1:-1,i=[pt,mt].indexOf(t)>=0?"y":"x";O[t]+=k[i]*e}))}return O}function Le(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=i.boundary,o=i.rootBoundary,r=i.padding,a=i.flipVariations,l=i.allowedAutoPlacements,c=void 0===l?xt:l,h=he(n),d=h?a?Ot:Ot.filter((function(t){return he(t)===h})):vt,u=d.filter((function(t){return c.indexOf(t)>=0}));0===u.length&&(u=d);var f=u.reduce((function(e,i){return e[i]=ke(t,{placement:i,boundary:s,rootBoundary:o,padding:r})[qt(i)],e}),{});return Object.keys(f).sort((function(t,e){return f[t]-f[e]}))}const Se={name:"flip",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name;if(!e.modifiersData[n]._skip){for(var s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0===r||r,l=i.fallbackPlacements,c=i.padding,h=i.boundary,d=i.rootBoundary,u=i.altBoundary,f=i.flipVariations,p=void 0===f||f,m=i.allowedAutoPlacements,g=e.options.placement,_=qt(g),b=l||(_!==g&&p?function(t){if(qt(t)===bt)return[];var e=_e(t);return[ve(t),e,ve(e)]}(g):[_e(g)]),v=[g].concat(b).reduce((function(t,i){return t.concat(qt(i)===bt?Le(e,{placement:i,boundary:h,rootBoundary:d,padding:c,flipVariations:p,allowedAutoPlacements:m}):i)}),[]),y=e.rects.reference,w=e.rects.popper,A=new Map,E=!0,T=v[0],C=0;C=0,S=L?"width":"height",D=ke(e,{placement:O,boundary:h,rootBoundary:d,altBoundary:u,padding:c}),I=L?k?gt:_t:k?mt:pt;y[S]>w[S]&&(I=_e(I));var N=_e(I),P=[];if(o&&P.push(D[x]<=0),a&&P.push(D[I]<=0,D[N]<=0),P.every((function(t){return t}))){T=O,E=!1;break}A.set(O,P)}if(E)for(var M=function(t){var e=v.find((function(e){var i=A.get(e);if(i)return i.slice(0,t).every((function(t){return t}))}));if(e)return T=e,"break"},j=p?3:1;j>0&&"break"!==M(j);j--);e.placement!==T&&(e.modifiersData[n]._skip=!0,e.placement=T,e.reset=!0)}},requiresIfExists:["offset"],data:{_skip:!1}};function De(t,e,i){return void 0===i&&(i={x:0,y:0}),{top:t.top-e.height-i.y,right:t.right-e.width+i.x,bottom:t.bottom-e.height+i.y,left:t.left-e.width-i.x}}function Ie(t){return[pt,gt,mt,_t].some((function(e){return t[e]>=0}))}const Ne={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(t){var e=t.state,i=t.name,n=e.rects.reference,s=e.rects.popper,o=e.modifiersData.preventOverflow,r=ke(e,{elementContext:"reference"}),a=ke(e,{altBoundary:!0}),l=De(r,n),c=De(a,s,o),h=Ie(l),d=Ie(c);e.modifiersData[i]={referenceClippingOffsets:l,popperEscapeOffsets:c,isReferenceHidden:h,hasPopperEscaped:d},e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-reference-hidden":h,"data-popper-escaped":d})}},Pe={name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.offset,o=void 0===s?[0,0]:s,r=xt.reduce((function(t,i){return t[i]=function(t,e,i){var n=qt(t),s=[_t,pt].indexOf(n)>=0?-1:1,o="function"==typeof i?i(Object.assign({},e,{placement:t})):i,r=o[0],a=o[1];return r=r||0,a=(a||0)*s,[_t,gt].indexOf(n)>=0?{x:a,y:r}:{x:r,y:a}}(i,e.rects,o),t}),{}),a=r[e.placement],l=a.x,c=a.y;null!=e.modifiersData.popperOffsets&&(e.modifiersData.popperOffsets.x+=l,e.modifiersData.popperOffsets.y+=c),e.modifiersData[n]=r}},Me={name:"popperOffsets",enabled:!0,phase:"read",fn:function(t){var e=t.state,i=t.name;e.modifiersData[i]=xe({reference:e.rects.reference,element:e.rects.popper,strategy:"absolute",placement:e.placement})},data:{}},je={name:"preventOverflow",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0!==r&&r,l=i.boundary,c=i.rootBoundary,h=i.altBoundary,d=i.padding,u=i.tether,f=void 0===u||u,p=i.tetherOffset,m=void 0===p?0:p,g=ke(e,{boundary:l,rootBoundary:c,padding:d,altBoundary:h}),_=qt(e.placement),b=he(e.placement),v=!b,y=oe(_),w="x"===y?"y":"x",A=e.modifiersData.popperOffsets,E=e.rects.reference,T=e.rects.popper,C="function"==typeof m?m(Object.assign({},e.rects,{placement:e.placement})):m,O="number"==typeof C?{mainAxis:C,altAxis:C}:Object.assign({mainAxis:0,altAxis:0},C),x=e.modifiersData.offset?e.modifiersData.offset[e.placement]:null,k={x:0,y:0};if(A){if(o){var L,S="y"===y?pt:_t,D="y"===y?mt:gt,I="y"===y?"height":"width",N=A[y],P=N+g[S],M=N-g[D],j=f?-T[I]/2:0,F=b===yt?E[I]:T[I],H=b===yt?-T[I]:-E[I],$=e.elements.arrow,W=f&&$?Gt($):{width:0,height:0},B=e.modifiersData["arrow#persistent"]?e.modifiersData["arrow#persistent"].padding:{top:0,right:0,bottom:0,left:0},z=B[S],R=B[D],q=re(0,E[I],W[I]),V=v?E[I]/2-j-q-z-O.mainAxis:F-q-z-O.mainAxis,K=v?-E[I]/2+j+q+R+O.mainAxis:H+q+R+O.mainAxis,Q=e.elements.arrow&&se(e.elements.arrow),X=Q?"y"===y?Q.clientTop||0:Q.clientLeft||0:0,Y=null!=(L=null==x?void 0:x[y])?L:0,U=N+K-Y,G=re(f?Kt(P,N+V-Y-X):P,N,f?Vt(M,U):M);A[y]=G,k[y]=G-N}if(a){var J,Z="x"===y?pt:_t,tt="x"===y?mt:gt,et=A[w],it="y"===w?"height":"width",nt=et+g[Z],st=et-g[tt],ot=-1!==[pt,_t].indexOf(_),rt=null!=(J=null==x?void 0:x[w])?J:0,at=ot?nt:et-E[it]-T[it]-rt+O.altAxis,lt=ot?et+E[it]+T[it]-rt-O.altAxis:st,ct=f&&ot?function(t,e,i){var n=re(t,e,i);return n>i?i:n}(at,et,lt):re(f?at:nt,et,f?lt:st);A[w]=ct,k[w]=ct-et}e.modifiersData[n]=k}},requiresIfExists:["offset"]};function Fe(t,e,i){void 0===i&&(i=!1);var n,s,o=Bt(e),r=Bt(e)&&function(t){var e=t.getBoundingClientRect(),i=Qt(e.width)/t.offsetWidth||1,n=Qt(e.height)/t.offsetHeight||1;return 1!==i||1!==n}(e),a=ee(e),l=Ut(t,r,i),c={scrollLeft:0,scrollTop:0},h={x:0,y:0};return(o||!o&&!i)&&(("body"!==Ht(e)||Ae(a))&&(c=(n=e)!==$t(n)&&Bt(n)?{scrollLeft:(s=n).scrollLeft,scrollTop:s.scrollTop}:ye(n)),Bt(e)?((h=Ut(e,!0)).x+=e.clientLeft,h.y+=e.clientTop):a&&(h.x=we(a))),{x:l.left+c.scrollLeft-h.x,y:l.top+c.scrollTop-h.y,width:l.width,height:l.height}}function He(t){var e=new Map,i=new Set,n=[];function s(t){i.add(t.name),[].concat(t.requires||[],t.requiresIfExists||[]).forEach((function(t){if(!i.has(t)){var n=e.get(t);n&&s(n)}})),n.push(t)}return t.forEach((function(t){e.set(t.name,t)})),t.forEach((function(t){i.has(t.name)||s(t)})),n}var $e={placement:"bottom",modifiers:[],strategy:"absolute"};function We(){for(var t=arguments.length,e=new Array(t),i=0;iNumber.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_getPopperConfig(){const t={placement:this._getPlacement(),modifiers:[{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"offset",options:{offset:this._getOffset()}}]};return(this._inNavbar||"static"===this._config.display)&&(H.setDataAttribute(this._menu,"popper","static"),t.modifiers=[{name:"applyStyles",enabled:!1}]),{...t,...g(this._config.popperConfig,[t])}}_selectMenuItem({key:t,target:e}){const i=z.find(".dropdown-menu .dropdown-item:not(.disabled):not(:disabled)",this._menu).filter((t=>a(t)));i.length&&b(i,e,t===Xe,!i.includes(e)).focus()}static jQueryInterface(t){return this.each((function(){const e=ci.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}static clearMenus(t){if(2===t.button||"keyup"===t.type&&"Tab"!==t.key)return;const e=z.find(Ze);for(const i of e){const e=ci.getInstance(i);if(!e||!1===e._config.autoClose)continue;const n=t.composedPath(),s=n.includes(e._menu);if(n.includes(e._element)||"inside"===e._config.autoClose&&!s||"outside"===e._config.autoClose&&s)continue;if(e._menu.contains(t.target)&&("keyup"===t.type&&"Tab"===t.key||/input|select|option|textarea|form/i.test(t.target.tagName)))continue;const o={relatedTarget:e._element};"click"===t.type&&(o.clickEvent=t),e._completeHide(o)}}static dataApiKeydownHandler(t){const e=/input|textarea/i.test(t.target.tagName),i="Escape"===t.key,n=[Qe,Xe].includes(t.key);if(!n&&!i)return;if(e&&!i)return;t.preventDefault();const s=this.matches(Je)?this:z.prev(this,Je)[0]||z.next(this,Je)[0]||z.findOne(Je,t.delegateTarget.parentNode),o=ci.getOrCreateInstance(s);if(n)return t.stopPropagation(),o.show(),void o._selectMenuItem(t);o._isShown()&&(t.stopPropagation(),o.hide(),s.focus())}}P.on(document,Ue,Je,ci.dataApiKeydownHandler),P.on(document,Ue,ti,ci.dataApiKeydownHandler),P.on(document,Ye,ci.clearMenus),P.on(document,"keyup.bs.dropdown.data-api",ci.clearMenus),P.on(document,Ye,Je,(function(t){t.preventDefault(),ci.getOrCreateInstance(this).toggle()})),m(ci);const hi="show",di="mousedown.bs.backdrop",ui={className:"modal-backdrop",clickCallback:null,isAnimated:!1,isVisible:!0,rootElement:"body"},fi={className:"string",clickCallback:"(function|null)",isAnimated:"boolean",isVisible:"boolean",rootElement:"(element|string)"};class pi extends ${constructor(t){super(),this._config=this._getConfig(t),this._isAppended=!1,this._element=null}static get Default(){return ui}static get DefaultType(){return fi}static get NAME(){return"backdrop"}show(t){if(!this._config.isVisible)return void g(t);this._append();const e=this._getElement();this._config.isAnimated&&d(e),e.classList.add(hi),this._emulateAnimation((()=>{g(t)}))}hide(t){this._config.isVisible?(this._getElement().classList.remove(hi),this._emulateAnimation((()=>{this.dispose(),g(t)}))):g(t)}dispose(){this._isAppended&&(P.off(this._element,di),this._element.remove(),this._isAppended=!1)}_getElement(){if(!this._element){const t=document.createElement("div");t.className=this._config.className,this._config.isAnimated&&t.classList.add("fade"),this._element=t}return this._element}_configAfterMerge(t){return t.rootElement=r(t.rootElement),t}_append(){if(this._isAppended)return;const t=this._getElement();this._config.rootElement.append(t),P.on(t,di,(()=>{g(this._config.clickCallback)})),this._isAppended=!0}_emulateAnimation(t){_(t,this._getElement(),this._config.isAnimated)}}const mi=".bs.focustrap",gi="backward",_i={autofocus:!0,trapElement:null},bi={autofocus:"boolean",trapElement:"element"};class vi extends ${constructor(t){super(),this._config=this._getConfig(t),this._isActive=!1,this._lastTabNavDirection=null}static get Default(){return _i}static get DefaultType(){return bi}static get NAME(){return"focustrap"}activate(){this._isActive||(this._config.autofocus&&this._config.trapElement.focus(),P.off(document,mi),P.on(document,"focusin.bs.focustrap",(t=>this._handleFocusin(t))),P.on(document,"keydown.tab.bs.focustrap",(t=>this._handleKeydown(t))),this._isActive=!0)}deactivate(){this._isActive&&(this._isActive=!1,P.off(document,mi))}_handleFocusin(t){const{trapElement:e}=this._config;if(t.target===document||t.target===e||e.contains(t.target))return;const i=z.focusableChildren(e);0===i.length?e.focus():this._lastTabNavDirection===gi?i[i.length-1].focus():i[0].focus()}_handleKeydown(t){"Tab"===t.key&&(this._lastTabNavDirection=t.shiftKey?gi:"forward")}}const yi=".fixed-top, .fixed-bottom, .is-fixed, .sticky-top",wi=".sticky-top",Ai="padding-right",Ei="margin-right";class Ti{constructor(){this._element=document.body}getWidth(){const t=document.documentElement.clientWidth;return Math.abs(window.innerWidth-t)}hide(){const t=this.getWidth();this._disableOverFlow(),this._setElementAttributes(this._element,Ai,(e=>e+t)),this._setElementAttributes(yi,Ai,(e=>e+t)),this._setElementAttributes(wi,Ei,(e=>e-t))}reset(){this._resetElementAttributes(this._element,"overflow"),this._resetElementAttributes(this._element,Ai),this._resetElementAttributes(yi,Ai),this._resetElementAttributes(wi,Ei)}isOverflowing(){return this.getWidth()>0}_disableOverFlow(){this._saveInitialAttribute(this._element,"overflow"),this._element.style.overflow="hidden"}_setElementAttributes(t,e,i){const n=this.getWidth();this._applyManipulationCallback(t,(t=>{if(t!==this._element&&window.innerWidth>t.clientWidth+n)return;this._saveInitialAttribute(t,e);const s=window.getComputedStyle(t).getPropertyValue(e);t.style.setProperty(e,`${i(Number.parseFloat(s))}px`)}))}_saveInitialAttribute(t,e){const i=t.style.getPropertyValue(e);i&&H.setDataAttribute(t,e,i)}_resetElementAttributes(t,e){this._applyManipulationCallback(t,(t=>{const i=H.getDataAttribute(t,e);null!==i?(H.removeDataAttribute(t,e),t.style.setProperty(e,i)):t.style.removeProperty(e)}))}_applyManipulationCallback(t,e){if(o(t))e(t);else for(const i of z.find(t,this._element))e(i)}}const Ci=".bs.modal",Oi="hidden.bs.modal",xi="show.bs.modal",ki="modal-open",Li="show",Si="modal-static",Di={backdrop:!0,focus:!0,keyboard:!0},Ii={backdrop:"(boolean|string)",focus:"boolean",keyboard:"boolean"};class Ni extends W{constructor(t,e){super(t,e),this._dialog=z.findOne(".modal-dialog",this._element),this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._isShown=!1,this._isTransitioning=!1,this._scrollBar=new Ti,this._addEventListeners()}static get Default(){return Di}static get DefaultType(){return Ii}static get NAME(){return"modal"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||this._isTransitioning||P.trigger(this._element,xi,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._isTransitioning=!0,this._scrollBar.hide(),document.body.classList.add(ki),this._adjustDialog(),this._backdrop.show((()=>this._showElement(t))))}hide(){this._isShown&&!this._isTransitioning&&(P.trigger(this._element,"hide.bs.modal").defaultPrevented||(this._isShown=!1,this._isTransitioning=!0,this._focustrap.deactivate(),this._element.classList.remove(Li),this._queueCallback((()=>this._hideModal()),this._element,this._isAnimated())))}dispose(){P.off(window,Ci),P.off(this._dialog,Ci),this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}handleUpdate(){this._adjustDialog()}_initializeBackDrop(){return new pi({isVisible:Boolean(this._config.backdrop),isAnimated:this._isAnimated()})}_initializeFocusTrap(){return new vi({trapElement:this._element})}_showElement(t){document.body.contains(this._element)||document.body.append(this._element),this._element.style.display="block",this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.scrollTop=0;const e=z.findOne(".modal-body",this._dialog);e&&(e.scrollTop=0),d(this._element),this._element.classList.add(Li),this._queueCallback((()=>{this._config.focus&&this._focustrap.activate(),this._isTransitioning=!1,P.trigger(this._element,"shown.bs.modal",{relatedTarget:t})}),this._dialog,this._isAnimated())}_addEventListeners(){P.on(this._element,"keydown.dismiss.bs.modal",(t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():this._triggerBackdropTransition())})),P.on(window,"resize.bs.modal",(()=>{this._isShown&&!this._isTransitioning&&this._adjustDialog()})),P.on(this._element,"mousedown.dismiss.bs.modal",(t=>{P.one(this._element,"click.dismiss.bs.modal",(e=>{this._element===t.target&&this._element===e.target&&("static"!==this._config.backdrop?this._config.backdrop&&this.hide():this._triggerBackdropTransition())}))}))}_hideModal(){this._element.style.display="none",this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._isTransitioning=!1,this._backdrop.hide((()=>{document.body.classList.remove(ki),this._resetAdjustments(),this._scrollBar.reset(),P.trigger(this._element,Oi)}))}_isAnimated(){return this._element.classList.contains("fade")}_triggerBackdropTransition(){if(P.trigger(this._element,"hidePrevented.bs.modal").defaultPrevented)return;const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._element.style.overflowY;"hidden"===e||this._element.classList.contains(Si)||(t||(this._element.style.overflowY="hidden"),this._element.classList.add(Si),this._queueCallback((()=>{this._element.classList.remove(Si),this._queueCallback((()=>{this._element.style.overflowY=e}),this._dialog)}),this._dialog),this._element.focus())}_adjustDialog(){const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._scrollBar.getWidth(),i=e>0;if(i&&!t){const t=p()?"paddingLeft":"paddingRight";this._element.style[t]=`${e}px`}if(!i&&t){const t=p()?"paddingRight":"paddingLeft";this._element.style[t]=`${e}px`}}_resetAdjustments(){this._element.style.paddingLeft="",this._element.style.paddingRight=""}static jQueryInterface(t,e){return this.each((function(){const i=Ni.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t](e)}}))}}P.on(document,"click.bs.modal.data-api",'[data-bs-toggle="modal"]',(function(t){const e=z.getElementFromSelector(this);["A","AREA"].includes(this.tagName)&&t.preventDefault(),P.one(e,xi,(t=>{t.defaultPrevented||P.one(e,Oi,(()=>{a(this)&&this.focus()}))}));const i=z.findOne(".modal.show");i&&Ni.getInstance(i).hide(),Ni.getOrCreateInstance(e).toggle(this)})),R(Ni),m(Ni);const Pi="show",Mi="showing",ji="hiding",Fi=".offcanvas.show",Hi="hidePrevented.bs.offcanvas",$i="hidden.bs.offcanvas",Wi={backdrop:!0,keyboard:!0,scroll:!1},Bi={backdrop:"(boolean|string)",keyboard:"boolean",scroll:"boolean"};class zi extends W{constructor(t,e){super(t,e),this._isShown=!1,this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._addEventListeners()}static get Default(){return Wi}static get DefaultType(){return Bi}static get NAME(){return"offcanvas"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||P.trigger(this._element,"show.bs.offcanvas",{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._backdrop.show(),this._config.scroll||(new Ti).hide(),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.classList.add(Mi),this._queueCallback((()=>{this._config.scroll&&!this._config.backdrop||this._focustrap.activate(),this._element.classList.add(Pi),this._element.classList.remove(Mi),P.trigger(this._element,"shown.bs.offcanvas",{relatedTarget:t})}),this._element,!0))}hide(){this._isShown&&(P.trigger(this._element,"hide.bs.offcanvas").defaultPrevented||(this._focustrap.deactivate(),this._element.blur(),this._isShown=!1,this._element.classList.add(ji),this._backdrop.hide(),this._queueCallback((()=>{this._element.classList.remove(Pi,ji),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._config.scroll||(new Ti).reset(),P.trigger(this._element,$i)}),this._element,!0)))}dispose(){this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}_initializeBackDrop(){const t=Boolean(this._config.backdrop);return new pi({className:"offcanvas-backdrop",isVisible:t,isAnimated:!0,rootElement:this._element.parentNode,clickCallback:t?()=>{"static"!==this._config.backdrop?this.hide():P.trigger(this._element,Hi)}:null})}_initializeFocusTrap(){return new vi({trapElement:this._element})}_addEventListeners(){P.on(this._element,"keydown.dismiss.bs.offcanvas",(t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():P.trigger(this._element,Hi))}))}static jQueryInterface(t){return this.each((function(){const e=zi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}P.on(document,"click.bs.offcanvas.data-api",'[data-bs-toggle="offcanvas"]',(function(t){const e=z.getElementFromSelector(this);if(["A","AREA"].includes(this.tagName)&&t.preventDefault(),l(this))return;P.one(e,$i,(()=>{a(this)&&this.focus()}));const i=z.findOne(Fi);i&&i!==e&&zi.getInstance(i).hide(),zi.getOrCreateInstance(e).toggle(this)})),P.on(window,"load.bs.offcanvas.data-api",(()=>{for(const t of z.find(Fi))zi.getOrCreateInstance(t).show()})),P.on(window,"resize.bs.offcanvas",(()=>{for(const t of z.find("[aria-modal][class*=show][class*=offcanvas-]"))"fixed"!==getComputedStyle(t).position&&zi.getOrCreateInstance(t).hide()})),R(zi),m(zi);const Ri={"*":["class","dir","id","lang","role",/^aria-[\w-]*$/i],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],div:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","srcset","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},qi=new Set(["background","cite","href","itemtype","longdesc","poster","src","xlink:href"]),Vi=/^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i,Ki=(t,e)=>{const i=t.nodeName.toLowerCase();return e.includes(i)?!qi.has(i)||Boolean(Vi.test(t.nodeValue)):e.filter((t=>t instanceof RegExp)).some((t=>t.test(i)))},Qi={allowList:Ri,content:{},extraClass:"",html:!1,sanitize:!0,sanitizeFn:null,template:"

"},Xi={allowList:"object",content:"object",extraClass:"(string|function)",html:"boolean",sanitize:"boolean",sanitizeFn:"(null|function)",template:"string"},Yi={entry:"(string|element|function|null)",selector:"(string|element)"};class Ui extends ${constructor(t){super(),this._config=this._getConfig(t)}static get Default(){return Qi}static get DefaultType(){return Xi}static get NAME(){return"TemplateFactory"}getContent(){return Object.values(this._config.content).map((t=>this._resolvePossibleFunction(t))).filter(Boolean)}hasContent(){return this.getContent().length>0}changeContent(t){return this._checkContent(t),this._config.content={...this._config.content,...t},this}toHtml(){const t=document.createElement("div");t.innerHTML=this._maybeSanitize(this._config.template);for(const[e,i]of Object.entries(this._config.content))this._setContent(t,i,e);const e=t.children[0],i=this._resolvePossibleFunction(this._config.extraClass);return i&&e.classList.add(...i.split(" ")),e}_typeCheckConfig(t){super._typeCheckConfig(t),this._checkContent(t.content)}_checkContent(t){for(const[e,i]of Object.entries(t))super._typeCheckConfig({selector:e,entry:i},Yi)}_setContent(t,e,i){const n=z.findOne(i,t);n&&((e=this._resolvePossibleFunction(e))?o(e)?this._putElementInTemplate(r(e),n):this._config.html?n.innerHTML=this._maybeSanitize(e):n.textContent=e:n.remove())}_maybeSanitize(t){return this._config.sanitize?function(t,e,i){if(!t.length)return t;if(i&&"function"==typeof i)return i(t);const n=(new window.DOMParser).parseFromString(t,"text/html"),s=[].concat(...n.body.querySelectorAll("*"));for(const t of s){const i=t.nodeName.toLowerCase();if(!Object.keys(e).includes(i)){t.remove();continue}const n=[].concat(...t.attributes),s=[].concat(e["*"]||[],e[i]||[]);for(const e of n)Ki(e,s)||t.removeAttribute(e.nodeName)}return n.body.innerHTML}(t,this._config.allowList,this._config.sanitizeFn):t}_resolvePossibleFunction(t){return g(t,[this])}_putElementInTemplate(t,e){if(this._config.html)return e.innerHTML="",void e.append(t);e.textContent=t.textContent}}const Gi=new Set(["sanitize","allowList","sanitizeFn"]),Ji="fade",Zi="show",tn=".modal",en="hide.bs.modal",nn="hover",sn="focus",on={AUTO:"auto",TOP:"top",RIGHT:p()?"left":"right",BOTTOM:"bottom",LEFT:p()?"right":"left"},rn={allowList:Ri,animation:!0,boundary:"clippingParents",container:!1,customClass:"",delay:0,fallbackPlacements:["top","right","bottom","left"],html:!1,offset:[0,6],placement:"top",popperConfig:null,sanitize:!0,sanitizeFn:null,selector:!1,template:'',title:"",trigger:"hover focus"},an={allowList:"object",animation:"boolean",boundary:"(string|element)",container:"(string|element|boolean)",customClass:"(string|function)",delay:"(number|object)",fallbackPlacements:"array",html:"boolean",offset:"(array|string|function)",placement:"(string|function)",popperConfig:"(null|object|function)",sanitize:"boolean",sanitizeFn:"(null|function)",selector:"(string|boolean)",template:"string",title:"(string|element|function)",trigger:"string"};class ln extends W{constructor(t,e){if(void 0===Ve)throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org)");super(t,e),this._isEnabled=!0,this._timeout=0,this._isHovered=null,this._activeTrigger={},this._popper=null,this._templateFactory=null,this._newContent=null,this.tip=null,this._setListeners(),this._config.selector||this._fixTitle()}static get Default(){return rn}static get DefaultType(){return an}static get NAME(){return"tooltip"}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(){this._isEnabled&&(this._activeTrigger.click=!this._activeTrigger.click,this._isShown()?this._leave():this._enter())}dispose(){clearTimeout(this._timeout),P.off(this._element.closest(tn),en,this._hideModalHandler),this._element.getAttribute("data-bs-original-title")&&this._element.setAttribute("title",this._element.getAttribute("data-bs-original-title")),this._disposePopper(),super.dispose()}show(){if("none"===this._element.style.display)throw new Error("Please use show on visible elements");if(!this._isWithContent()||!this._isEnabled)return;const t=P.trigger(this._element,this.constructor.eventName("show")),e=(c(this._element)||this._element.ownerDocument.documentElement).contains(this._element);if(t.defaultPrevented||!e)return;this._disposePopper();const i=this._getTipElement();this._element.setAttribute("aria-describedby",i.getAttribute("id"));const{container:n}=this._config;if(this._element.ownerDocument.documentElement.contains(this.tip)||(n.append(i),P.trigger(this._element,this.constructor.eventName("inserted"))),this._popper=this._createPopper(i),i.classList.add(Zi),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))P.on(t,"mouseover",h);this._queueCallback((()=>{P.trigger(this._element,this.constructor.eventName("shown")),!1===this._isHovered&&this._leave(),this._isHovered=!1}),this.tip,this._isAnimated())}hide(){if(this._isShown()&&!P.trigger(this._element,this.constructor.eventName("hide")).defaultPrevented){if(this._getTipElement().classList.remove(Zi),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))P.off(t,"mouseover",h);this._activeTrigger.click=!1,this._activeTrigger.focus=!1,this._activeTrigger.hover=!1,this._isHovered=null,this._queueCallback((()=>{this._isWithActiveTrigger()||(this._isHovered||this._disposePopper(),this._element.removeAttribute("aria-describedby"),P.trigger(this._element,this.constructor.eventName("hidden")))}),this.tip,this._isAnimated())}}update(){this._popper&&this._popper.update()}_isWithContent(){return Boolean(this._getTitle())}_getTipElement(){return this.tip||(this.tip=this._createTipElement(this._newContent||this._getContentForTemplate())),this.tip}_createTipElement(t){const e=this._getTemplateFactory(t).toHtml();if(!e)return null;e.classList.remove(Ji,Zi),e.classList.add(`bs-${this.constructor.NAME}-auto`);const i=(t=>{do{t+=Math.floor(1e6*Math.random())}while(document.getElementById(t));return t})(this.constructor.NAME).toString();return e.setAttribute("id",i),this._isAnimated()&&e.classList.add(Ji),e}setContent(t){this._newContent=t,this._isShown()&&(this._disposePopper(),this.show())}_getTemplateFactory(t){return this._templateFactory?this._templateFactory.changeContent(t):this._templateFactory=new Ui({...this._config,content:t,extraClass:this._resolvePossibleFunction(this._config.customClass)}),this._templateFactory}_getContentForTemplate(){return{".tooltip-inner":this._getTitle()}}_getTitle(){return this._resolvePossibleFunction(this._config.title)||this._element.getAttribute("data-bs-original-title")}_initializeOnDelegatedTarget(t){return this.constructor.getOrCreateInstance(t.delegateTarget,this._getDelegateConfig())}_isAnimated(){return this._config.animation||this.tip&&this.tip.classList.contains(Ji)}_isShown(){return this.tip&&this.tip.classList.contains(Zi)}_createPopper(t){const e=g(this._config.placement,[this,t,this._element]),i=on[e.toUpperCase()];return qe(this._element,t,this._getPopperConfig(i))}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map((t=>Number.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_resolvePossibleFunction(t){return g(t,[this._element])}_getPopperConfig(t){const e={placement:t,modifiers:[{name:"flip",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:"offset",options:{offset:this._getOffset()}},{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"arrow",options:{element:`.${this.constructor.NAME}-arrow`}},{name:"preSetPlacement",enabled:!0,phase:"beforeMain",fn:t=>{this._getTipElement().setAttribute("data-popper-placement",t.state.placement)}}]};return{...e,...g(this._config.popperConfig,[e])}}_setListeners(){const t=this._config.trigger.split(" ");for(const e of t)if("click"===e)P.on(this._element,this.constructor.eventName("click"),this._config.selector,(t=>{this._initializeOnDelegatedTarget(t).toggle()}));else if("manual"!==e){const t=e===nn?this.constructor.eventName("mouseenter"):this.constructor.eventName("focusin"),i=e===nn?this.constructor.eventName("mouseleave"):this.constructor.eventName("focusout");P.on(this._element,t,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusin"===t.type?sn:nn]=!0,e._enter()})),P.on(this._element,i,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusout"===t.type?sn:nn]=e._element.contains(t.relatedTarget),e._leave()}))}this._hideModalHandler=()=>{this._element&&this.hide()},P.on(this._element.closest(tn),en,this._hideModalHandler)}_fixTitle(){const t=this._element.getAttribute("title");t&&(this._element.getAttribute("aria-label")||this._element.textContent.trim()||this._element.setAttribute("aria-label",t),this._element.setAttribute("data-bs-original-title",t),this._element.removeAttribute("title"))}_enter(){this._isShown()||this._isHovered?this._isHovered=!0:(this._isHovered=!0,this._setTimeout((()=>{this._isHovered&&this.show()}),this._config.delay.show))}_leave(){this._isWithActiveTrigger()||(this._isHovered=!1,this._setTimeout((()=>{this._isHovered||this.hide()}),this._config.delay.hide))}_setTimeout(t,e){clearTimeout(this._timeout),this._timeout=setTimeout(t,e)}_isWithActiveTrigger(){return Object.values(this._activeTrigger).includes(!0)}_getConfig(t){const e=H.getDataAttributes(this._element);for(const t of Object.keys(e))Gi.has(t)&&delete e[t];return t={...e,..."object"==typeof t&&t?t:{}},t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t.container=!1===t.container?document.body:r(t.container),"number"==typeof t.delay&&(t.delay={show:t.delay,hide:t.delay}),"number"==typeof t.title&&(t.title=t.title.toString()),"number"==typeof t.content&&(t.content=t.content.toString()),t}_getDelegateConfig(){const t={};for(const[e,i]of Object.entries(this._config))this.constructor.Default[e]!==i&&(t[e]=i);return t.selector=!1,t.trigger="manual",t}_disposePopper(){this._popper&&(this._popper.destroy(),this._popper=null),this.tip&&(this.tip.remove(),this.tip=null)}static jQueryInterface(t){return this.each((function(){const e=ln.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}m(ln);const cn={...ln.Default,content:"",offset:[0,8],placement:"right",template:'',trigger:"click"},hn={...ln.DefaultType,content:"(null|string|element|function)"};class dn extends ln{static get Default(){return cn}static get DefaultType(){return hn}static get NAME(){return"popover"}_isWithContent(){return this._getTitle()||this._getContent()}_getContentForTemplate(){return{".popover-header":this._getTitle(),".popover-body":this._getContent()}}_getContent(){return this._resolvePossibleFunction(this._config.content)}static jQueryInterface(t){return this.each((function(){const e=dn.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}m(dn);const un="click.bs.scrollspy",fn="active",pn="[href]",mn={offset:null,rootMargin:"0px 0px -25%",smoothScroll:!1,target:null,threshold:[.1,.5,1]},gn={offset:"(number|null)",rootMargin:"string",smoothScroll:"boolean",target:"element",threshold:"array"};class _n extends W{constructor(t,e){super(t,e),this._targetLinks=new Map,this._observableSections=new Map,this._rootElement="visible"===getComputedStyle(this._element).overflowY?null:this._element,this._activeTarget=null,this._observer=null,this._previousScrollData={visibleEntryTop:0,parentScrollTop:0},this.refresh()}static get Default(){return mn}static get DefaultType(){return gn}static get NAME(){return"scrollspy"}refresh(){this._initializeTargetsAndObservables(),this._maybeEnableSmoothScroll(),this._observer?this._observer.disconnect():this._observer=this._getNewObserver();for(const t of this._observableSections.values())this._observer.observe(t)}dispose(){this._observer.disconnect(),super.dispose()}_configAfterMerge(t){return t.target=r(t.target)||document.body,t.rootMargin=t.offset?`${t.offset}px 0px -30%`:t.rootMargin,"string"==typeof t.threshold&&(t.threshold=t.threshold.split(",").map((t=>Number.parseFloat(t)))),t}_maybeEnableSmoothScroll(){this._config.smoothScroll&&(P.off(this._config.target,un),P.on(this._config.target,un,pn,(t=>{const e=this._observableSections.get(t.target.hash);if(e){t.preventDefault();const i=this._rootElement||window,n=e.offsetTop-this._element.offsetTop;if(i.scrollTo)return void i.scrollTo({top:n,behavior:"smooth"});i.scrollTop=n}})))}_getNewObserver(){const t={root:this._rootElement,threshold:this._config.threshold,rootMargin:this._config.rootMargin};return new IntersectionObserver((t=>this._observerCallback(t)),t)}_observerCallback(t){const e=t=>this._targetLinks.get(`#${t.target.id}`),i=t=>{this._previousScrollData.visibleEntryTop=t.target.offsetTop,this._process(e(t))},n=(this._rootElement||document.documentElement).scrollTop,s=n>=this._previousScrollData.parentScrollTop;this._previousScrollData.parentScrollTop=n;for(const o of t){if(!o.isIntersecting){this._activeTarget=null,this._clearActiveClass(e(o));continue}const t=o.target.offsetTop>=this._previousScrollData.visibleEntryTop;if(s&&t){if(i(o),!n)return}else s||t||i(o)}}_initializeTargetsAndObservables(){this._targetLinks=new Map,this._observableSections=new Map;const t=z.find(pn,this._config.target);for(const e of t){if(!e.hash||l(e))continue;const t=z.findOne(decodeURI(e.hash),this._element);a(t)&&(this._targetLinks.set(decodeURI(e.hash),e),this._observableSections.set(e.hash,t))}}_process(t){this._activeTarget!==t&&(this._clearActiveClass(this._config.target),this._activeTarget=t,t.classList.add(fn),this._activateParents(t),P.trigger(this._element,"activate.bs.scrollspy",{relatedTarget:t}))}_activateParents(t){if(t.classList.contains("dropdown-item"))z.findOne(".dropdown-toggle",t.closest(".dropdown")).classList.add(fn);else for(const e of z.parents(t,".nav, .list-group"))for(const t of z.prev(e,".nav-link, .nav-item > .nav-link, .list-group-item"))t.classList.add(fn)}_clearActiveClass(t){t.classList.remove(fn);const e=z.find("[href].active",t);for(const t of e)t.classList.remove(fn)}static jQueryInterface(t){return this.each((function(){const e=_n.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}))}}P.on(window,"load.bs.scrollspy.data-api",(()=>{for(const t of z.find('[data-bs-spy="scroll"]'))_n.getOrCreateInstance(t)})),m(_n);const bn="ArrowLeft",vn="ArrowRight",yn="ArrowUp",wn="ArrowDown",An="active",En="fade",Tn="show",Cn='[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]',On=`.nav-link:not(.dropdown-toggle), .list-group-item:not(.dropdown-toggle), [role="tab"]:not(.dropdown-toggle), ${Cn}`;class xn extends W{constructor(t){super(t),this._parent=this._element.closest('.list-group, .nav, [role="tablist"]'),this._parent&&(this._setInitialAttributes(this._parent,this._getChildren()),P.on(this._element,"keydown.bs.tab",(t=>this._keydown(t))))}static get NAME(){return"tab"}show(){const t=this._element;if(this._elemIsActive(t))return;const e=this._getActiveElem(),i=e?P.trigger(e,"hide.bs.tab",{relatedTarget:t}):null;P.trigger(t,"show.bs.tab",{relatedTarget:e}).defaultPrevented||i&&i.defaultPrevented||(this._deactivate(e,t),this._activate(t,e))}_activate(t,e){t&&(t.classList.add(An),this._activate(z.getElementFromSelector(t)),this._queueCallback((()=>{"tab"===t.getAttribute("role")?(t.removeAttribute("tabindex"),t.setAttribute("aria-selected",!0),this._toggleDropDown(t,!0),P.trigger(t,"shown.bs.tab",{relatedTarget:e})):t.classList.add(Tn)}),t,t.classList.contains(En)))}_deactivate(t,e){t&&(t.classList.remove(An),t.blur(),this._deactivate(z.getElementFromSelector(t)),this._queueCallback((()=>{"tab"===t.getAttribute("role")?(t.setAttribute("aria-selected",!1),t.setAttribute("tabindex","-1"),this._toggleDropDown(t,!1),P.trigger(t,"hidden.bs.tab",{relatedTarget:e})):t.classList.remove(Tn)}),t,t.classList.contains(En)))}_keydown(t){if(![bn,vn,yn,wn].includes(t.key))return;t.stopPropagation(),t.preventDefault();const e=[vn,wn].includes(t.key),i=b(this._getChildren().filter((t=>!l(t))),t.target,e,!0);i&&(i.focus({preventScroll:!0}),xn.getOrCreateInstance(i).show())}_getChildren(){return z.find(On,this._parent)}_getActiveElem(){return this._getChildren().find((t=>this._elemIsActive(t)))||null}_setInitialAttributes(t,e){this._setAttributeIfNotExists(t,"role","tablist");for(const t of e)this._setInitialAttributesOnChild(t)}_setInitialAttributesOnChild(t){t=this._getInnerElement(t);const e=this._elemIsActive(t),i=this._getOuterElement(t);t.setAttribute("aria-selected",e),i!==t&&this._setAttributeIfNotExists(i,"role","presentation"),e||t.setAttribute("tabindex","-1"),this._setAttributeIfNotExists(t,"role","tab"),this._setInitialAttributesOnTargetPanel(t)}_setInitialAttributesOnTargetPanel(t){const e=z.getElementFromSelector(t);e&&(this._setAttributeIfNotExists(e,"role","tabpanel"),t.id&&this._setAttributeIfNotExists(e,"aria-labelledby",`${t.id}`))}_toggleDropDown(t,e){const i=this._getOuterElement(t);if(!i.classList.contains("dropdown"))return;const n=(t,n)=>{const s=z.findOne(t,i);s&&s.classList.toggle(n,e)};n(".dropdown-toggle",An),n(".dropdown-menu",Tn),i.setAttribute("aria-expanded",e)}_setAttributeIfNotExists(t,e,i){t.hasAttribute(e)||t.setAttribute(e,i)}_elemIsActive(t){return t.classList.contains(An)}_getInnerElement(t){return t.matches(On)?t:z.findOne(On,t)}_getOuterElement(t){return t.closest(".nav-item, .list-group-item")||t}static jQueryInterface(t){return this.each((function(){const e=xn.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}))}}P.on(document,"click.bs.tab",Cn,(function(t){["A","AREA"].includes(this.tagName)&&t.preventDefault(),l(this)||xn.getOrCreateInstance(this).show()})),P.on(window,"load.bs.tab",(()=>{for(const t of z.find('.active[data-bs-toggle="tab"], .active[data-bs-toggle="pill"], .active[data-bs-toggle="list"]'))xn.getOrCreateInstance(t)})),m(xn);const kn="hide",Ln="show",Sn="showing",Dn={animation:"boolean",autohide:"boolean",delay:"number"},In={animation:!0,autohide:!0,delay:5e3};class Nn extends W{constructor(t,e){super(t,e),this._timeout=null,this._hasMouseInteraction=!1,this._hasKeyboardInteraction=!1,this._setListeners()}static get Default(){return In}static get DefaultType(){return Dn}static get NAME(){return"toast"}show(){P.trigger(this._element,"show.bs.toast").defaultPrevented||(this._clearTimeout(),this._config.animation&&this._element.classList.add("fade"),this._element.classList.remove(kn),d(this._element),this._element.classList.add(Ln,Sn),this._queueCallback((()=>{this._element.classList.remove(Sn),P.trigger(this._element,"shown.bs.toast"),this._maybeScheduleHide()}),this._element,this._config.animation))}hide(){this.isShown()&&(P.trigger(this._element,"hide.bs.toast").defaultPrevented||(this._element.classList.add(Sn),this._queueCallback((()=>{this._element.classList.add(kn),this._element.classList.remove(Sn,Ln),P.trigger(this._element,"hidden.bs.toast")}),this._element,this._config.animation)))}dispose(){this._clearTimeout(),this.isShown()&&this._element.classList.remove(Ln),super.dispose()}isShown(){return this._element.classList.contains(Ln)}_maybeScheduleHide(){this._config.autohide&&(this._hasMouseInteraction||this._hasKeyboardInteraction||(this._timeout=setTimeout((()=>{this.hide()}),this._config.delay)))}_onInteraction(t,e){switch(t.type){case"mouseover":case"mouseout":this._hasMouseInteraction=e;break;case"focusin":case"focusout":this._hasKeyboardInteraction=e}if(e)return void this._clearTimeout();const i=t.relatedTarget;this._element===i||this._element.contains(i)||this._maybeScheduleHide()}_setListeners(){P.on(this._element,"mouseover.bs.toast",(t=>this._onInteraction(t,!0))),P.on(this._element,"mouseout.bs.toast",(t=>this._onInteraction(t,!1))),P.on(this._element,"focusin.bs.toast",(t=>this._onInteraction(t,!0))),P.on(this._element,"focusout.bs.toast",(t=>this._onInteraction(t,!1)))}_clearTimeout(){clearTimeout(this._timeout),this._timeout=null}static jQueryInterface(t){return this.each((function(){const e=Nn.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}return R(Nn),m(Nn),{Alert:q,Button:K,Carousel:rt,Collapse:ft,Dropdown:ci,Modal:Ni,Offcanvas:zi,Popover:dn,ScrollSpy:_n,Tab:xn,Toast:Nn,Tooltip:ln}})); +//# sourceMappingURL=bootstrap.bundle.min.js.map \ No newline at end of file diff --git a/src/main/resources/templates/execution-detail.html b/src/main/resources/templates/execution-detail.html index 28f2792..ea9a195 100644 --- a/src/main/resources/templates/execution-detail.html +++ b/src/main/resources/templates/execution-detail.html @@ -265,8 +265,8 @@ @@ -275,7 +275,10 @@ - + + - + + - + + + + + + + +