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/common/util/JsonChangeDetector.java b/src/main/java/com/snp/batch/common/util/JsonChangeDetector.java index 96231f1..f600491 100644 --- a/src/main/java/com/snp/batch/common/util/JsonChangeDetector.java +++ b/src/main/java/com/snp/batch/common/util/JsonChangeDetector.java @@ -15,12 +15,38 @@ public class JsonChangeDetector { private static final java.util.Set EXCLUDE_KEYS = java.util.Set.of("DataSetVersion", "APSStatus", "LastUpdateDate", "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"); + map.put("CrewList", "ID"); + 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"); + + LIST_SORT_KEYS = Collections.unmodifiableMap(map); + } // ========================================================================= // 1. JSON 문자열을 정렬 및 필터링된 Map으로 변환하는 핵심 로직 @@ -90,14 +116,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 +133,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 +183,6 @@ public class JsonChangeDetector { return sortedMap; } - // ========================================================================= // 2. 해시 생성 로직 // ========================================================================= 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 ddeb443..d05a2f9 100644 --- a/src/main/java/com/snp/batch/global/config/MaritimeApiWebClientConfig.java +++ b/src/main/java/com/snp/batch/global/config/MaritimeApiWebClientConfig.java @@ -64,7 +64,7 @@ public class MaritimeApiWebClientConfig { .defaultHeaders(headers -> headers.setBasicAuth(maritimeApiUsername, maritimeApiPassword)) .codecs(configurer -> configurer .defaultCodecs() - .maxInMemorySize(30 * 1024 * 1024)) // 30MB 버퍼 + .maxInMemorySize(100 * 1024 * 1024)) // 30MB 버퍼 .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 5af2a69..8d4e911 100644 --- a/src/main/java/com/snp/batch/global/config/SwaggerConfig.java +++ b/src/main/java/com/snp/batch/global/config/SwaggerConfig.java @@ -30,23 +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 + "/snp-api") + .url("http://localhost:" + serverPort + contextPath) .description("로컬 개발 서버"), new Server() - .url("http://10.26.252.39:" + serverPort) + .url("http://10.26.252.39:" + serverPort + contextPath) .description("로컬 개발 서버"), new Server() - .url("http://211.208.115.83:" + serverPort) + .url("http://211.208.115.83:" + serverPort + contextPath) .description("중계 서버"), new Server() - .url("http://10.187.58.58:" + serverPort) - .description("운영 서버") + .url("http://10.187.58.58:" + serverPort + contextPath) + .description("운영 서버"), + new Server() + .url("https://mda.kcg.go.kr" + contextPath) + .description("운영 서버 프록시") )); } 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..fc95e42 100644 --- a/src/main/java/com/snp/batch/global/controller/BatchController.java +++ b/src/main/java/com/snp/batch/global/controller/BatchController.java @@ -178,7 +178,7 @@ public class BatchController { } } - @PutMapping("/schedules/{jobName}") + @PostMapping("/schedules/{jobName}/update") public ResponseEntity> updateSchedule( @PathVariable String jobName, @RequestBody Map request) { @@ -206,7 +206,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 +226,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/model/BatchLastExecution.java b/src/main/java/com/snp/batch/global/model/BatchLastExecution.java new file mode 100644 index 0000000..6158776 --- /dev/null +++ b/src/main/java/com/snp/batch/global/model/BatchLastExecution.java @@ -0,0 +1,38 @@ +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; +@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 LocalDate lastSuccessDate; + + @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, LocalDate 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 index 60bc58f..33fc195 100644 --- a/src/main/java/com/snp/batch/global/partition/PartitionConfig.java +++ b/src/main/java/com/snp/batch/global/partition/PartitionConfig.java @@ -1,50 +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 기반) * - * Daily 파티션: 매일 실행 - * Monthly 파티션: 매월 말일에만 실행 + * 설정 예시: + * 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 { /** - * Daily 파티션 대상 테이블 (파티션 네이밍: {table}_YYYY_MM_DD) + * 일별 파티션 테이블 목록 (파티션 네이밍: {table}_YYMMDD) */ - private final List dailyPartitionTables = List.of( - // 추후 daily 파티션 테이블 추가 - ); + private List dailyTables = new ArrayList<>(); /** - * Monthly 파티션 대상 테이블 (파티션 네이밍: {table}_YYYY_MM) + * 월별 파티션 테이블 목록 (파티션 네이밍: {table}_YYYY_MM) */ - private final List monthlyPartitionTables = List.of( - new PartitionTableInfo( - "snp_data", - "ais_target", - "message_timestamp", - 2 // 미리 생성할 개월 수 - ) - ); + private List monthlyTables = new ArrayList<>(); /** - * 파티션 테이블 정보 + * 보관기간 설정 */ - public record PartitionTableInfo( - String schema, - String tableName, - String partitionColumn, - int periodsAhead // 미리 생성할 기간 수 (daily: 일, monthly: 월) - ) { + 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/PartitionManagerTasklet.java b/src/main/java/com/snp/batch/global/partition/PartitionManagerTasklet.java index e904116..a6918ed 100644 --- a/src/main/java/com/snp/batch/global/partition/PartitionManagerTasklet.java +++ b/src/main/java/com/snp/batch/global/partition/PartitionManagerTasklet.java @@ -1,6 +1,6 @@ package com.snp.batch.global.partition; -import com.snp.batch.global.partition.PartitionConfig.PartitionTableInfo; +import com.snp.batch.global.partition.PartitionConfig.PartitionTableConfig; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.batch.core.StepContribution; @@ -20,8 +20,12 @@ import java.util.List; * 파티션 관리 Tasklet * * 스케줄: 매일 실행 - * - Daily 파티션: 매일 생성 - * - Monthly 파티션: 매월 말일에만 생성 + * - Daily 파티션: 매일 생성/삭제 (네이밍: {table}_YYMMDD) + * - Monthly 파티션: 매월 말일에만 생성/삭제 (네이밍: {table}_YYYY_MM) + * + * 보관기간: + * - 기본값: 일별 14일, 월별 1개월 + * - 개별 테이블별 보관기간 설정 가능 (application.yml) */ @Slf4j @Component @@ -31,6 +35,9 @@ 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 @@ -41,6 +48,17 @@ public class PartitionManagerTasklet implements Tasklet { ) """; + 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(); @@ -52,14 +70,24 @@ public class PartitionManagerTasklet implements Tasklet { log.info("월 말일 여부: {}", isLastDayOfMonth); log.info("========================================"); - // Daily 파티션 처리 (매일) - processDailyPartitions(today); + // 1. Daily 파티션 생성 (매일) + createDailyPartitions(today); - // Monthly 파티션 처리 (매월 말일만) + // 2. Daily 파티션 삭제 (보관기간 초과분) + deleteDailyPartitions(today); + + // 3. Monthly 파티션 생성 (매월 말일만) if (isLastDayOfMonth) { - processMonthlyPartitions(today); + createMonthlyPartitions(today); } else { - log.info("Monthly 파티션: 말일이 아니므로 스킵"); + log.info("Monthly 파티션 생성: 말일이 아니므로 스킵"); + } + + // 4. Monthly 파티션 삭제 (매월 1일에만, 보관기간 초과분) + if (today.getDayOfMonth() == 1) { + deleteMonthlyPartitions(today); + } else { + log.info("Monthly 파티션 삭제: 1일이 아니므로 스킵"); } log.info("========================================"); @@ -76,36 +104,38 @@ public class PartitionManagerTasklet implements Tasklet { return date.getDayOfMonth() == YearMonth.from(date).lengthOfMonth(); } - /** - * Daily 파티션 처리 - */ - private void processDailyPartitions(LocalDate today) { - List tables = partitionConfig.getDailyPartitionTables(); + // ==================== Daily 파티션 생성 ==================== - if (tables.isEmpty()) { - log.info("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()); + log.info("Daily 파티션 생성 시작: {} 개 테이블", tables.size()); - for (PartitionTableInfo table : tables) { - processDailyPartition(table, today); + for (PartitionTableConfig table : tables) { + createDailyPartitionsForTable(table, today); } } /** - * 개별 Daily 파티션 생성 + * 개별 테이블 Daily 파티션 생성 */ - private void processDailyPartition(PartitionTableInfo table, LocalDate today) { + private void createDailyPartitionsForTable(PartitionTableConfig table, LocalDate today) { List created = new ArrayList<>(); List skipped = new ArrayList<>(); - for (int i = 0; i <= table.periodsAhead(); i++) { + for (int i = 0; i <= table.getPeriodsAhead(); i++) { LocalDate targetDate = today.plusDays(i); - String partitionName = getDailyPartitionName(table.tableName(), targetDate); + String partitionName = getDailyPartitionName(table.getTableName(), targetDate); - if (partitionExists(table.schema(), partitionName)) { + if (partitionExists(table.getSchema(), partitionName)) { skipped.add(partitionName); } else { createDailyPartition(table, targetDate, partitionName); @@ -113,40 +143,97 @@ public class PartitionManagerTasklet implements Tasklet { } } - log.info("[{}] Daily 파티션 - 생성: {}, 스킵: {}", - table.tableName(), created.size(), skipped.size()); + log.info("[{}] Daily 파티션 생성 - 생성: {}, 스킵: {}", + table.getTableName(), created.size(), skipped.size()); + if (!created.isEmpty()) { + log.info("[{}] 생성된 파티션: {}", table.getTableName(), created); + } } - /** - * Monthly 파티션 처리 - */ - private void processMonthlyPartitions(LocalDate today) { - List tables = partitionConfig.getMonthlyPartitionTables(); + // ==================== Daily 파티션 삭제 ==================== - if (tables.isEmpty()) { - log.info("Monthly 파티션: 대상 테이블 없음"); + /** + * Daily 파티션 삭제 (보관기간 초과분) + */ + private void deleteDailyPartitions(LocalDate today) { + List tables = partitionConfig.getDailyTables(); + + if (tables == null || tables.isEmpty()) { + log.info("Daily 파티션 삭제: 대상 테이블 없음"); return; } - log.info("Monthly 파티션 처리 시작: {} 개 테이블", tables.size()); + log.info("Daily 파티션 삭제 시작: {} 개 테이블", tables.size()); - for (PartitionTableInfo table : tables) { - processMonthlyPartition(table, today); + for (PartitionTableConfig table : tables) { + int retentionDays = partitionConfig.getDailyRetentionDays(table.getTableName()); + deleteDailyPartitionsForTable(table, today, retentionDays); } } /** - * 개별 Monthly 파티션 생성 + * 개별 테이블 Daily 파티션 삭제 */ - private void processMonthlyPartition(PartitionTableInfo table, LocalDate today) { + 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.periodsAhead(); i++) { + for (int i = 0; i <= table.getPeriodsAhead(); i++) { LocalDate targetDate = today.plusMonths(i).withDayOfMonth(1); - String partitionName = getMonthlyPartitionName(table.tableName(), targetDate); + String partitionName = getMonthlyPartitionName(table.getTableName(), targetDate); - if (partitionExists(table.schema(), partitionName)) { + if (partitionExists(table.getSchema(), partitionName)) { skipped.add(partitionName); } else { createMonthlyPartition(table, targetDate, partitionName); @@ -154,27 +241,127 @@ public class PartitionManagerTasklet implements Tasklet { } } - log.info("[{}] Monthly 파티션 - 생성: {}, 스킵: {}", - table.tableName(), created.size(), skipped.size()); + log.info("[{}] Monthly 파티션 생성 - 생성: {}, 스킵: {}", + table.getTableName(), created.size(), skipped.size()); if (!created.isEmpty()) { - log.info("[{}] 생성된 파티션: {}", table.tableName(), created); + 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); } } /** - * Daily 파티션 이름 생성 (table_YYYY_MM_DD) + * 개별 테이블 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(DateTimeFormatter.ofPattern("yyyy_MM_dd")); + return tableName + "_" + date.format(DAILY_PARTITION_FORMAT); } /** * Monthly 파티션 이름 생성 (table_YYYY_MM) */ private String getMonthlyPartitionName(String tableName, LocalDate date) { - return tableName + "_" + date.format(DateTimeFormatter.ofPattern("yyyy_MM")); + 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 작업 ==================== + /** * 파티션 존재 여부 확인 */ @@ -186,14 +373,14 @@ public class PartitionManagerTasklet implements Tasklet { /** * Daily 파티션 생성 */ - private void createDailyPartition(PartitionTableInfo table, LocalDate targetDate, String partitionName) { + 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.schema(), partitionName, table.getFullTableName(), + table.getSchema(), partitionName, table.getFullTableName(), targetDate, endDate); jdbcTemplate.execute(sql); @@ -203,7 +390,7 @@ public class PartitionManagerTasklet implements Tasklet { /** * Monthly 파티션 생성 */ - private void createMonthlyPartition(PartitionTableInfo table, LocalDate targetDate, String partitionName) { + private void createMonthlyPartition(PartitionTableConfig table, LocalDate targetDate, String partitionName) { LocalDate startDate = targetDate.withDayOfMonth(1); LocalDate endDate = startDate.plusMonths(1); @@ -211,10 +398,19 @@ public class PartitionManagerTasklet implements Tasklet { CREATE TABLE %s.%s PARTITION OF %s FOR VALUES FROM ('%s 00:00:00+00') TO ('%s 00:00:00+00') """, - table.schema(), partitionName, table.getFullTableName(), + 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/repository/BatchLastExecutionRepository.java b/src/main/java/com/snp/batch/global/repository/BatchLastExecutionRepository.java new file mode 100644 index 0000000..d62e7d3 --- /dev/null +++ b/src/main/java/com/snp/batch/global/repository/BatchLastExecutionRepository.java @@ -0,0 +1,36 @@ +package com.snp.batch.global.repository; + +import com.snp.batch.global.model.BatchLastExecution; +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.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. 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") LocalDate successDate); +} 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 index c26f40a..d5f7ccf 100644 --- 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 @@ -6,6 +6,7 @@ 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; @@ -43,6 +44,7 @@ public class AisTargetImportJobConfig extends BaseJobConfig se.getWriteCount()) - .sum()); + .sum(), + core20CacheManager.size()); } }); } 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 index 57bb322..0e4e1a9 100644 --- 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 @@ -82,4 +82,21 @@ public class AisTargetEntity extends BaseEntity { // ========== 타임스탬프 ========== 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; } 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 index 05e302f..76f81b0 100644 --- 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 @@ -4,6 +4,7 @@ import com.snp.batch.common.batch.writer.BaseWriter; 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.classifier.AisClassTypeClassifier; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -13,8 +14,9 @@ import java.util.List; * AIS Target 데이터 Writer * * 동작: - * - UPSERT 방식으로 DB 저장 (PK: mmsi + message_timestamp) - * - 동시에 캐시에도 최신 위치 정보 업데이트 + * 1. UPSERT 방식으로 DB 저장 (PK: mmsi + message_timestamp) + * 2. ClassType 분류 (Core20 캐시 기반 A/B 분류) + * 3. 캐시에 최신 위치 정보 업데이트 (classType, core20Mmsi 포함) */ @Slf4j @Component @@ -22,23 +24,30 @@ public class AisTargetDataWriter extends BaseWriter { private final AisTargetRepository aisTargetRepository; private final AisTargetCacheManager cacheManager; + private final AisClassTypeClassifier classTypeClassifier; public AisTargetDataWriter( AisTargetRepository aisTargetRepository, - AisTargetCacheManager cacheManager) { + AisTargetCacheManager cacheManager, + AisClassTypeClassifier classTypeClassifier) { super("AisTarget"); this.aisTargetRepository = aisTargetRepository; this.cacheManager = cacheManager; + this.classTypeClassifier = classTypeClassifier; } @Override protected void writeItems(List items) throws Exception { log.debug("AIS Target 데이터 저장 시작: {} 건", items.size()); - // 1. DB 저장 + // 1. DB 저장 (classType 없이 원본 데이터만 저장) aisTargetRepository.batchUpsert(items); - // 2. 캐시 업데이트 (최신 위치 정보) + // 2. ClassType 분류 (캐시 저장 전에 분류) + // - Core20 캐시의 IMO와 매칭하여 classType(A/B), core20Mmsi 설정 + classTypeClassifier.classifyAll(items); + + // 3. 캐시 업데이트 (classType, core20Mmsi 포함) cacheManager.putAll(items); log.debug("AIS Target 데이터 저장 완료: {} 건 (캐시 크기: {})", 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 index ecffa32..c884954 100644 --- a/src/main/java/com/snp/batch/jobs/aistarget/cache/AisTargetFilterUtil.java +++ b/src/main/java/com/snp/batch/jobs/aistarget/cache/AisTargetFilterUtil.java @@ -2,10 +2,12 @@ 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; @@ -16,6 +18,7 @@ import java.util.stream.Collectors; * - SOG, COG, Heading: 숫자 범위 조건 * - Destination: 문자열 부분 일치 * - Status: 다중 선택 일치 + * - ClassType: 선박 클래스 타입 (A/B) */ @Slf4j @Component @@ -45,6 +48,7 @@ public class AisTargetFilterUtil { .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; @@ -54,6 +58,54 @@ public class AisTargetFilterUtil { 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 (속도) 조건 매칭 */ @@ -150,4 +202,28 @@ public class AisTargetFilterUtil { 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/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 index fea1864..609fc70 100644 --- 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 @@ -74,11 +74,23 @@ public class AisTargetController { @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인 경우에만 존재할 수 있음) 공간 범위가 지정되지 않으면 전체 선박의 최신 위치를 반환합니다. """ @@ -92,16 +104,19 @@ public class AisTargetController { @Parameter(description = "중심 위도", example = "35.0") @RequestParam(required = false) Double centerLat, @Parameter(description = "반경 (미터)", example = "50000") - @RequestParam(required = false) Double radiusMeters) { + @RequestParam(required = false) Double radiusMeters, + @Parameter(description = "선박 클래스 타입 필터 (A: Core20 등록, B: 미등록)", example = "A") + @RequestParam(required = false) String classType) { - log.info("선박 검색 요청 - minutes: {}, center: ({}, {}), radius: {}", - minutes, centerLon, centerLat, radiusMeters); + 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); @@ -113,13 +128,33 @@ public class AisTargetController { @Operation( summary = "시간/공간 범위로 선박 검색 (POST)", - description = "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: {}", - request.getMinutes(), request.hasAreaFilter()); + log.info("선박 검색 요청 (POST) - minutes: {}, hasArea: {}, classType: {}", + request.getMinutes(), request.hasAreaFilter(), request.getClassType()); List result = aisTargetService.search(request); return ResponseEntity.ok(ApiResponse.success( 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 index 34e172a..66637d8 100644 --- 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 @@ -121,6 +121,16 @@ public class AisTargetFilterRequest { 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() { @@ -143,8 +153,13 @@ public class AisTargetFilterRequest { 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(); + || 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 index b7b0c74..aa8f3f4 100644 --- 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 @@ -2,6 +2,7 @@ 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; @@ -17,6 +18,7 @@ import java.time.OffsetDateTime; @NoArgsConstructor @AllArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "AIS Target 응답") public class AisTargetResponseDto { // 선박 식별 정보 @@ -51,8 +53,26 @@ public class AisTargetResponseDto { 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 변환 */ @@ -82,6 +102,8 @@ public class AisTargetResponseDto { .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 index 9e7a7a8..33d334f 100644 --- 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 @@ -39,10 +39,28 @@ public class AisTargetSearchRequest { @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/service/AisTargetService.java b/src/main/java/com/snp/batch/jobs/aistarget/web/service/AisTargetService.java index 6b1b79f..a7e7fe2 100644 --- 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 @@ -122,11 +122,12 @@ public class AisTargetService { * 전략: * 1. 캐시에서 시간 범위 내 데이터 조회 * 2. 공간 필터 있으면 JTS로 필터링 - * 3. 캐시 데이터가 없으면 DB Fallback + * 3. ClassType 필터 있으면 적용 + * 4. 캐시 데이터가 없으면 DB Fallback */ public List search(AisTargetSearchRequest request) { - log.debug("선박 검색 - minutes: {}, hasArea: {}", - request.getMinutes(), request.hasAreaFilter()); + log.debug("선박 검색 - minutes: {}, hasArea: {}, classType: {}", + request.getMinutes(), request.hasAreaFilter(), request.getClassType()); long startTime = System.currentTimeMillis(); @@ -154,6 +155,11 @@ public class AisTargetService { ); } + // 3. ClassType 필터 적용 + if (request.hasClassTypeFilter()) { + entities = filterUtil.filterByClassType(entities, request); + } + long elapsed = System.currentTimeMillis() - startTime; log.info("선박 검색 완료 - 소스: {}, 결과: {} 건, 소요: {}ms", source, entities.size(), elapsed); 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..3cf87f0 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/pscInspection/batch/config/PscInspectionJobConfig.java @@ -0,0 +1,118 @@ +package com.snp.batch.jobs.pscInspection.batch.config; + +import com.snp.batch.common.batch.config.BaseJobConfig; +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 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.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 org.springframework.beans.factory.annotation.Value; + +/** + * 선박 상세 정보 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 PscInspectionJobConfig extends BaseJobConfig { + + private final PscInspectionProcessor pscInspectionProcessor; + private final PscInspectionWriter pscInspectionWriter; + private final JdbcTemplate jdbcTemplate; + private final WebClient maritimeApiWebClient; + + public PscInspectionJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + PscInspectionProcessor pscInspectionProcessor, + PscInspectionWriter pscInspectionWriter, + JdbcTemplate jdbcTemplate, + @Qualifier("maritimeApiWebClient") WebClient maritimeApiWebClient, PscApiReader pscApiReader) { // ObjectMapper 주입 추가 + super(jobRepository, transactionManager); + this.pscInspectionProcessor = pscInspectionProcessor; + this.pscInspectionWriter = pscInspectionWriter; + this.jdbcTemplate = jdbcTemplate; + this.maritimeApiWebClient = maritimeApiWebClient; + } + + + + @Override + protected String getJobName() { + return "PSCDetailImportJob"; + } + + @Override + protected String getStepName() { + return "PSCDetailImportStep"; + } + + @Bean + @StepScope + public PscApiReader pscApiReader( + @Qualifier("maritimeApiWebClient") WebClient webClient, + @Value("#{jobParameters['fromDate']}") String fromDate, + @Value("#{jobParameters['toDate']}") String toDate + ) { + return new PscApiReader(webClient, fromDate, toDate); + } + + @Override + protected ItemReader createReader() { + return pscApiReader(null, null, null); + } + + @Override + protected ItemProcessor createProcessor() { + return pscInspectionProcessor; + } + + @Override + protected ItemWriter createWriter() { // 타입 변경 + return pscInspectionWriter; + } + + @Override + protected int getChunkSize() { + return 10; // API에서 100개씩 가져오므로 chunk도 100으로 설정 + } + + @Bean(name = "PSCDetailImportJob") + public Job PSCDetailImportJob() { + return job(); + } + + @Bean(name = "PSCDetailImportStep") + public Step PSCDetailImportStep() { + return step(); + } +} 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..70a8f81 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/pscInspection/batch/reader/PscApiReader.java @@ -0,0 +1,173 @@ +package com.snp.batch.jobs.pscInspection.batch.reader; + +import com.fasterxml.jackson.databind.ObjectMapper; +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 lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.Collections; +import java.util.List; + +@Slf4j +@StepScope +public class PscApiReader extends BaseApiReader { + + //private final JdbcTemplate jdbcTemplate; + + private final String fromDate; + private final String toDate; +// private List allImoNumbers; + private List allData; + private int currentBatchIndex = 0; + private final int batchSize = 10; + + public PscApiReader(@Qualifier("maritimeApiWebClient") WebClient webClient, + @Value("#{jobParameters['fromDate']}") String fromDate, + @Value("#{jobParameters['toDate']}") String toDate) { + super(webClient); + //this.jdbcTemplate = jdbcTemplate; + this.fromDate = fromDate; + this.toDate = toDate; + enableChunkMode(); + } + + @Override + protected String getReaderName() { + return "PscApiReader"; + } + + @Override + protected void resetCustomState() { + this.currentBatchIndex = 0; +// this.allImoNumbers = null; + } + + @Override + protected String getApiPath() { + return "/MaritimeWCF/PSCService.svc/RESTFul/GetPSCDataByLastUpdateDateRange"; + } + + 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_berthcalls) ORDER BY imo_number"; + + @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);*/ + log.info("[PSC] 요청 날짜 범위: {} → {}", fromDate, toDate); + } + + + @Override + protected List fetchNextBatch() { + + + // 1) 처음 호출이면 API 한 번 호출해서 전체 데이터를 가져온다 + if (allData == null) { + log.info("[PSC] 최초 API 조회 실행: {} ~ {}", fromDate, toDate); + allData = callApiWithBatch(fromDate, toDate); + + 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(String lrno) { + private List callApiWithBatch(String from, String to) { + + String[] f = from.split("-"); + String[] t = to.split("-"); + + String url = getApiPath() + + "?shipsCategory=0" + + "&fromYear=" + f[0] + + "&fromMonth=" + f[1] + + "&fromDay=" + f[2] + + "&toYear=" + t[0] + + "&toMonth=" + t[1] + + "&toDay=" + t[2]; + + log.info("[PSC] API 호출 URL = {}", url); + + String json = webClient.get() + .uri(url) + .retrieve() + .bodyToMono(String.class) + .block(); + + if (json == null || json.isBlank()) { + log.warn("[PSC] API 응답 없음"); + return Collections.emptyList(); + } + + try { + ObjectMapper mapper = new ObjectMapper(); + PscApiResponseDto resp = mapper.readValue(json, PscApiResponseDto.class); + + if (resp.getInspections() == null) { + log.warn("[PSC] inspections 필드 없음"); + return Collections.emptyList(); + } + + return resp.getInspections(); + + } catch (Exception e) { + log.error("[PSC] JSON 파싱 실패: {}", e.getMessage()); + 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); + log.info("[{}] 총 {} 개의 IMO 번호에 대한 API 호출 종료", + getReaderName(), allData.size()); + } + } +} 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..28a8bbb --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/pscInspection/batch/repository/PscAllCertificateRepositoryImpl.java @@ -0,0 +1,146 @@ +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, + type_id, + data_set_version, + inspection_id, + lrno, + certificate_title_code, + certificate_title, + issuing_authority_code, + issuing_authority, + class_soc_of_issuer, + 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 ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, \s + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ? + ) + ON CONFLICT (certificate_id) + DO UPDATE SET + type_id = EXCLUDED.type_id, + 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, + class_soc_of_issuer = EXCLUDED.class_soc_of_issuer, + 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.getTypeId()); + 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.getClassSocOfIssuer()); + 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..b7ac013 --- /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..9fedb79 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/pscInspection/batch/repository/PscDefectRepositoryImpl.java @@ -0,0 +1,163 @@ +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, + type_id, + data_set_version, + action_1, + action_2, + action_3, + action_code_1, + action_code_2, + action_code_3, + amsa_action_code_1, + amsa_action_code_2, + amsa_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, + type_id = EXCLUDED.type_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, + amsa_action_code_1 = EXCLUDED.amsa_action_code_1, + amsa_action_code_2 = EXCLUDED.amsa_action_code_2, + amsa_action_code_3 = EXCLUDED.amsa_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.getTypeId()); + 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.getAmsaActionCode1()); + ps.setString(i++, e.getAmsaActionCode2()); + ps.setString(i++, e.getAmsaActionCode3()); + 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..e558071 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/pscInspection/batch/repository/PscInspectionRepositoryImpl.java @@ -0,0 +1,186 @@ +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, + type_id, + data_set_version, + authorisation, + call_sign, + class, + cargo, + charterer, + country, + inspection_date, + release_date, + ship_detained, + dead_weight, + expanded_inspection, + flag, + follow_up_inspection, + gross_tonnage, + inspection_port_code, + inspection_port_decode, + keel_laid, + 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 + type_id = EXCLUDED.type_id, + data_set_version = EXCLUDED.data_set_version, + authorisation = EXCLUDED.authorisation, + call_sign = EXCLUDED.call_sign, + class = EXCLUDED.class, + cargo = EXCLUDED.cargo, + 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_code = EXCLUDED.inspection_port_code, + inspection_port_decode = EXCLUDED.inspection_port_decode, + keel_laid = EXCLUDED.keel_laid, + 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.getTypeId()); + ps.setString(i++, e.getDataSetVersion()); + ps.setString(i++, e.getAuthorisation()); + ps.setString(i++, e.getCallSign()); + ps.setString(i++, e.getShipClass()); + ps.setString(i++, e.getCargo()); + 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.getInspectionPortCode()); + ps.setString(i++, e.getInspectionPortDecode()); + ps.setString(i++, e.getKeelLaid()); + 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/repository/RiskRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/risk/batch/repository/RiskRepositoryImpl.java index 4a1236c..a5c8695 100644 --- 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 @@ -65,7 +65,7 @@ public class RiskRepositoryImpl extends BaseJdbcRepository imp VALUES ( ?, ?::timestamptz, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'N' ) - ON CONFLICT (lrno) + ON CONFLICT (lrno, lastupdated) DO UPDATE SET riskdatamaintained = EXCLUDED.riskdatamaintained, dayssincelastseenonais = EXCLUDED.dayssincelastseenonais, diff --git a/src/main/java/com/snp/batch/jobs/sanction/batch/repository/ComplianceRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/sanction/batch/repository/ComplianceRepositoryImpl.java index 981e081..db90923 100644 --- a/src/main/java/com/snp/batch/jobs/sanction/batch/repository/ComplianceRepositoryImpl.java +++ b/src/main/java/com/snp/batch/jobs/sanction/batch/repository/ComplianceRepositoryImpl.java @@ -58,7 +58,7 @@ public class ComplianceRepositoryImpl extends BaseJdbcRepository { +public class ShipMovementJobConfig extends BaseJobConfig { private final ShipMovementProcessor shipMovementProcessor; private final ShipMovementWriter shipMovementWriter; @@ -101,14 +101,14 @@ public class ShipMovementJobConfig extends BaseJobConfig createReader() { // 타입 변경 + protected ItemReader createReader() { // 타입 변경 // Reader 생성자 수정: ObjectMapper를 전달합니다. return shipMovementReader(null, null); //return new ShipMovementReader(maritimeApiWebClient, jdbcTemplate, objectMapper); } @Override - protected ItemProcessor createProcessor() { + protected ItemProcessor createProcessor() { return shipMovementProcessor; } diff --git a/src/main/java/com/snp/batch/jobs/shipMovement/batch/dto/PortCallDto.java b/src/main/java/com/snp/batch/jobs/shipMovement/batch/dto/PortCallsDto.java similarity index 91% rename from src/main/java/com/snp/batch/jobs/shipMovement/batch/dto/PortCallDto.java rename to src/main/java/com/snp/batch/jobs/shipMovement/batch/dto/PortCallsDto.java index 6d02359..c97db50 100644 --- a/src/main/java/com/snp/batch/jobs/shipMovement/batch/dto/PortCallDto.java +++ b/src/main/java/com/snp/batch/jobs/shipMovement/batch/dto/PortCallsDto.java @@ -3,7 +3,7 @@ package com.snp.batch.jobs.shipMovement.batch.dto; import lombok.Data; @Data -public class PortCallDto { +public class PortCallsDto { private String movementType; private String imolRorIHSNumber; private String movementDate; @@ -29,7 +29,7 @@ public class PortCallDto { private Double latitude; private Double longitude; - private PositionDto position; + private PortCallsPositionDto position; private String destination; private String iso2; diff --git a/src/main/java/com/snp/batch/jobs/shipMovement/batch/dto/PositionDto.java b/src/main/java/com/snp/batch/jobs/shipMovement/batch/dto/PortCallsPositionDto.java similarity index 90% rename from src/main/java/com/snp/batch/jobs/shipMovement/batch/dto/PositionDto.java rename to src/main/java/com/snp/batch/jobs/shipMovement/batch/dto/PortCallsPositionDto.java index 9a367ba..8906ba0 100644 --- a/src/main/java/com/snp/batch/jobs/shipMovement/batch/dto/PositionDto.java +++ b/src/main/java/com/snp/batch/jobs/shipMovement/batch/dto/PortCallsPositionDto.java @@ -4,7 +4,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; @Data -public class PositionDto { +public class PortCallsPositionDto { private boolean isNull; private int stSrid; private double lat; diff --git a/src/main/java/com/snp/batch/jobs/shipMovement/batch/dto/ShipMovementApiResponse.java b/src/main/java/com/snp/batch/jobs/shipMovement/batch/dto/ShipMovementApiResponse.java index effef52..eb8fae8 100644 --- a/src/main/java/com/snp/batch/jobs/shipMovement/batch/dto/ShipMovementApiResponse.java +++ b/src/main/java/com/snp/batch/jobs/shipMovement/batch/dto/ShipMovementApiResponse.java @@ -8,5 +8,5 @@ import java.util.List; @Data public class ShipMovementApiResponse { @JsonProperty("portCalls") - List portCallList; + List portCallList; } diff --git a/src/main/java/com/snp/batch/jobs/shipMovement/batch/processor/ShipMovementProcessor.java b/src/main/java/com/snp/batch/jobs/shipMovement/batch/processor/ShipMovementProcessor.java index a270089..102e404 100644 --- a/src/main/java/com/snp/batch/jobs/shipMovement/batch/processor/ShipMovementProcessor.java +++ b/src/main/java/com/snp/batch/jobs/shipMovement/batch/processor/ShipMovementProcessor.java @@ -3,7 +3,7 @@ package com.snp.batch.jobs.shipMovement.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.shipMovement.batch.dto.PortCallDto; +import com.snp.batch.jobs.shipMovement.batch.dto.PortCallsDto; import com.snp.batch.jobs.shipMovement.batch.entity.ShipMovementEntity; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -22,7 +22,7 @@ import java.time.LocalDateTime; */ @Slf4j @Component -public class ShipMovementProcessor extends BaseProcessor { +public class ShipMovementProcessor extends BaseProcessor { private final ObjectMapper objectMapper; @@ -31,7 +31,7 @@ public class ShipMovementProcessor extends BaseProcessor { +public class ShipMovementReader extends BaseApiReader { private final JdbcTemplate jdbcTemplate; private final ObjectMapper objectMapper; @@ -91,8 +85,8 @@ public class ShipMovementReader extends BaseApiReader { } 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"; + "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"; private static final String FETCH_ALL_HASHES_QUERY = "SELECT imo_number, ship_detail_hash FROM ship_detail_hash_json ORDER BY imo_number"; @@ -125,7 +119,7 @@ public class ShipMovementReader extends BaseApiReader { * @return 다음 배치 100건 (더 이상 없으면 null) */ @Override - protected List fetchNextBatch() throws Exception { + protected List fetchNextBatch() throws Exception { // 모든 배치 처리 완료 확인 if (allImoNumbers == null || currentBatchIndex >= allImoNumbers.size()) { @@ -158,7 +152,7 @@ public class ShipMovementReader extends BaseApiReader { // 응답 처리 if (response != null && response.getPortCallList() != null) { - List portCalls = response.getPortCallList(); + List portCalls = response.getPortCallList(); log.info("[{}] 배치 {}/{} 완료: {} 건 조회", getReaderName(), currentBatchNumber, totalBatches, portCalls.size()); @@ -213,7 +207,7 @@ public class ShipMovementReader extends BaseApiReader { } @Override - protected void afterFetch(List data) { + protected void afterFetch(List data) { if (data == null) { int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize); log.info("[{}] 전체 {} 개 배치 처리 완료", getReaderName(), totalBatches); diff --git a/src/main/java/com/snp/batch/jobs/shipMovement/batch/repository/ShipMovementRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/shipMovement/batch/repository/ShipMovementRepositoryImpl.java index 5c405e0..13a3ac0 100644 --- a/src/main/java/com/snp/batch/jobs/shipMovement/batch/repository/ShipMovementRepositoryImpl.java +++ b/src/main/java/com/snp/batch/jobs/shipMovement/batch/repository/ShipMovementRepositoryImpl.java @@ -69,7 +69,7 @@ public class ShipMovementRepositoryImpl extends BaseJdbcRepository { + + 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/shipMovementAnchorageCalls/batch/dto/AnchorageCallsDto.java b/src/main/java/com/snp/batch/jobs/shipMovementAnchorageCalls/batch/dto/AnchorageCallsDto.java new file mode 100644 index 0000000..cd26678 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementAnchorageCalls/batch/dto/AnchorageCallsDto.java @@ -0,0 +1,33 @@ +package com.snp.batch.jobs.shipMovementAnchorageCalls.batch.dto; + +import lombok.Data; + +import java.time.LocalDateTime; + +@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/shipMovementAnchorageCalls/batch/dto/AnchorageCallsPositionDto.java b/src/main/java/com/snp/batch/jobs/shipMovementAnchorageCalls/batch/dto/AnchorageCallsPositionDto.java new file mode 100644 index 0000000..23d3613 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementAnchorageCalls/batch/dto/AnchorageCallsPositionDto.java @@ -0,0 +1,19 @@ +package com.snp.batch.jobs.shipMovementAnchorageCalls.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/shipMovementAnchorageCalls/batch/entity/AnchorageCallsEntity.java b/src/main/java/com/snp/batch/jobs/shipMovementAnchorageCalls/batch/entity/AnchorageCallsEntity.java new file mode 100644 index 0000000..70aaad8 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementAnchorageCalls/batch/entity/AnchorageCallsEntity.java @@ -0,0 +1,47 @@ +package com.snp.batch.jobs.shipMovementAnchorageCalls.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/shipMovementAnchorageCalls/batch/processor/AnchorageCallsProcessor.java b/src/main/java/com/snp/batch/jobs/shipMovementAnchorageCalls/batch/processor/AnchorageCallsProcessor.java new file mode 100644 index 0000000..ee03a7e --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementAnchorageCalls/batch/processor/AnchorageCallsProcessor.java @@ -0,0 +1,68 @@ +package com.snp.batch.jobs.shipMovementAnchorageCalls.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.shipMovementAnchorageCalls.batch.dto.AnchorageCallsDto; +import com.snp.batch.jobs.shipMovementAnchorageCalls.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/shipMovementAnchorageCalls/batch/reader/AnchorageCallsReader.java b/src/main/java/com/snp/batch/jobs/shipMovementAnchorageCalls/batch/reader/AnchorageCallsReader.java new file mode 100644 index 0000000..60957d2 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementAnchorageCalls/batch/reader/AnchorageCallsReader.java @@ -0,0 +1,216 @@ +package com.snp.batch.jobs.shipMovementAnchorageCalls.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.shipMovementAnchorageCalls.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/shipMovementAnchorageCalls/batch/repository/AnchorageCallsRepository.java b/src/main/java/com/snp/batch/jobs/shipMovementAnchorageCalls/batch/repository/AnchorageCallsRepository.java new file mode 100644 index 0000000..5bcfa85 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementAnchorageCalls/batch/repository/AnchorageCallsRepository.java @@ -0,0 +1,14 @@ +package com.snp.batch.jobs.shipMovementAnchorageCalls.batch.repository; + +import com.snp.batch.jobs.shipMovement.batch.entity.ShipMovementEntity; +import com.snp.batch.jobs.shipMovementAnchorageCalls.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/shipMovementAnchorageCalls/batch/repository/AnchorageCallsRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/shipMovementAnchorageCalls/batch/repository/AnchorageCallsRepositoryImpl.java new file mode 100644 index 0000000..0a590a9 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementAnchorageCalls/batch/repository/AnchorageCallsRepositoryImpl.java @@ -0,0 +1,201 @@ +package com.snp.batch.jobs.shipMovementAnchorageCalls.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.shipMovement.batch.entity.ShipMovementEntity; +import com.snp.batch.jobs.shipMovement.batch.repository.ShipMovementRepository; +import com.snp.batch.jobs.shipMovementAnchorageCalls.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"; + } + + @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( + 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/shipMovementAnchorageCalls/batch/writer/AnchorageCallsWriter.java b/src/main/java/com/snp/batch/jobs/shipMovementAnchorageCalls/batch/writer/AnchorageCallsWriter.java new file mode 100644 index 0000000..198d223 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementAnchorageCalls/batch/writer/AnchorageCallsWriter.java @@ -0,0 +1,38 @@ +package com.snp.batch.jobs.shipMovementAnchorageCalls.batch.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.shipMovement.batch.repository.ShipMovementRepository; +import com.snp.batch.jobs.shipMovementAnchorageCalls.batch.entity.AnchorageCallsEntity; +import com.snp.batch.jobs.shipMovementAnchorageCalls.batch.repository.AnchorageCallsRepository; +import com.snp.batch.jobs.shipdetail.batch.repository.ShipDetailRepository; +import com.snp.batch.jobs.shipdetail.batch.repository.ShipHashRepository; +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/shipMovementBerthCalls/batch/config/BerthCallsJobConfig.java b/src/main/java/com/snp/batch/jobs/shipMovementBerthCalls/batch/config/BerthCallsJobConfig.java new file mode 100644 index 0000000..2b43ed5 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementBerthCalls/batch/config/BerthCallsJobConfig.java @@ -0,0 +1,107 @@ +package com.snp.batch.jobs.shipMovementBerthCalls.batch.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.config.BaseJobConfig; +import com.snp.batch.jobs.shipMovementBerthCalls.batch.dto.BerthCallsDto; +import com.snp.batch.jobs.shipMovementBerthCalls.batch.entiity.BerthCallsEntity; +import com.snp.batch.jobs.shipMovementBerthCalls.batch.processor.BerthCallsProcessor; +import com.snp.batch.jobs.shipMovementBerthCalls.batch.reader.BerthCallsReader; +import com.snp.batch.jobs.shipMovementBerthCalls.batch.writer.BerthCallsWriter; +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/shipMovementBerthCalls/batch/dto/BerthCallsDto.java b/src/main/java/com/snp/batch/jobs/shipMovementBerthCalls/batch/dto/BerthCallsDto.java new file mode 100644 index 0000000..9483216 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementBerthCalls/batch/dto/BerthCallsDto.java @@ -0,0 +1,32 @@ +package com.snp.batch.jobs.shipMovementBerthCalls.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/shipMovementBerthCalls/batch/dto/BerthCallsPositionDto.java b/src/main/java/com/snp/batch/jobs/shipMovementBerthCalls/batch/dto/BerthCallsPositionDto.java new file mode 100644 index 0000000..ffc652c --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementBerthCalls/batch/dto/BerthCallsPositionDto.java @@ -0,0 +1,17 @@ +package com.snp.batch.jobs.shipMovementBerthCalls.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/shipMovementBerthCalls/batch/entiity/BerthCallsEntity.java b/src/main/java/com/snp/batch/jobs/shipMovementBerthCalls/batch/entiity/BerthCallsEntity.java new file mode 100644 index 0000000..4cc1b8f --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementBerthCalls/batch/entiity/BerthCallsEntity.java @@ -0,0 +1,47 @@ +package com.snp.batch.jobs.shipMovementBerthCalls.batch.entiity; + +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/shipMovementBerthCalls/batch/processor/BerthCallsProcessor.java b/src/main/java/com/snp/batch/jobs/shipMovementBerthCalls/batch/processor/BerthCallsProcessor.java new file mode 100644 index 0000000..d196256 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementBerthCalls/batch/processor/BerthCallsProcessor.java @@ -0,0 +1,68 @@ +package com.snp.batch.jobs.shipMovementBerthCalls.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.shipMovementBerthCalls.batch.dto.BerthCallsDto; +import com.snp.batch.jobs.shipMovementBerthCalls.batch.entiity.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/shipMovementBerthCalls/batch/reader/BerthCallsReader.java b/src/main/java/com/snp/batch/jobs/shipMovementBerthCalls/batch/reader/BerthCallsReader.java new file mode 100644 index 0000000..3d7f5c7 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementBerthCalls/batch/reader/BerthCallsReader.java @@ -0,0 +1,213 @@ +package com.snp.batch.jobs.shipMovementBerthCalls.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.shipMovementBerthCalls.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/shipMovementBerthCalls/batch/repository/BerthCallsRepository.java b/src/main/java/com/snp/batch/jobs/shipMovementBerthCalls/batch/repository/BerthCallsRepository.java new file mode 100644 index 0000000..df2d707 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementBerthCalls/batch/repository/BerthCallsRepository.java @@ -0,0 +1,14 @@ +package com.snp.batch.jobs.shipMovementBerthCalls.batch.repository; + +import com.snp.batch.jobs.shipMovementAnchorageCalls.batch.entity.AnchorageCallsEntity; +import com.snp.batch.jobs.shipMovementBerthCalls.batch.entiity.BerthCallsEntity; + +import java.util.List; + +/** + * 선박 상세 정보 Repository 인터페이스 + */ + +public interface BerthCallsRepository { + void saveAll(List entities); +} diff --git a/src/main/java/com/snp/batch/jobs/shipMovementBerthCalls/batch/repository/BerthCallsRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/shipMovementBerthCalls/batch/repository/BerthCallsRepositoryImpl.java new file mode 100644 index 0000000..db5d696 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementBerthCalls/batch/repository/BerthCallsRepositoryImpl.java @@ -0,0 +1,192 @@ +package com.snp.batch.jobs.shipMovementBerthCalls.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.shipMovementAnchorageCalls.batch.entity.AnchorageCallsEntity; +import com.snp.batch.jobs.shipMovementAnchorageCalls.batch.repository.AnchorageCallsRepository; +import com.snp.batch.jobs.shipMovementBerthCalls.batch.entiity.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"; + } + + @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( + 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/shipMovementBerthCalls/batch/writer/BerthCallsWriter.java b/src/main/java/com/snp/batch/jobs/shipMovementBerthCalls/batch/writer/BerthCallsWriter.java new file mode 100644 index 0000000..03c1db0 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementBerthCalls/batch/writer/BerthCallsWriter.java @@ -0,0 +1,37 @@ +package com.snp.batch.jobs.shipMovementBerthCalls.batch.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.shipMovementAnchorageCalls.batch.entity.AnchorageCallsEntity; +import com.snp.batch.jobs.shipMovementAnchorageCalls.batch.repository.AnchorageCallsRepository; +import com.snp.batch.jobs.shipMovementBerthCalls.batch.entiity.BerthCallsEntity; +import com.snp.batch.jobs.shipMovementBerthCalls.batch.repository.BerthCallsRepository; +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/shipMovementCurrentlyAt/batch/config/CurrentlyAtJobConfig.java b/src/main/java/com/snp/batch/jobs/shipMovementCurrentlyAt/batch/config/CurrentlyAtJobConfig.java new file mode 100644 index 0000000..e6a525d --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementCurrentlyAt/batch/config/CurrentlyAtJobConfig.java @@ -0,0 +1,103 @@ +package com.snp.batch.jobs.shipMovementCurrentlyAt.batch.config; + +import com.snp.batch.common.batch.config.BaseJobConfig; +import com.snp.batch.jobs.shipMovementCurrentlyAt.batch.dto.CurrentlyAtDto; +import com.snp.batch.jobs.shipMovementCurrentlyAt.batch.entity.CurrentlyAtEntity; +import com.snp.batch.jobs.shipMovementCurrentlyAt.batch.processor.CurrentlyAtProcessor; +import com.snp.batch.jobs.shipMovementCurrentlyAt.batch.reader.CurrentlyAtReader; +import com.snp.batch.jobs.shipMovementCurrentlyAt.batch.writer.CurrentlyAtWriter; +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/shipMovementCurrentlyAt/batch/dto/CurrentlyAtDto.java b/src/main/java/com/snp/batch/jobs/shipMovementCurrentlyAt/batch/dto/CurrentlyAtDto.java new file mode 100644 index 0000000..f20bad1 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementCurrentlyAt/batch/dto/CurrentlyAtDto.java @@ -0,0 +1,37 @@ +package com.snp.batch.jobs.shipMovementCurrentlyAt.batch.dto; + +import com.snp.batch.jobs.shipMovement.batch.dto.PortCallsPositionDto; +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/shipMovementCurrentlyAt/batch/dto/CurrentlyAtPositionDto.java b/src/main/java/com/snp/batch/jobs/shipMovementCurrentlyAt/batch/dto/CurrentlyAtPositionDto.java new file mode 100644 index 0000000..0fed56a --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementCurrentlyAt/batch/dto/CurrentlyAtPositionDto.java @@ -0,0 +1,17 @@ +package com.snp.batch.jobs.shipMovementCurrentlyAt.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/shipMovementCurrentlyAt/batch/entity/CurrentlyAtEntity.java b/src/main/java/com/snp/batch/jobs/shipMovementCurrentlyAt/batch/entity/CurrentlyAtEntity.java new file mode 100644 index 0000000..90beda4 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementCurrentlyAt/batch/entity/CurrentlyAtEntity.java @@ -0,0 +1,41 @@ +package com.snp.batch.jobs.shipMovementCurrentlyAt.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/shipMovementCurrentlyAt/batch/processor/CurrentlyAtProcessor.java b/src/main/java/com/snp/batch/jobs/shipMovementCurrentlyAt/batch/processor/CurrentlyAtProcessor.java new file mode 100644 index 0000000..db905bd --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementCurrentlyAt/batch/processor/CurrentlyAtProcessor.java @@ -0,0 +1,71 @@ +package com.snp.batch.jobs.shipMovementCurrentlyAt.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.shipMovementCurrentlyAt.batch.dto.CurrentlyAtDto; +import com.snp.batch.jobs.shipMovementCurrentlyAt.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/shipMovementCurrentlyAt/batch/reader/CurrentlyAtReader.java b/src/main/java/com/snp/batch/jobs/shipMovementCurrentlyAt/batch/reader/CurrentlyAtReader.java new file mode 100644 index 0000000..26d0552 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementCurrentlyAt/batch/reader/CurrentlyAtReader.java @@ -0,0 +1,211 @@ +package com.snp.batch.jobs.shipMovementCurrentlyAt.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.shipMovementCurrentlyAt.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/shipMovementCurrentlyAt/batch/repository/CurrentlyAtRepository.java b/src/main/java/com/snp/batch/jobs/shipMovementCurrentlyAt/batch/repository/CurrentlyAtRepository.java new file mode 100644 index 0000000..ae47b79 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementCurrentlyAt/batch/repository/CurrentlyAtRepository.java @@ -0,0 +1,13 @@ +package com.snp.batch.jobs.shipMovementCurrentlyAt.batch.repository; + +import com.snp.batch.jobs.shipMovementCurrentlyAt.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/shipMovementCurrentlyAt/batch/repository/CurrentlyAtRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/shipMovementCurrentlyAt/batch/repository/CurrentlyAtRepositoryImpl.java new file mode 100644 index 0000000..9cb26eb --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementCurrentlyAt/batch/repository/CurrentlyAtRepositoryImpl.java @@ -0,0 +1,206 @@ +package com.snp.batch.jobs.shipMovementCurrentlyAt.batch.repository; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.shipMovementCurrentlyAt.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"; + } + + @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( + 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); + + } + + + /*private static class ShipMovementRowMapper implements RowMapper { + @Override + public ShipMovementEntity mapRow(ResultSet rs, int rowNum) throws SQLException { + ShipMovementEntity entity = ShipMovementEntity.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 ObjectMapper().readTree(json); + } catch (Exception e) { + throw new RuntimeException("JSON 파싱 오류: " + json); + } + } + }*/ +} diff --git a/src/main/java/com/snp/batch/jobs/shipMovementCurrentlyAt/batch/writer/CurrentlyAtWriter.java b/src/main/java/com/snp/batch/jobs/shipMovementCurrentlyAt/batch/writer/CurrentlyAtWriter.java new file mode 100644 index 0000000..ceea6b6 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementCurrentlyAt/batch/writer/CurrentlyAtWriter.java @@ -0,0 +1,36 @@ +package com.snp.batch.jobs.shipMovementCurrentlyAt.batch.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.shipMovementCurrentlyAt.batch.entity.CurrentlyAtEntity; +import com.snp.batch.jobs.shipMovementCurrentlyAt.batch.repository.CurrentlyAtRepository; +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/shipMovementDarkActivity/batch/config/DarkActivityJobConfig.java b/src/main/java/com/snp/batch/jobs/shipMovementDarkActivity/batch/config/DarkActivityJobConfig.java new file mode 100644 index 0000000..a370b0b --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementDarkActivity/batch/config/DarkActivityJobConfig.java @@ -0,0 +1,106 @@ +package com.snp.batch.jobs.shipMovementDarkActivity.batch.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.config.BaseJobConfig; +import com.snp.batch.jobs.shipMovementDarkActivity.batch.dto.DarkActivityDto; +import com.snp.batch.jobs.shipMovementDarkActivity.batch.entity.DarkActivityEntity; +import com.snp.batch.jobs.shipMovementDarkActivity.batch.processor.DarkActivityProcessor; +import com.snp.batch.jobs.shipMovementDarkActivity.batch.reader.DarkActivityReader; +import com.snp.batch.jobs.shipMovementDarkActivity.batch.writer.DarkActivityWriter; +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/shipMovementDarkActivity/batch/dto/DarkActivityDto.java b/src/main/java/com/snp/batch/jobs/shipMovementDarkActivity/batch/dto/DarkActivityDto.java new file mode 100644 index 0000000..9cb7b81 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementDarkActivity/batch/dto/DarkActivityDto.java @@ -0,0 +1,30 @@ +package com.snp.batch.jobs.shipMovementDarkActivity.batch.dto; + +import com.snp.batch.jobs.shipMovementAnchorageCalls.batch.dto.AnchorageCallsPositionDto; +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 AnchorageCallsPositionDto position; + + private String eventStartDate; +} diff --git a/src/main/java/com/snp/batch/jobs/shipMovementDarkActivity/batch/entity/DarkActivityEntity.java b/src/main/java/com/snp/batch/jobs/shipMovementDarkActivity/batch/entity/DarkActivityEntity.java new file mode 100644 index 0000000..f05aea5 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementDarkActivity/batch/entity/DarkActivityEntity.java @@ -0,0 +1,41 @@ +package com.snp.batch.jobs.shipMovementDarkActivity.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/shipMovementDarkActivity/batch/processor/DarkActivityProcessor.java b/src/main/java/com/snp/batch/jobs/shipMovementDarkActivity/batch/processor/DarkActivityProcessor.java new file mode 100644 index 0000000..e465f8a --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementDarkActivity/batch/processor/DarkActivityProcessor.java @@ -0,0 +1,66 @@ +package com.snp.batch.jobs.shipMovementDarkActivity.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.shipMovementDarkActivity.batch.dto.DarkActivityDto; +import com.snp.batch.jobs.shipMovementDarkActivity.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/shipMovementDarkActivity/batch/reader/DarkActivityReader.java b/src/main/java/com/snp/batch/jobs/shipMovementDarkActivity/batch/reader/DarkActivityReader.java new file mode 100644 index 0000000..7587fad --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementDarkActivity/batch/reader/DarkActivityReader.java @@ -0,0 +1,212 @@ +package com.snp.batch.jobs.shipMovementDarkActivity.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.shipMovementDarkActivity.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/shipMovementDarkActivity/batch/repository/DarkActivityRepository.java b/src/main/java/com/snp/batch/jobs/shipMovementDarkActivity/batch/repository/DarkActivityRepository.java new file mode 100644 index 0000000..f18da07 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementDarkActivity/batch/repository/DarkActivityRepository.java @@ -0,0 +1,14 @@ +package com.snp.batch.jobs.shipMovementDarkActivity.batch.repository; + +import com.snp.batch.jobs.shipMovementBerthCalls.batch.entiity.BerthCallsEntity; +import com.snp.batch.jobs.shipMovementDarkActivity.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/shipMovementDarkActivity/batch/repository/DarkActivityRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/shipMovementDarkActivity/batch/repository/DarkActivityRepositoryImpl.java new file mode 100644 index 0000000..2055651 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementDarkActivity/batch/repository/DarkActivityRepositoryImpl.java @@ -0,0 +1,186 @@ +package com.snp.batch.jobs.shipMovementDarkActivity.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.shipMovementBerthCalls.batch.entiity.BerthCallsEntity; +import com.snp.batch.jobs.shipMovementBerthCalls.batch.repository.BerthCallsRepository; +import com.snp.batch.jobs.shipMovementDarkActivity.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 "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( + 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) + 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/shipMovementDarkActivity/batch/writer/DarkActivityWriter.java b/src/main/java/com/snp/batch/jobs/shipMovementDarkActivity/batch/writer/DarkActivityWriter.java new file mode 100644 index 0000000..901876c --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementDarkActivity/batch/writer/DarkActivityWriter.java @@ -0,0 +1,37 @@ +package com.snp.batch.jobs.shipMovementDarkActivity.batch.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.shipMovementBerthCalls.batch.entiity.BerthCallsEntity; +import com.snp.batch.jobs.shipMovementBerthCalls.batch.repository.BerthCallsRepository; +import com.snp.batch.jobs.shipMovementDarkActivity.batch.entity.DarkActivityEntity; +import com.snp.batch.jobs.shipMovementDarkActivity.batch.repository.DarkActivityRepository; +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/shipMovementDestination/batch/config/DestinationsJobConfig.java b/src/main/java/com/snp/batch/jobs/shipMovementDestination/batch/config/DestinationsJobConfig.java new file mode 100644 index 0000000..807741e --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementDestination/batch/config/DestinationsJobConfig.java @@ -0,0 +1,103 @@ +package com.snp.batch.jobs.shipMovementDestination.batch.config; + +import com.snp.batch.common.batch.config.BaseJobConfig; +import com.snp.batch.jobs.shipMovementDestination.batch.dto.DestinationDto; +import com.snp.batch.jobs.shipMovementDestination.batch.entity.DestinationEntity; +import com.snp.batch.jobs.shipMovementDestination.batch.processor.DestinationProcessor; +import com.snp.batch.jobs.shipMovementDestination.batch.reader.DestinationReader; +import com.snp.batch.jobs.shipMovementDestination.batch.writer.DestinationWriter; +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/shipMovementDestination/batch/dto/DestinationDto.java b/src/main/java/com/snp/batch/jobs/shipMovementDestination/batch/dto/DestinationDto.java new file mode 100644 index 0000000..c150ee4 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementDestination/batch/dto/DestinationDto.java @@ -0,0 +1,24 @@ +package com.snp.batch.jobs.shipMovementDestination.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/shipMovementDestination/batch/dto/DestinationPositionDto.java b/src/main/java/com/snp/batch/jobs/shipMovementDestination/batch/dto/DestinationPositionDto.java new file mode 100644 index 0000000..f600d28 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementDestination/batch/dto/DestinationPositionDto.java @@ -0,0 +1,17 @@ +package com.snp.batch.jobs.shipMovementDestination.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/shipMovementDestination/batch/entity/DestinationEntity.java b/src/main/java/com/snp/batch/jobs/shipMovementDestination/batch/entity/DestinationEntity.java new file mode 100644 index 0000000..fa2a23a --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementDestination/batch/entity/DestinationEntity.java @@ -0,0 +1,32 @@ +package com.snp.batch.jobs.shipMovementDestination.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/shipMovementDestination/batch/processor/DestinationProcessor.java b/src/main/java/com/snp/batch/jobs/shipMovementDestination/batch/processor/DestinationProcessor.java new file mode 100644 index 0000000..8379fe5 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementDestination/batch/processor/DestinationProcessor.java @@ -0,0 +1,61 @@ +package com.snp.batch.jobs.shipMovementDestination.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.shipMovementDestination.batch.dto.DestinationDto; +import com.snp.batch.jobs.shipMovementDestination.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/shipMovementDestination/batch/reader/DestinationReader.java b/src/main/java/com/snp/batch/jobs/shipMovementDestination/batch/reader/DestinationReader.java new file mode 100644 index 0000000..a57adbe --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementDestination/batch/reader/DestinationReader.java @@ -0,0 +1,211 @@ +package com.snp.batch.jobs.shipMovementDestination.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.shipMovementDestination.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; +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 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/shipMovementDestination/batch/repository/DestinationRepository.java b/src/main/java/com/snp/batch/jobs/shipMovementDestination/batch/repository/DestinationRepository.java new file mode 100644 index 0000000..4613e37 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementDestination/batch/repository/DestinationRepository.java @@ -0,0 +1,14 @@ +package com.snp.batch.jobs.shipMovementDestination.batch.repository; + +import com.snp.batch.jobs.shipMovementDestination.batch.entity.DestinationEntity; +import com.snp.batch.jobs.shipMovementTerminalCalls.batch.entity.TerminalCallsEntity; + +import java.util.List; + +/** + * 선박 상세 정보 Repository 인터페이스 + */ + +public interface DestinationRepository { + void saveAll(List entities); +} diff --git a/src/main/java/com/snp/batch/jobs/shipMovementDestination/batch/repository/DestinationRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/shipMovementDestination/batch/repository/DestinationRepositoryImpl.java new file mode 100644 index 0000000..bea7875 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementDestination/batch/repository/DestinationRepositoryImpl.java @@ -0,0 +1,131 @@ +package com.snp.batch.jobs.shipMovementDestination.batch.repository; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.shipMovementDestination.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"; + } + + @Override + protected String getEntityName() { + return "Destinations"; + } + + @Override + protected String extractId(DestinationEntity entity) { + return entity.getImolRorIHSNumber(); + } + + @Override + public String getInsertSql() { + return """ + INSERT INTO snp_data.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, 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, + 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/shipMovementDestination/batch/writer/DestinationWriter.java b/src/main/java/com/snp/batch/jobs/shipMovementDestination/batch/writer/DestinationWriter.java new file mode 100644 index 0000000..be05993 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementDestination/batch/writer/DestinationWriter.java @@ -0,0 +1,36 @@ +package com.snp.batch.jobs.shipMovementDestination.batch.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.shipMovementDestination.batch.entity.DestinationEntity; +import com.snp.batch.jobs.shipMovementDestination.batch.repository.DestinationRepository; +import com.snp.batch.jobs.shipMovementTerminalCalls.batch.entity.TerminalCallsEntity; +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/shipMovementStsOperations/batch/config/StsOperationJobConfig.java b/src/main/java/com/snp/batch/jobs/shipMovementStsOperations/batch/config/StsOperationJobConfig.java new file mode 100644 index 0000000..d2a5cce --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementStsOperations/batch/config/StsOperationJobConfig.java @@ -0,0 +1,104 @@ +package com.snp.batch.jobs.shipMovementStsOperations.batch.config; + +import com.snp.batch.common.batch.config.BaseJobConfig; +import com.snp.batch.jobs.shipMovementStsOperations.batch.dto.StsOperationDto; +import com.snp.batch.jobs.shipMovementStsOperations.batch.entity.StsOperationEntity; +import com.snp.batch.jobs.shipMovementStsOperations.batch.processor.StsOperationProcessor; +import com.snp.batch.jobs.shipMovementStsOperations.batch.reader.StsOperationReader; +import com.snp.batch.jobs.shipMovementStsOperations.batch.writer.StsOperationWriter; +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/shipMovementStsOperations/batch/dto/StsOperationDto.java b/src/main/java/com/snp/batch/jobs/shipMovementStsOperations/batch/dto/StsOperationDto.java new file mode 100644 index 0000000..0a7fca7 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementStsOperations/batch/dto/StsOperationDto.java @@ -0,0 +1,34 @@ +package com.snp.batch.jobs.shipMovementStsOperations.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/shipMovementStsOperations/batch/dto/StsOperationPositionDto.java b/src/main/java/com/snp/batch/jobs/shipMovementStsOperations/batch/dto/StsOperationPositionDto.java new file mode 100644 index 0000000..85496f0 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementStsOperations/batch/dto/StsOperationPositionDto.java @@ -0,0 +1,17 @@ +package com.snp.batch.jobs.shipMovementStsOperations.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/shipMovementStsOperations/batch/entity/StsOperationEntity.java b/src/main/java/com/snp/batch/jobs/shipMovementStsOperations/batch/entity/StsOperationEntity.java new file mode 100644 index 0000000..e47acf0 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementStsOperations/batch/entity/StsOperationEntity.java @@ -0,0 +1,45 @@ +package com.snp.batch.jobs.shipMovementStsOperations.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/shipMovementStsOperations/batch/processor/StsOperationProcessor.java b/src/main/java/com/snp/batch/jobs/shipMovementStsOperations/batch/processor/StsOperationProcessor.java new file mode 100644 index 0000000..fdb73bd --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementStsOperations/batch/processor/StsOperationProcessor.java @@ -0,0 +1,69 @@ +package com.snp.batch.jobs.shipMovementStsOperations.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.shipMovementStsOperations.batch.dto.StsOperationDto; +import com.snp.batch.jobs.shipMovementStsOperations.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/shipMovementStsOperations/batch/reader/StsOperationReader.java b/src/main/java/com/snp/batch/jobs/shipMovementStsOperations/batch/reader/StsOperationReader.java new file mode 100644 index 0000000..5aff9b9 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementStsOperations/batch/reader/StsOperationReader.java @@ -0,0 +1,213 @@ +package com.snp.batch.jobs.shipMovementStsOperations.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.shipMovementStsOperations.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/shipMovementStsOperations/batch/repository/StsOperationRepository.java b/src/main/java/com/snp/batch/jobs/shipMovementStsOperations/batch/repository/StsOperationRepository.java new file mode 100644 index 0000000..a081c51 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementStsOperations/batch/repository/StsOperationRepository.java @@ -0,0 +1,12 @@ +package com.snp.batch.jobs.shipMovementStsOperations.batch.repository; + +import com.snp.batch.jobs.shipMovementStsOperations.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/shipMovementStsOperations/batch/repository/StsOperationRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/shipMovementStsOperations/batch/repository/StsOperationRepositoryImpl.java new file mode 100644 index 0000000..4cebb94 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementStsOperations/batch/repository/StsOperationRepositoryImpl.java @@ -0,0 +1,162 @@ +package com.snp.batch.jobs.shipMovementStsOperations.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.shipMovementStsOperations.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.ResultSet; +import java.sql.SQLException; +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"; + } + + @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( + 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) + 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/shipMovementStsOperations/batch/writer/StsOperationWriter.java b/src/main/java/com/snp/batch/jobs/shipMovementStsOperations/batch/writer/StsOperationWriter.java new file mode 100644 index 0000000..44c5536 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementStsOperations/batch/writer/StsOperationWriter.java @@ -0,0 +1,36 @@ +package com.snp.batch.jobs.shipMovementStsOperations.batch.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.shipMovementStsOperations.batch.entity.StsOperationEntity; +import com.snp.batch.jobs.shipMovementStsOperations.batch.repository.StsOperationRepository; +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/shipMovementTerminalCalls/batch/config/TerminalCallsJobConfig.java b/src/main/java/com/snp/batch/jobs/shipMovementTerminalCalls/batch/config/TerminalCallsJobConfig.java new file mode 100644 index 0000000..a221b25 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementTerminalCalls/batch/config/TerminalCallsJobConfig.java @@ -0,0 +1,103 @@ +package com.snp.batch.jobs.shipMovementTerminalCalls.batch.config; + +import com.snp.batch.common.batch.config.BaseJobConfig; +import com.snp.batch.jobs.shipMovementTerminalCalls.batch.dto.TerminalCallsDto; +import com.snp.batch.jobs.shipMovementTerminalCalls.batch.entity.TerminalCallsEntity; +import com.snp.batch.jobs.shipMovementTerminalCalls.batch.processor.TerminalCallsProcessor; +import com.snp.batch.jobs.shipMovementTerminalCalls.batch.reader.TerminalCallsReader; +import com.snp.batch.jobs.shipMovementTerminalCalls.batch.writer.TerminalCallsWriter; +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/shipMovementTerminalCalls/batch/dto/TerminalCallsDto.java b/src/main/java/com/snp/batch/jobs/shipMovementTerminalCalls/batch/dto/TerminalCallsDto.java new file mode 100644 index 0000000..d35a7d7 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementTerminalCalls/batch/dto/TerminalCallsDto.java @@ -0,0 +1,32 @@ +package com.snp.batch.jobs.shipMovementTerminalCalls.batch.dto; + +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; +} diff --git a/src/main/java/com/snp/batch/jobs/shipMovementTerminalCalls/batch/dto/TerminalCallsPositionDto.java b/src/main/java/com/snp/batch/jobs/shipMovementTerminalCalls/batch/dto/TerminalCallsPositionDto.java new file mode 100644 index 0000000..844f8bb --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementTerminalCalls/batch/dto/TerminalCallsPositionDto.java @@ -0,0 +1,17 @@ +package com.snp.batch.jobs.shipMovementTerminalCalls.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/shipMovementTerminalCalls/batch/entity/TerminalCallsEntity.java b/src/main/java/com/snp/batch/jobs/shipMovementTerminalCalls/batch/entity/TerminalCallsEntity.java new file mode 100644 index 0000000..a003375 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementTerminalCalls/batch/entity/TerminalCallsEntity.java @@ -0,0 +1,43 @@ +package com.snp.batch.jobs.shipMovementTerminalCalls.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 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; +} diff --git a/src/main/java/com/snp/batch/jobs/shipMovementTerminalCalls/batch/processor/TerminalCallsProcessor.java b/src/main/java/com/snp/batch/jobs/shipMovementTerminalCalls/batch/processor/TerminalCallsProcessor.java new file mode 100644 index 0000000..8438cc4 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementTerminalCalls/batch/processor/TerminalCallsProcessor.java @@ -0,0 +1,68 @@ +package com.snp.batch.jobs.shipMovementTerminalCalls.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.shipMovementTerminalCalls.batch.dto.TerminalCallsDto; +import com.snp.batch.jobs.shipMovementTerminalCalls.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())) + .build(); + + return entity; + } + +} diff --git a/src/main/java/com/snp/batch/jobs/shipMovementTerminalCalls/batch/reader/TerminalCallsReader.java b/src/main/java/com/snp/batch/jobs/shipMovementTerminalCalls/batch/reader/TerminalCallsReader.java new file mode 100644 index 0000000..62a9061 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementTerminalCalls/batch/reader/TerminalCallsReader.java @@ -0,0 +1,213 @@ +package com.snp.batch.jobs.shipMovementTerminalCalls.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.shipMovementTerminalCalls.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/shipMovementTerminalCalls/batch/repository/TerminalCallsRepository.java b/src/main/java/com/snp/batch/jobs/shipMovementTerminalCalls/batch/repository/TerminalCallsRepository.java new file mode 100644 index 0000000..6b22b39 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementTerminalCalls/batch/repository/TerminalCallsRepository.java @@ -0,0 +1,13 @@ +package com.snp.batch.jobs.shipMovementTerminalCalls.batch.repository; + +import com.snp.batch.jobs.shipMovementTerminalCalls.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/shipMovementTerminalCalls/batch/repository/TerminalCallsRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/shipMovementTerminalCalls/batch/repository/TerminalCallsRepositoryImpl.java new file mode 100644 index 0000000..66366e1 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementTerminalCalls/batch/repository/TerminalCallsRepositoryImpl.java @@ -0,0 +1,152 @@ +package com.snp.batch.jobs.shipMovementTerminalCalls.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.shipMovementTerminalCalls.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.ResultSet; +import java.sql.SQLException; +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"; + } + + @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( + 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, 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); + } + } + + 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/shipMovementTerminalCalls/batch/writer/TerminalCallsWriter.java b/src/main/java/com/snp/batch/jobs/shipMovementTerminalCalls/batch/writer/TerminalCallsWriter.java new file mode 100644 index 0000000..c5d1a2a --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementTerminalCalls/batch/writer/TerminalCallsWriter.java @@ -0,0 +1,35 @@ +package com.snp.batch.jobs.shipMovementTerminalCalls.batch.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.shipMovementTerminalCalls.batch.entity.TerminalCallsEntity; +import com.snp.batch.jobs.shipMovementTerminalCalls.batch.repository.TerminalCallsRepository; +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/shipMovementTransits/batch/config/TransitsJobConfig.java b/src/main/java/com/snp/batch/jobs/shipMovementTransits/batch/config/TransitsJobConfig.java new file mode 100644 index 0000000..f6d65ba --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementTransits/batch/config/TransitsJobConfig.java @@ -0,0 +1,103 @@ +package com.snp.batch.jobs.shipMovementTransits.batch.config; + +import com.snp.batch.common.batch.config.BaseJobConfig; +import com.snp.batch.jobs.shipMovementTransits.batch.dto.TransitsDto; +import com.snp.batch.jobs.shipMovementTransits.batch.entity.TransitsEntity; +import com.snp.batch.jobs.shipMovementTransits.batch.processor.TransitsProcessor; +import com.snp.batch.jobs.shipMovementTransits.batch.reader.TransitsReader; +import com.snp.batch.jobs.shipMovementTransits.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/shipMovementTransits/batch/dto/TransitsDto.java b/src/main/java/com/snp/batch/jobs/shipMovementTransits/batch/dto/TransitsDto.java new file mode 100644 index 0000000..7dd2958 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementTransits/batch/dto/TransitsDto.java @@ -0,0 +1,13 @@ +package com.snp.batch.jobs.shipMovementTransits.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/shipMovementTransits/batch/entity/TransitsEntity.java b/src/main/java/com/snp/batch/jobs/shipMovementTransits/batch/entity/TransitsEntity.java new file mode 100644 index 0000000..ddfe811 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementTransits/batch/entity/TransitsEntity.java @@ -0,0 +1,21 @@ +package com.snp.batch.jobs.shipMovementTransits.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/shipMovementTransits/batch/processor/TransitsProcessor.java b/src/main/java/com/snp/batch/jobs/shipMovementTransits/batch/processor/TransitsProcessor.java new file mode 100644 index 0000000..8c7df92 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementTransits/batch/processor/TransitsProcessor.java @@ -0,0 +1,47 @@ +package com.snp.batch.jobs.shipMovementTransits.batch.processor; + +import com.snp.batch.common.batch.processor.BaseProcessor; +import com.snp.batch.jobs.shipMovementTransits.batch.dto.TransitsDto; +import com.snp.batch.jobs.shipMovementTransits.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/shipMovementTransits/batch/reader/TransitsReader.java b/src/main/java/com/snp/batch/jobs/shipMovementTransits/batch/reader/TransitsReader.java new file mode 100644 index 0000000..daf2b94 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementTransits/batch/reader/TransitsReader.java @@ -0,0 +1,211 @@ +package com.snp.batch.jobs.shipMovementTransits.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.shipMovementTransits.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; +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 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/shipMovementTransits/batch/repository/TransitlsRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/shipMovementTransits/batch/repository/TransitlsRepositoryImpl.java new file mode 100644 index 0000000..af747c0 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementTransits/batch/repository/TransitlsRepositoryImpl.java @@ -0,0 +1,108 @@ +package com.snp.batch.jobs.shipMovementTransits.batch.repository; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.shipMovementTransits.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 TransitlsRepositoryImpl extends BaseJdbcRepository + implements TransitsRepository { + + public TransitlsRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + @Override + protected String getTableName() { + return "snp_data.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( + 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/shipMovementTransits/batch/repository/TransitsRepository.java b/src/main/java/com/snp/batch/jobs/shipMovementTransits/batch/repository/TransitsRepository.java new file mode 100644 index 0000000..e134548 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementTransits/batch/repository/TransitsRepository.java @@ -0,0 +1,13 @@ +package com.snp.batch.jobs.shipMovementTransits.batch.repository; + +import com.snp.batch.jobs.shipMovementTransits.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/shipMovementTransits/batch/writer/TransitsWriter.java b/src/main/java/com/snp/batch/jobs/shipMovementTransits/batch/writer/TransitsWriter.java new file mode 100644 index 0000000..2e72d53 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipMovementTransits/batch/writer/TransitsWriter.java @@ -0,0 +1,35 @@ +package com.snp.batch.jobs.shipMovementTransits.batch.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.shipMovementTransits.batch.entity.TransitsEntity; +import com.snp.batch.jobs.shipMovementTransits.batch.repository.TransitsRepository; +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/shipdetail/batch/config/ShipDetailUpdateJobConfig.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/config/ShipDetailUpdateJobConfig.java new file mode 100644 index 0000000..20d345c --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/config/ShipDetailUpdateJobConfig.java @@ -0,0 +1,118 @@ +package com.snp.batch.jobs.shipdetail.batch.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.config.BaseJobConfig; +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.BatchDateService; +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) + * + * 데이터 흐름: + * ShipDetailDataReader (ship_data → Maritime API) + * ↓ (ShipDetailDto) + * ShipDetailDataProcessor + * ↓ (ShipDetailEntity) + * ShipDetailDataWriter + * ↓ (ship_detail 테이블) + */ + +/** + * 선박 상세 정보 Import Job Config + * I: ShipDetailComparisonData (Reader 출력) + * O: ShipDetailUpdate (Processor 출력) + */ +@Slf4j +@Configuration +public class ShipDetailUpdateJobConfig extends BaseJobConfig { + + private final ShipDetailDataProcessor shipDetailDataProcessor; + private final ShipDetailDataWriter shipDetailDataWriter; + private final JdbcTemplate jdbcTemplate; + private final WebClient maritimeApiWebClient; + private final ObjectMapper objectMapper; // ObjectMapper 주입 추가 + private final BatchDateService batchDateService; + + public ShipDetailUpdateJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + ShipDetailDataProcessor shipDetailDataProcessor, + ShipDetailDataWriter shipDetailDataWriter, + JdbcTemplate jdbcTemplate, + @Qualifier("maritimeApiWebClient") WebClient maritimeApiWebClient, + ObjectMapper objectMapper, + BatchDateService batchDateService) { // ObjectMapper 주입 추가 + super(jobRepository, transactionManager); + this.shipDetailDataProcessor = shipDetailDataProcessor; + this.shipDetailDataWriter = shipDetailDataWriter; + this.jdbcTemplate = jdbcTemplate; + this.maritimeApiWebClient = maritimeApiWebClient; + this.objectMapper = objectMapper; // ObjectMapper 초기화 + this.batchDateService = batchDateService; + } + + @Override + protected String getJobName() { + return "ShipDetailUpdateJob"; + } + + @Override + protected String getStepName() { + return "ShipDetailUpdateStep"; + } + + @Override + protected ItemReader createReader() { // 타입 변경 + // Reader 생성자 수정: ObjectMapper를 전달합니다. + return new ShipDetailUpdateDataReader(maritimeApiWebClient, jdbcTemplate, objectMapper, batchDateService); + } + + @Override + protected ItemProcessor createProcessor() { + return shipDetailDataProcessor; + } + + @Override + protected ItemWriter createWriter() { // 타입 변경 + return shipDetailDataWriter; + } + + @Override + protected int getChunkSize() { + return 30; // API에서 100개씩 가져오므로 chunk도 100으로 설정 + } + + @Bean(name = "ShipDetailUpdateJob") + public Job ShipDetailUpdateJob() { + return job(); + } + + @Bean(name = "ShipDetailUpdateStep") + public Step ShipDetailUpdateStep() { + return step(); + } +} 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..2e16054 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/AdditionalInformationDto.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 AdditionalInformationDto { + @Getter @Setter @ToString @NoArgsConstructor + public static class DataSetVersionDto { + @JsonProperty("DataSetVersion") + private String dataSetVersion; + } + @JsonProperty("DataSetVersion") + private DataSetVersionDto dataSetVersion; + @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/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/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/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..2682d7d 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 @@ -183,91 +183,150 @@ 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; } 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..f998552 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,25 @@ 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; } \ 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/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..de98856 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/AdditionalInformationEntity.java @@ -0,0 +1,29 @@ +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; +} 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/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/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/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/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/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 1be7f93..5756ff4 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; @@ -33,7 +28,7 @@ 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()); // 3. 최종 업데이트 DTO 생성 (Writer에 전달) return ShipDetailUpdate.builder() @@ -57,6 +67,27 @@ 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())) + .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; + } /** * 해시값을 비교하여 변경 여부를 판단합니다. diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/reader/ShipDetailUpdateDataReader.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/reader/ShipDetailUpdateDataReader.java new file mode 100644 index 0000000..6465807 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/reader/ShipDetailUpdateDataReader.java @@ -0,0 +1,309 @@ +package com.snp.batch.jobs.shipdetail.batch.reader; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.common.util.JsonChangeDetector; +import com.snp.batch.jobs.shipdetail.batch.dto.*; +import com.snp.batch.service.BatchDateService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.reactive.function.client.WebClient; + +import java.time.LocalDate; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 선박 상세 정보 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 +public class ShipDetailUpdateDataReader extends BaseApiReader { + + private final JdbcTemplate jdbcTemplate; + private final ObjectMapper objectMapper; + private final BatchDateService batchDateService; // ✨ BatchDateService 필드 추가 + + // 배치 처리 상태 + private List allImoNumbers; + // DB 해시값을 저장할 맵 + private Map dbMasterHashes; + private int currentBatchIndex = 0; + private final int batchSize = 30; + + public ShipDetailUpdateDataReader(WebClient webClient, JdbcTemplate jdbcTemplate, ObjectMapper objectMapper,BatchDateService batchDateService) { + super(webClient); + this.jdbcTemplate = jdbcTemplate; + this.objectMapper = objectMapper; + this.batchDateService = batchDateService; + enableChunkMode(); // ✨ Chunk 모드 활성화 + } + + @Override + protected String getReaderName() { + return "ShipDetailUpdateDataReader"; + } + + @Override + protected void resetCustomState() { + this.currentBatchIndex = 0; + this.allImoNumbers = null; + this.dbMasterHashes = null; + } + + @Override + protected String getApiPath() { + return "/MaritimeWCF/APSShipService.svc/RESTFul/GetShipsByIHSLRorIMONumbersAll"; + } + + protected String getShipUpdateApiPath(){ + return "MaritimeWCF/APSShipService.svc/RESTFul/GetShipChangesByLastUpdateDateRange"; + } + + 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) { + String url = getApiPath() + "?IMONumbers=" + imoNumbers; + + log.info("[{}] API 호출: {}", getReaderName(), url); + + return webClient.get() + .uri(url) + .retrieve() + .bodyToMono(ShipDetailApiResponse.class) + .block(); + } + + private ShipUpdateApiResponse callShipUpdateApi(){ + // 1. BatchDateService를 통해 동적 날짜 파라미터 맵 조회 + Map params = batchDateService.getShipUpdateApiDateParams(); + + String url = getShipUpdateApiPath(); + log.info("[{}] API 호출: {}", getReaderName(), url); + + return webClient.get() + .uri(url, uriBuilder -> uriBuilder + // 맵에서 파라미터 값을 동적으로 가져와 세팅 + .queryParam("shipsCategory", params.get("shipsCategory")) + .queryParam("fromYear", params.get("fromYear")) + .queryParam("fromMonth", params.get("fromMonth")) + .queryParam("fromDay", params.get("fromDay")) + .queryParam("toYear", params.get("toYear")) + .queryParam("toMonth", params.get("toMonth")) + .queryParam("toDay", params.get("toDay")) + .build()) + .retrieve() + .bodyToMono(ShipUpdateApiResponse.class) + .block(); + } + + 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) { + // 3. ✨ 배치 성공 시 상태 업데이트 (트랜잭션 커밋 직전에 실행) + LocalDate successDate = LocalDate.now(); // 현재 배치 실행 시점의 날짜 (Reader의 toDay와 동일한 값) + batchDateService.updateLastSuccessDate(successDate); + log.info("batch_last_execution update 완료"); + + 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/repository/ShipDetailRepository.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/repository/ShipDetailRepository.java index 9dd59e3..e192014 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,69 @@ public interface ShipDetailRepository { void saveAllOwnerHistoryData(List entities); + //TODO : CrewList 저장 함수 선언 + void saveAllCrewListData(List entities); + + //TODO : StowageCommodity 저장 함수 선언 + void saveAllStowageCommodityData(List entities); + + //TODO : GroupBeneficialOwnerHistory 저장 함수 선언 + void saveAllGroupBeneficialOwnerHistoryData(List entities); + + //TODO : ShipManagerHistory 저장 함수 선언 + void saveAllShipManagerHistoryData(List entities); + + //TODO : OperatorHistory 저장 함수 선언 + void saveAllOperatorHistoryData(List entities); + + //TODO : TechnicalManagerHistory 저장 함수 선언 + void saveAllTechnicalManagerHistoryData(List entities); + + //TODO : BareBoatCharterHistory 저장 함수 선언 + void saveAllBareBoatCharterHistoryData(List entities); + + //TODO : NameHistory 저장 함수 선언 + void saveAllNameHistoryData(List entities); + + //TODO : FlagHistory 저장 함수 선언 + void saveAllFlagHistoryData(List entities); + + //TODO : AdditionalInformation 저장 함수 선언 + void saveAllAdditionalInformationData(List entities); + + //TODO : PandIHistory 저장 함수 선언 + void saveAllPandIHistoryData(List entities); + + //TODO : CallSignAndMmsiHistory 저장 함수 선언 + void saveAllCallSignAndMmsiHistoryData(List entities); + + //TODO : IceClass 저장 함수 선언 + void saveAllIceClassData(List entities); + + //TODO : SafetyManagementCertificateHistory 저장 함수 선언 + void saveAllSafetyManagementCertificateHistoryData(List entities); + + //TODO : ClassHistory 저장 함수 선언 + void saveAllClassHistoryData(List entities); + + //TODO : SurveyDatesHistory 저장 함수 선언 + void saveAllSurveyDatesHistoryData(List entities); + + //TODO : SurveyDatesHistoryUnique 저장 함수 선언 + void saveAllSurveyDatesHistoryUniqueData(List entities); + + //TODO : SisterShipLinks 저장 함수 선언 + void saveAllSisterShipLinksData(List entities); + + //TODO : StatusHistory 저장 함수 선언 + void saveAllStatusHistoryData(List entities); + + //TODO : SpecialFeature 저장 함수 선언 + void saveAllSpecialFeatureData(List entities); + + //TODO : Thrusters 저장 함수 선언 + void saveAllThrustersData(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 247c082..d442518 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,8 +1,7 @@ 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; @@ -157,6 +156,21 @@ public class ShipDetailRepositoryImpl extends BaseJdbcRepository entities) { + String entityName = "CrewListEntity"; + String sql = ShipDetailSql.getCrewListSql(); + + if (entities == null || entities.isEmpty()) { + return; + } + + log.info("{} 배치 삽입 시작: {} 건", 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.info("{} 배치 삽입 시작: {} 건", 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.info("{} 배치 삽입 시작: {} 건", 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.info("{} 배치 삽입 시작: {} 건", 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.info("{} 배치 삽입 시작: {} 건", 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.info("{} 배치 삽입 시작: {} 건", 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.info("{} 배치 삽입 시작: {} 건", 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.info("{} 배치 삽입 시작: {} 건", 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.info("{} 배치 삽입 시작: {} 건", 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.info("{} 배치 삽입 시작: {} 건", 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.info("{} 배치 삽입 시작: {} 건", 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.info("{} 배치 삽입 시작: {} 건", 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.info("{} 배치 삽입 시작: {} 건", 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.info("{} 배치 삽입 시작: {} 건", 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.info("{} 배치 삽입 시작: {} 건", 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.info("{} 배치 삽입 시작: {} 건", 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.info("{} 배치 삽입 시작: {} 건", 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.info("{} 배치 삽입 시작: {} 건", 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.info("{} 배치 삽입 시작: {} 건", 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.info("{} 배치 삽입 시작: {} 건", 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.info("{} 배치 삽입 시작: {} 건", 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()); + } + 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); @@ -270,6 +788,301 @@ public class ShipDetailRepositoryImpl extends BaseJdbcRepository { .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); // 1-3. List (Hash값 데이터 처리용) List hashEntities = items.stream() @@ -63,11 +76,94 @@ 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); // ✅ 2-3. ShipHashRepository (Hash값 데이터) log.debug("Ship Hash 데이터 저장 시작: {} 건", hashEntities.size()); @@ -77,4 +173,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/service/BatchDateService.java b/src/main/java/com/snp/batch/service/BatchDateService.java new file mode 100644 index 0000000..3ab3c66 --- /dev/null +++ b/src/main/java/com/snp/batch/service/BatchDateService.java @@ -0,0 +1,66 @@ +package com.snp.batch.service; +import com.snp.batch.global.model.BatchLastExecution; +import com.snp.batch.global.repository.BatchLastExecutionRepository; +import jakarta.transaction.Transactional; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.*; + +@Service +public class BatchDateService { + private static final String API_KEY = "SHIP_UPDATE_API"; + private final BatchLastExecutionRepository repository; + + public BatchDateService(BatchLastExecutionRepository repository) { + this.repository = repository; + } + + /** + * API 호출에 필요한 from/to 날짜 파라미터를 계산하고 반환합니다. + */ + public Map getShipUpdateApiDateParams() { + // 1. 마지막 성공 일자 (FROM 날짜)를 DB에서 조회 + // 조회된 값이 없으면 (최초 실행), API 호출 시점의 하루 전 날짜를 사용합니다. + LocalDate lastDate = repository.findLastSuccessDate(API_KEY) + .orElse(LocalDate.now().minusDays(1)); + + // 2. 현재 실행 시점의 일자 (TO 날짜) 계산 + LocalDate currentDate = LocalDate.now(); + + // 3. 파라미터 Map 구성 + Map params = new HashMap<>(); + + // FROM Parameters (DB 조회 값) + params.put("fromYear", String.valueOf(lastDate.getYear())); + params.put("fromMonth", String.valueOf(lastDate.getMonthValue())); + params.put("fromDay", String.valueOf(lastDate.getDayOfMonth())); + + // TO Parameters (현재 시점 값) + params.put("toYear", String.valueOf(currentDate.getYear())); + params.put("toMonth", String.valueOf(currentDate.getMonthValue())); + params.put("toDay", String.valueOf(currentDate.getDayOfMonth())); + + // 고정 값 + params.put("shipsCategory", "0"); + + return params; + } + + /** + * 배치 성공 시, 다음 실행을 위해 to 날짜를 DB에 저장 및 업데이트합니다. + * @param successDate API 호출 성공 시 사용된 to 날짜 + */ + @Transactional // UPDATE 쿼리를 사용하므로 트랜잭션 필요 + public void updateLastSuccessDate(LocalDate successDate) { + + // 1. UPDATE 시도 + int updatedRows = repository.updateLastSuccessDate(API_KEY, successDate); + + // 2. 업데이트된 레코드가 없다면 (최초 실행), INSERT 수행 + if (updatedRows == 0) { + BatchLastExecution entity = new BatchLastExecution(API_KEY, successDate); + repository.save(entity); + } + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 4987575..9e47c57 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -4,9 +4,9 @@ spring: # PostgreSQL Database Configuration datasource: - url: jdbc:postgresql://10.26.252.39:5432/mdadb?currentSchema=snp_data - username: mda - password: mda#8932 + 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 @@ -49,15 +49,16 @@ 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 # Server Configuration server: - port: 8041 + port: 8081 +# port: 8041 servlet: - context-path: / + context-path: /snp-api # Actuator Configuration management: @@ -69,18 +70,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: @@ -100,3 +92,44 @@ app: schedule: enabled: true cron: "0 0 * * * ?" # Every hour + + # AIS Target 배치 설정 + ais-target: + since-seconds: 60 # API 조회 범위 (초) + chunk-size: 5000 # 배치 청크 크기 + 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: core20 # 테이블명 + 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 index 267e704..cb01835 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -4,7 +4,7 @@ spring: # PostgreSQL Database Configuration datasource: - url: jdbc:postgresql://10.187.58.58:5432/mdadb?currentSchema=snp_data + url: jdbc:postgresql://10.187.58.58:5432/mdadb username: mda password: mda#8932 driver-class-name: org.postgresql.Driver @@ -28,7 +28,7 @@ spring: batch: jdbc: table-prefix: "snp_data.batch_" - initialize-schema: always # Changed to 'never' as tables already exist + initialize-schema: never # Changed to 'never' as tables already exist job: enabled: false # Prevent auto-run on startup @@ -55,9 +55,10 @@ spring: # Server Configuration server: - port: 8041 + port: 9000 +# port: 8041 servlet: - context-path: / + context-path: /snp-api # Actuator Configuration management: @@ -69,18 +70,11 @@ 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: @@ -100,3 +94,44 @@ app: schedule: enabled: true cron: "0 0 * * * ?" # Every hour + + # AIS Target 배치 설정 + ais-target: + since-seconds: 60 # API 조회 범위 (초) + chunk-size: 5000 # 배치 청크 크기 + 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: core20 # 테이블명 + 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-qa.yml b/src/main/resources/application-qa.yml deleted file mode 100644 index 03f8e3b..0000000 --- a/src/main/resources/application-qa.yml +++ /dev/null @@ -1,101 +0,0 @@ -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: - 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: QRTZ_ - org.quartz.jobStore.isClustered: false - org.quartz.jobStore.misfireThreshold: 60000 - -# Server Configuration -server: - port: 8041 - servlet: - context-path: / - -# Actuator Configuration -management: - endpoints: - web: - exposure: - include: health,info,metrics,prometheus,batch - endpoint: - health: - show-details: always - -# Logging Configuration -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 - -# 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 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8fc3c41..df9bcb9 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -49,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 @@ -91,15 +91,44 @@ app: schedule: enabled: true cron: "0 0 * * * ?" # Every hour + # AIS Target 배치 설정 ais-target: since-seconds: 60 # API 조회 범위 (초) chunk-size: 5000 # 배치 청크 크기 schedule: cron: "15 * * * * ?" # 매 분 15초 실행 - partition: - months-ahead: 2 # 미리 생성할 파티션 개월 수 # 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: core20 # 테이블명 + 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/sql/ais_target_ddl.sql b/src/main/resources/sql/ais_target_ddl.sql index 8e34732..a56ee09 100644 --- a/src/main/resources/sql/ais_target_ddl.sql +++ b/src/main/resources/sql/ais_target_ddl.sql @@ -3,8 +3,8 @@ -- ============================================ -- 용도: 선박 AIS 위치 정보 저장 (항적 분석용) -- 수집 주기: 매 분 15초 --- 예상 데이터량: 약 33,000건/분 --- 파티셔닝: 월별 파티션 (ais_target_YYYY_MM) +-- 예상 데이터량: 약 33,000건/분, 일 20GB (인덱스 포함) +-- 파티셔닝: 일별 파티션 (ais_target_YYMMDD) -- ============================================ -- PostGIS 확장 활성화 (이미 설치되어 있다면 생략) @@ -77,18 +77,26 @@ CREATE TABLE IF NOT EXISTS snp_data.ais_target ( ) PARTITION BY RANGE (message_timestamp); -- ============================================ --- 2. 초기 파티션 생성 (현재 월 + 다음 월) +-- 2. 초기 파티션 생성 (현재 일 + 다음 3일) -- ============================================ --- 예: 2025년 12월과 2026년 1월 파티션 --- 실제 운영 시 create_ais_target_partition 함수로 자동 생성 +-- 파티션 네이밍: ais_target_YYMMDD +-- 실제 운영 시 partitionManagerJob에서 자동 생성 --- 2025년 12월 파티션 -CREATE TABLE IF NOT EXISTS snp_data.ais_target_2025_12 PARTITION OF snp_data.ais_target - FOR VALUES FROM ('2025-12-01 00:00:00+00') TO ('2026-01-01 00:00:00+00'); +-- 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'); --- 2026년 1월 파티션 -CREATE TABLE IF NOT EXISTS snp_data.ais_target_2026_01 PARTITION OF snp_data.ais_target - FOR VALUES FROM ('2026-01-01 00:00:00+00') TO ('2026-02-01 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. 인덱스 생성 (각 파티션에 자동 상속) @@ -120,7 +128,7 @@ CREATE INDEX IF NOT EXISTS idx_ais_target_collected_at ON snp_data.ais_target (collected_at DESC); -- ============================================ --- 4. 파티션 자동 생성 함수 +-- 4. 파티션 자동 생성 함수 (일별) -- ============================================ -- 파티션 존재 여부 확인 함수 @@ -137,8 +145,8 @@ BEGIN END; $$ LANGUAGE plpgsql; --- 특정 월의 파티션 생성 함수 -CREATE OR REPLACE FUNCTION snp_data.create_ais_target_partition(target_date DATE) +-- 특정 일의 파티션 생성 함수 +CREATE OR REPLACE FUNCTION snp_data.create_ais_target_daily_partition(target_date DATE) RETURNS TEXT AS $$ DECLARE partition_name TEXT; @@ -146,12 +154,12 @@ DECLARE end_date DATE; create_sql TEXT; BEGIN - -- 파티션 이름 생성: ais_target_YYYY_MM - partition_name := 'ais_target_' || TO_CHAR(target_date, 'YYYY_MM'); + -- 파티션 이름 생성: ais_target_YYMMDD + partition_name := 'ais_target_' || TO_CHAR(target_date, 'YYMMDD'); -- 시작/종료 날짜 계산 - start_date := DATE_TRUNC('month', target_date)::DATE; - end_date := (DATE_TRUNC('month', target_date) + INTERVAL '1 month')::DATE; + start_date := target_date; + end_date := target_date + INTERVAL '1 day'; -- 이미 존재하면 스킵 IF snp_data.partition_exists(partition_name) THEN @@ -175,18 +183,18 @@ BEGIN END; $$ LANGUAGE plpgsql; --- 다음 N개월 파티션 사전 생성 함수 -CREATE OR REPLACE FUNCTION snp_data.create_future_ais_target_partitions(months_ahead INTEGER DEFAULT 2) +-- 다음 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..months_ahead LOOP - target_date := DATE_TRUNC('month', CURRENT_DATE + (i || ' months')::INTERVAL)::DATE; - result := snp_data.create_ais_target_partition(target_date); - partition_name := 'ais_target_' || TO_CHAR(target_date, 'YYYY_MM'); + 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; @@ -194,17 +202,17 @@ END; $$ LANGUAGE plpgsql; -- ============================================ --- 5. 오래된 파티션 삭제 함수 +-- 5. 오래된 파티션 삭제 함수 (일별) -- ============================================ --- 특정 월의 파티션 삭제 함수 -CREATE OR REPLACE FUNCTION snp_data.drop_ais_target_partition(target_date DATE) +-- 특정 일의 파티션 삭제 함수 +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, 'YYYY_MM'); + partition_name := 'ais_target_' || TO_CHAR(target_date, 'YYMMDD'); -- 존재하지 않으면 스킵 IF NOT snp_data.partition_exists(partition_name) THEN @@ -221,17 +229,17 @@ BEGIN END; $$ LANGUAGE plpgsql; --- N개월 이전 파티션 정리 함수 -CREATE OR REPLACE FUNCTION snp_data.cleanup_old_ais_target_partitions(retention_months INTEGER DEFAULT 3) +-- 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 := DATE_TRUNC('month', CURRENT_DATE - (retention_months || ' months')::INTERVAL)::DATE; + cutoff_date := CURRENT_DATE - retention_days; - -- ais_target_YYYY_MM 패턴의 파티션 조회 + -- ais_target_YYMMDD 패턴의 파티션 조회 FOR rec IN SELECT c.relname FROM pg_class c @@ -239,12 +247,13 @@ BEGIN 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_YYYY_MM) + -- 파티션 이름에서 날짜 추출 (ais_target_YYMMDD) BEGIN - partition_date := TO_DATE(SUBSTRING(rec.relname FROM 'ais_target_(\d{4}_\d{2})'), 'YYYY_MM'); + 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); @@ -261,8 +270,8 @@ BEGIN END; $$ LANGUAGE plpgsql; --- 파티션별 통계 조회 함수 -CREATE OR REPLACE FUNCTION snp_data.ais_target_partition_stats() +-- 파티션별 통계 조회 함수 (일별) +CREATE OR REPLACE FUNCTION snp_data.ais_target_daily_partition_stats() RETURNS TABLE ( partition_name TEXT, row_count BIGINT, @@ -273,11 +282,7 @@ BEGIN RETURN QUERY SELECT c.relname::TEXT as partition_name, - (SELECT COUNT(*)::BIGINT FROM snp_data.ais_target WHERE message_timestamp >= - TO_DATE(SUBSTRING(c.relname FROM 'ais_target_(\d{4}_\d{2})'), 'YYYY_MM') - AND message_timestamp < - TO_DATE(SUBSTRING(c.relname FROM 'ais_target_(\d{4}_\d{2})'), 'YYYY_MM') + INTERVAL '1 month' - ) as row_count, + (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 @@ -294,7 +299,7 @@ $$ LANGUAGE plpgsql; -- 6. 코멘트 -- ============================================ -COMMENT ON TABLE snp_data.ais_target IS 'AIS 선박 위치 정보 (매 분 15초 수집, 월별 파티션)'; +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, 파티션 키)'; @@ -308,33 +313,6 @@ 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.cleanup_ais_target(retention_days INTEGER DEFAULT 7) -RETURNS INTEGER AS $$ -DECLARE - deleted_count INTEGER; -BEGIN - DELETE FROM snp_data.ais_target - WHERE message_timestamp < NOW() - (retention_days || ' days')::INTERVAL; - - GET DIAGNOSTICS deleted_count = ROW_COUNT; - - RAISE NOTICE 'Deleted % rows older than % days', deleted_count, retention_days; - - RETURN deleted_count; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION snp_data.create_ais_target_partition IS '특정 월의 AIS Target 파티션 생성'; -COMMENT ON FUNCTION snp_data.create_future_ais_target_partitions IS '향후 N개월 파티션 사전 생성'; -COMMENT ON FUNCTION snp_data.drop_ais_target_partition IS '특정 월의 파티션 삭제'; -COMMENT ON FUNCTION snp_data.cleanup_old_ais_target_partitions IS 'N개월 이전 파티션 정리'; -COMMENT ON FUNCTION snp_data.ais_target_partition_stats IS '파티션별 통계 조회'; - -- ============================================ -- 7. 유지보수용 함수: 통계 조회 -- ============================================ @@ -362,6 +340,11 @@ 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 '파티션별 통계 조회'; -- ============================================ -- 예시 쿼리 @@ -373,7 +356,7 @@ COMMENT ON FUNCTION snp_data.ais_target_stats IS 'AIS Target 테이블 통계 -- 2. 특정 시간 범위의 항적 조회 -- SELECT * FROM snp_data.ais_target -- WHERE mmsi = 123456789 --- AND message_timestamp BETWEEN '2025-12-01 00:00:00+00' AND '2025-12-01 01:00:00+00' +-- AND message_timestamp BETWEEN '2024-12-04 00:00:00+00' AND '2024-12-04 01:00:00+00' -- ORDER BY message_timestamp; -- 3. 특정 구역(원형) 내 선박 조회 @@ -387,26 +370,19 @@ COMMENT ON FUNCTION snp_data.ais_target_stats IS 'AIS Target 테이블 통계 -- ) -- ORDER BY mmsi, message_timestamp DESC; --- 4. LineString 항적 생성 --- SELECT mmsi, ST_MakeLine(geom ORDER BY message_timestamp) as track --- FROM snp_data.ais_target --- WHERE mmsi = 123456789 --- AND message_timestamp BETWEEN '2025-12-01 00:00:00+00' AND '2025-12-01 01:00:00+00' --- GROUP BY mmsi; +-- 4. 다음 7일 파티션 미리 생성 +-- SELECT * FROM snp_data.create_future_ais_target_daily_partitions(7); --- 5. 다음 3개월 파티션 미리 생성 --- SELECT * FROM snp_data.create_future_ais_target_partitions(3); +-- 5. 특정 일 파티션 생성 +-- SELECT snp_data.create_ais_target_daily_partition('2024-12-10'); --- 6. 특정 월 파티션 생성 --- SELECT snp_data.create_ais_target_partition('2026-03-01'); +-- 6. 14일 이전 파티션 정리 +-- SELECT * FROM snp_data.cleanup_old_ais_target_daily_partitions(14); --- 7. 3개월 이전 파티션 정리 --- SELECT * FROM snp_data.cleanup_old_ais_target_partitions(3); +-- 7. 파티션별 통계 조회 +-- SELECT * FROM snp_data.ais_target_daily_partition_stats(); --- 8. 파티션별 통계 조회 --- SELECT * FROM snp_data.ais_target_partition_stats(); - --- 9. 전체 통계 조회 +-- 8. 전체 통계 조회 -- SELECT * FROM snp_data.ais_target_stats(); -- ============================================ @@ -431,12 +407,13 @@ VALUES ( updated_at = NOW(); -- 2. partitionManagerJob: 매일 00:10에 실행 --- Daily 파티션: 매일 생성, Monthly 파티션: 말일에만 생성 (Job 내부에서 분기) +-- 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: 말일만)', + '파티션 관리 - 매일 00:10 실행 (Daily: 생성/삭제, Monthly: 말일 생성/1일 삭제)', true, NOW(), 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 @@ - + + - + + - + + + + + + + +