✨ AnchorageCalls, Berthcalls, DarkActivity, StsOperations, TerminalCalls Job 개발
This commit is contained in:
부모
2671d613f3
커밋
e7f4a9d912
@ -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) {
|
||||
|
||||
@ -15,12 +15,38 @@ public class JsonChangeDetector {
|
||||
private static final java.util.Set<String> EXCLUDE_KEYS =
|
||||
java.util.Set.of("DataSetVersion", "APSStatus", "LastUpdateDate", "LastUpdateDateTime");
|
||||
|
||||
private static final Map<String, String> LIST_SORT_KEYS = Map.of(
|
||||
// List 필드명 // 정렬 기준 키
|
||||
"OwnerHistory" ,"Sequence", // OwnerHistory는 Sequence를 기준으로 정렬
|
||||
"SurveyDatesHistoryUnique" , "SurveyDate" // SurveyDatesHistoryUnique는 SurveyDate를 기준으로 정렬
|
||||
// 추가적인 List/Array 필드가 있다면 여기에 추가
|
||||
);
|
||||
// =========================================================================
|
||||
// ✅ LIST_SORT_KEYS: 정적 초기화 블록을 사용한 Map 정의
|
||||
// =========================================================================
|
||||
private static final Map<String, String> LIST_SORT_KEYS;
|
||||
static {
|
||||
// TreeMap을 사용하여 키를 알파벳 순으로 정렬할 수도 있지만, 여기서는 HashMap을 사용하고 final로 만듭니다.
|
||||
Map<String, String> 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<Object>() {
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
@ -105,22 +133,45 @@ public class JsonChangeDetector {
|
||||
Map<String, Object> map1 = (Map<String, Object>) o1;
|
||||
Map<String, Object> map2 = (Map<String, Object>) o2;
|
||||
|
||||
// 정렬 기준 키(sortKey)의 값을 가져와 비교
|
||||
// 복합 키(sortKeys)를 순서대로 순회하며 비교
|
||||
for (String rawSortKey : sortKeys) {
|
||||
// 키의 공백 제거
|
||||
String sortKey = rawSortKey.trim();
|
||||
|
||||
Object key1 = map1.get(sortKey);
|
||||
Object key2 = map2.get(sortKey);
|
||||
|
||||
if (key1 == null || key2 == null) {
|
||||
// 키 값이 null인 경우, Map의 전체 문자열로 비교 (안전장치)
|
||||
return map1.toString().compareTo(map2.toString());
|
||||
// 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 타입으로 변환하여 비교 (Date, Number 타입도 대부분 String으로 처리 가능)
|
||||
return key1.toString().compareTo(key2.toString());
|
||||
// 값을 문자열로 변환하여 비교 (String, Number, Date 타입 모두 처리 가능)
|
||||
int comparisonResult = key1.toString().compareTo(key2.toString());
|
||||
|
||||
// 현재 키에서 순서가 결정되면 즉시 반환
|
||||
if (comparisonResult != 0) {
|
||||
return comparisonResult;
|
||||
}
|
||||
// comparisonResult == 0 이면 다음 키로 이동하여 비교를 계속함
|
||||
}
|
||||
|
||||
// 모든 키를 비교해도 동일한 경우
|
||||
// 이 경우 두 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. 해시 생성 로직
|
||||
// =========================================================================
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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("운영 서버 프록시")
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@ -178,7 +178,7 @@ public class BatchController {
|
||||
}
|
||||
}
|
||||
|
||||
@PutMapping("/schedules/{jobName}")
|
||||
@PostMapping("/schedules/{jobName}/update")
|
||||
public ResponseEntity<Map<String, Object>> updateSchedule(
|
||||
@PathVariable String jobName,
|
||||
@RequestBody Map<String, String> 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<Map<String, Object>> 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<Map<String, Object>> toggleSchedule(
|
||||
@PathVariable String jobName,
|
||||
@RequestBody Map<String, Boolean> request) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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<PartitionTableInfo> dailyPartitionTables = List.of(
|
||||
// 추후 daily 파티션 테이블 추가
|
||||
);
|
||||
private List<PartitionTableConfig> dailyTables = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Monthly 파티션 대상 테이블 (파티션 네이밍: {table}_YYYY_MM)
|
||||
* 월별 파티션 테이블 목록 (파티션 네이밍: {table}_YYYY_MM)
|
||||
*/
|
||||
private final List<PartitionTableInfo> monthlyPartitionTables = List.of(
|
||||
new PartitionTableInfo(
|
||||
"snp_data",
|
||||
"ais_target",
|
||||
"message_timestamp",
|
||||
2 // 미리 생성할 개월 수
|
||||
)
|
||||
);
|
||||
private List<PartitionTableConfig> 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<CustomRetention> 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<CustomRetention> getCustomRetention(String tableName) {
|
||||
if (retention.getCustom() == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return retention.getCustom().stream()
|
||||
.filter(c -> tableName.equals(c.getTableName()))
|
||||
.findFirst();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<PartitionTableInfo> tables = partitionConfig.getDailyPartitionTables();
|
||||
// ==================== Daily 파티션 생성 ====================
|
||||
|
||||
if (tables.isEmpty()) {
|
||||
log.info("Daily 파티션: 대상 테이블 없음");
|
||||
/**
|
||||
* Daily 파티션 생성
|
||||
*/
|
||||
private void createDailyPartitions(LocalDate today) {
|
||||
List<PartitionTableConfig> 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<String> created = new ArrayList<>();
|
||||
List<String> 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<PartitionTableInfo> tables = partitionConfig.getMonthlyPartitionTables();
|
||||
// ==================== Daily 파티션 삭제 ====================
|
||||
|
||||
if (tables.isEmpty()) {
|
||||
log.info("Monthly 파티션: 대상 테이블 없음");
|
||||
/**
|
||||
* Daily 파티션 삭제 (보관기간 초과분)
|
||||
*/
|
||||
private void deleteDailyPartitions(LocalDate today) {
|
||||
List<PartitionTableConfig> 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<String> partitions = jdbcTemplate.queryForList(
|
||||
FIND_PARTITIONS_SQL, String.class, table.getSchema(), likePattern);
|
||||
|
||||
List<String> 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<PartitionTableConfig> 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<String> created = new ArrayList<>();
|
||||
List<String> 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<PartitionTableConfig> 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<String> partitions = jdbcTemplate.queryForList(
|
||||
FIND_PARTITIONS_SQL, String.class, table.getSchema(), likePattern);
|
||||
|
||||
List<String> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<BatchLastExecution, String> {
|
||||
// 1. findLastSuccessDate 함수 구현
|
||||
/**
|
||||
* API 키를 기준으로 마지막 성공 일자를 조회합니다.
|
||||
* @param apiKey 조회할 API 키 (예: "SHIP_UPDATE_API")
|
||||
* @return 마지막 성공 일자 (LocalDate)를 포함하는 Optional
|
||||
*/
|
||||
@Query("SELECT b.lastSuccessDate FROM BatchLastExecution b WHERE b.apiKey = :apiKey")
|
||||
Optional<LocalDate> 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);
|
||||
}
|
||||
@ -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<AisTargetDto, AisTar
|
||||
private final AisTargetDataProcessor aisTargetDataProcessor;
|
||||
private final AisTargetDataWriter aisTargetDataWriter;
|
||||
private final WebClient maritimeAisApiWebClient;
|
||||
private final Core20CacheManager core20CacheManager;
|
||||
|
||||
@Value("${app.batch.ais-target.since-seconds:60}")
|
||||
private int sinceSeconds;
|
||||
@ -55,11 +57,13 @@ public class AisTargetImportJobConfig extends BaseJobConfig<AisTargetDto, AisTar
|
||||
PlatformTransactionManager transactionManager,
|
||||
AisTargetDataProcessor aisTargetDataProcessor,
|
||||
AisTargetDataWriter aisTargetDataWriter,
|
||||
@Qualifier("maritimeAisApiWebClient") WebClient maritimeAisApiWebClient) {
|
||||
@Qualifier("maritimeAisApiWebClient") WebClient maritimeAisApiWebClient,
|
||||
Core20CacheManager core20CacheManager) {
|
||||
super(jobRepository, transactionManager);
|
||||
this.aisTargetDataProcessor = aisTargetDataProcessor;
|
||||
this.aisTargetDataWriter = aisTargetDataWriter;
|
||||
this.maritimeAisApiWebClient = maritimeAisApiWebClient;
|
||||
this.core20CacheManager = core20CacheManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -101,16 +105,29 @@ public class AisTargetImportJobConfig extends BaseJobConfig<AisTargetDto, AisTar
|
||||
OffsetDateTime collectedAt = OffsetDateTime.now();
|
||||
aisTargetDataProcessor.setCollectedAt(collectedAt);
|
||||
log.info("[{}] Job 시작 - 수집 시점: {}", getJobName(), collectedAt);
|
||||
|
||||
// Core20 캐시 관리
|
||||
// 1. 캐시가 비어있으면 즉시 로딩 (첫 실행 또는 재시작 시)
|
||||
// 2. 지정된 시간대(기본 04:00)이면 일일 갱신 수행
|
||||
if (!core20CacheManager.isLoaded()) {
|
||||
log.info("[{}] Core20 캐시 초기 로딩 시작", getJobName());
|
||||
core20CacheManager.refresh();
|
||||
} else if (core20CacheManager.shouldRefresh()) {
|
||||
log.info("[{}] Core20 캐시 일일 갱신 시작 (스케줄: {}시)",
|
||||
getJobName(), core20CacheManager.getLastRefreshTime());
|
||||
core20CacheManager.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterJob(JobExecution jobExecution) {
|
||||
log.info("[{}] Job 완료 - 상태: {}, 처리 건수: {}",
|
||||
log.info("[{}] Job 완료 - 상태: {}, 처리 건수: {}, Core20 캐시 크기: {}",
|
||||
getJobName(),
|
||||
jobExecution.getStatus(),
|
||||
jobExecution.getStepExecutions().stream()
|
||||
.mapToLong(se -> se.getWriteCount())
|
||||
.sum());
|
||||
.sum(),
|
||||
core20CacheManager.size());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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<AisTargetEntity> {
|
||||
|
||||
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<AisTargetEntity> 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 데이터 저장 완료: {} 건 (캐시 크기: {})",
|
||||
|
||||
@ -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<AisTargetEntity> filterByClassType(List<AisTargetEntity> entities, AisTargetSearchRequest request) {
|
||||
if (entities == null || entities.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
if (!request.hasClassTypeFilter()) {
|
||||
return entities;
|
||||
}
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
List<AisTargetEntity> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<String> 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<AisTargetEntity> 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();
|
||||
}
|
||||
}
|
||||
@ -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<String, String> 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<String> 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<String, String> 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<String, Object> getStats() {
|
||||
Map<String, Object> 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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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<AisTargetResponseDto> 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<ApiResponse<List<AisTargetResponseDto>>> 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<AisTargetResponseDto> result = aisTargetService.search(request);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
|
||||
@ -121,6 +121,16 @@ public class AisTargetFilterRequest {
|
||||
example = "[\"Under way using engine\", \"Anchored\", \"Moored\"]")
|
||||
private List<String> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -122,11 +122,12 @@ public class AisTargetService {
|
||||
* 전략:
|
||||
* 1. 캐시에서 시간 범위 내 데이터 조회
|
||||
* 2. 공간 필터 있으면 JTS로 필터링
|
||||
* 3. 캐시 데이터가 없으면 DB Fallback
|
||||
* 3. ClassType 필터 있으면 적용
|
||||
* 4. 캐시 데이터가 없으면 DB Fallback
|
||||
*/
|
||||
public List<AisTargetResponseDto> 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);
|
||||
|
||||
@ -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<PscInspectionDto, PscInspectionEntity> {
|
||||
|
||||
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<PscInspectionDto> createReader() {
|
||||
return pscApiReader(null, null, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemProcessor<PscInspectionDto, PscInspectionEntity> createProcessor() {
|
||||
return pscInspectionProcessor;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemWriter<PscInspectionEntity> 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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -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<PscInspectionDto> inspections;
|
||||
@JsonProperty("inspectionCount")
|
||||
private Integer inspectionCount;
|
||||
|
||||
@JsonProperty("APSStatus")
|
||||
private PscApsStatusDto apsStatus;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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<PscDefectDto> pscDefects;
|
||||
|
||||
@JsonProperty("PSCCertificates")
|
||||
private List<PscCertificateDto> pscCertificates;
|
||||
|
||||
@JsonProperty("PSCAllCertificates")
|
||||
private List<PscAllCertificateDto> pscAllCertificates;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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<PscDefectEntity> defects;
|
||||
private List<PscCertificateEntity> certificates;
|
||||
private List<PscAllCertificateEntity> allCertificates;
|
||||
}
|
||||
@ -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<PscInspectionDto, PscInspectionEntity> {
|
||||
|
||||
@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<PscDefectEntity> convertDefectDtos(List<PscDefectDto> 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<PscCertificateEntity> convertCertificateDtos(List<PscCertificateDto> 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<PscAllCertificateEntity> convertAllCertificateDtos(List<PscAllCertificateDto> 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<DateTimeFormatter> 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;
|
||||
}
|
||||
}
|
||||
@ -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<PscInspectionDto> {
|
||||
|
||||
//private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
private final String fromDate;
|
||||
private final String toDate;
|
||||
// private List<String> allImoNumbers;
|
||||
private List<PscInspectionDto> 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<PscInspectionDto> 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<PscInspectionDto> 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<PscInspectionDto> callApiWithBatch(String lrno) {
|
||||
private List<PscInspectionDto> 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<PscInspectionDto> data) {
|
||||
if (data == null) {
|
||||
int totalBatches = (int) Math.ceil((double) allData.size() / batchSize);
|
||||
log.info("[{}] 전체 {} 개 배치 처리 완료", getReaderName(), totalBatches);
|
||||
log.info("[{}] 총 {} 개의 IMO 번호에 대한 API 호출 종료",
|
||||
getReaderName(), allData.size());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<PscAllCertificateEntity> certificates);
|
||||
}
|
||||
@ -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<PscAllCertificateEntity, String>
|
||||
implements PscAllCertificateRepository {
|
||||
public PscAllCertificateRepositoryImpl(JdbcTemplate jdbcTemplate) {
|
||||
super(jdbcTemplate);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getTableName() {
|
||||
return "new_snp.psc_all_certificate";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected RowMapper<PscAllCertificateEntity> 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<PscAllCertificateEntity> entities) {
|
||||
if (entities == null || entities.isEmpty()) return;
|
||||
log.info("PSC AllCertificates 저장 시작 = {}건", entities.size());
|
||||
batchInsert(entities);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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<PscCertificateEntity> certificates);
|
||||
}
|
||||
@ -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<PscCertificateEntity, String>
|
||||
implements PscCertificateRepository {
|
||||
public PscCertificateRepositoryImpl(JdbcTemplate jdbcTemplate) {
|
||||
super(jdbcTemplate);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getTableName() {
|
||||
return "new_snp.psc_certificate";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected RowMapper<PscCertificateEntity> 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<PscCertificateEntity> entities) {
|
||||
if (entities == null || entities.isEmpty()) return;
|
||||
log.info("PSC Certificate 저장 시작 = {}건", entities.size());
|
||||
batchInsert(entities);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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<PscDefectEntity> defects);
|
||||
}
|
||||
@ -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<PscDefectEntity, String>
|
||||
implements PscDefectRepository {
|
||||
public PscDefectRepositoryImpl(JdbcTemplate jdbcTemplate) {
|
||||
super(jdbcTemplate);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getTableName() {
|
||||
return "new_snp.psc_detail";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected RowMapper<PscDefectEntity> 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<PscDefectEntity> entities) {
|
||||
if (entities == null || entities.isEmpty()) return;
|
||||
log.info("PSC Defect 저장 시작 = {}건", entities.size());
|
||||
batchInsert(entities);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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<PscInspectionEntity> entities);
|
||||
}
|
||||
@ -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<PscInspectionEntity, String>
|
||||
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<PscInspectionEntity> 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<PscInspectionEntity> getRowMapper() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -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<PscInspectionEntity> {
|
||||
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<PscInspectionEntity> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -65,7 +65,7 @@ public class RiskRepositoryImpl extends BaseJdbcRepository<RiskEntity, Long> imp
|
||||
VALUES (
|
||||
?, ?::timestamptz, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'N'
|
||||
)
|
||||
ON CONFLICT (lrno)
|
||||
ON CONFLICT (lrno, lastupdated)
|
||||
DO UPDATE SET
|
||||
riskdatamaintained = EXCLUDED.riskdatamaintained,
|
||||
dayssincelastseenonais = EXCLUDED.dayssincelastseenonais,
|
||||
|
||||
@ -58,7 +58,7 @@ public class ComplianceRepositoryImpl extends BaseJdbcRepository<ComplianceEntit
|
||||
VALUES (
|
||||
?, ?::timestamptz, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'N'
|
||||
)
|
||||
ON CONFLICT (lrimoshipno)
|
||||
ON CONFLICT (lrimoshipno, dateamended)
|
||||
DO UPDATE SET
|
||||
legaloverall = EXCLUDED.legaloverall,
|
||||
shipbessanctionlist = EXCLUDED.shipbessanctionlist,
|
||||
|
||||
@ -2,7 +2,7 @@ package com.snp.batch.jobs.shipMovement.batch.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.snp.batch.common.batch.config.BaseJobConfig;
|
||||
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 com.snp.batch.jobs.shipMovement.batch.processor.ShipMovementProcessor;
|
||||
import com.snp.batch.jobs.shipMovement.batch.reader.ShipMovementReader;
|
||||
@ -47,7 +47,7 @@ import java.time.format.DateTimeFormatter;
|
||||
|
||||
@Slf4j
|
||||
@Configuration
|
||||
public class ShipMovementJobConfig extends BaseJobConfig<PortCallDto, ShipMovementEntity> {
|
||||
public class ShipMovementJobConfig extends BaseJobConfig<PortCallsDto, ShipMovementEntity> {
|
||||
|
||||
private final ShipMovementProcessor shipMovementProcessor;
|
||||
private final ShipMovementWriter shipMovementWriter;
|
||||
@ -101,14 +101,14 @@ public class ShipMovementJobConfig extends BaseJobConfig<PortCallDto, ShipMoveme
|
||||
return reader;
|
||||
}
|
||||
@Override
|
||||
protected ItemReader<PortCallDto> createReader() { // 타입 변경
|
||||
protected ItemReader<PortCallsDto> createReader() { // 타입 변경
|
||||
// Reader 생성자 수정: ObjectMapper를 전달합니다.
|
||||
return shipMovementReader(null, null);
|
||||
//return new ShipMovementReader(maritimeApiWebClient, jdbcTemplate, objectMapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemProcessor<PortCallDto, ShipMovementEntity> createProcessor() {
|
||||
protected ItemProcessor<PortCallsDto, ShipMovementEntity> createProcessor() {
|
||||
return shipMovementProcessor;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -8,5 +8,5 @@ import java.util.List;
|
||||
@Data
|
||||
public class ShipMovementApiResponse {
|
||||
@JsonProperty("portCalls")
|
||||
List<PortCallDto> portCallList;
|
||||
List<PortCallsDto> portCallList;
|
||||
}
|
||||
|
||||
@ -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<PortCallDto, ShipMovementEntity> {
|
||||
public class ShipMovementProcessor extends BaseProcessor<PortCallsDto, ShipMovementEntity> {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@ -31,7 +31,7 @@ public class ShipMovementProcessor extends BaseProcessor<PortCallDto, ShipMoveme
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ShipMovementEntity processItem(PortCallDto dto) throws Exception {
|
||||
protected ShipMovementEntity processItem(PortCallsDto dto) throws Exception {
|
||||
log.debug("선박 상세 정보 처리 시작: imoNumber={}, facilityName={}",
|
||||
dto.getImolRorIHSNumber(), dto.getFacilityName());
|
||||
|
||||
|
||||
@ -1,15 +1,9 @@
|
||||
package com.snp.batch.jobs.shipMovement.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.shipMovement.batch.dto.PortCallDto;
|
||||
import com.snp.batch.jobs.shipMovement.batch.dto.PortCallsDto;
|
||||
import com.snp.batch.jobs.shipMovement.batch.dto.ShipMovementApiResponse;
|
||||
import com.snp.batch.jobs.shipdetail.batch.dto.ShipDetailApiResponse;
|
||||
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.ShipResultDto;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.batch.core.configuration.annotation.StepScope;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@ -40,7 +34,7 @@ import java.util.*;
|
||||
*/
|
||||
@Slf4j
|
||||
@StepScope
|
||||
public class ShipMovementReader extends BaseApiReader<PortCallDto> {
|
||||
public class ShipMovementReader extends BaseApiReader<PortCallsDto> {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
@ -91,8 +85,8 @@ public class ShipMovementReader extends BaseApiReader<PortCallDto> {
|
||||
}
|
||||
|
||||
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<PortCallDto> {
|
||||
* @return 다음 배치 100건 (더 이상 없으면 null)
|
||||
*/
|
||||
@Override
|
||||
protected List<PortCallDto> fetchNextBatch() throws Exception {
|
||||
protected List<PortCallsDto> fetchNextBatch() throws Exception {
|
||||
|
||||
// 모든 배치 처리 완료 확인
|
||||
if (allImoNumbers == null || currentBatchIndex >= allImoNumbers.size()) {
|
||||
@ -158,7 +152,7 @@ public class ShipMovementReader extends BaseApiReader<PortCallDto> {
|
||||
|
||||
// 응답 처리
|
||||
if (response != null && response.getPortCallList() != null) {
|
||||
List<PortCallDto> portCalls = response.getPortCallList();
|
||||
List<PortCallsDto> portCalls = response.getPortCallList();
|
||||
log.info("[{}] 배치 {}/{} 완료: {} 건 조회",
|
||||
getReaderName(), currentBatchNumber, totalBatches, portCalls.size());
|
||||
|
||||
@ -213,7 +207,7 @@ public class ShipMovementReader extends BaseApiReader<PortCallDto> {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void afterFetch(List<PortCallDto> data) {
|
||||
protected void afterFetch(List<PortCallsDto> data) {
|
||||
if (data == null) {
|
||||
int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize);
|
||||
log.info("[{}] 전체 {} 개 배치 처리 완료", getReaderName(), totalBatches);
|
||||
|
||||
@ -69,7 +69,7 @@ public class ShipMovementRepositoryImpl extends BaseJdbcRepository<ShipMovementE
|
||||
iso2_ntn_cd,
|
||||
lcinfo
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (imo, mvmn_dt)
|
||||
ON CONFLICT (imo, mvmn_type, mvmn_dt)
|
||||
DO UPDATE SET
|
||||
mvmn_type = EXCLUDED.mvmn_type,
|
||||
mvmn_dt = EXCLUDED.mvmn_dt,
|
||||
|
||||
@ -0,0 +1,104 @@
|
||||
package com.snp.batch.jobs.shipMovementAnchorageCalls.batch.config;
|
||||
|
||||
import com.snp.batch.common.batch.config.BaseJobConfig;
|
||||
import com.snp.batch.jobs.shipMovementAnchorageCalls.batch.dto.AnchorageCallsDto;
|
||||
import com.snp.batch.jobs.shipMovementAnchorageCalls.batch.entity.AnchorageCallsEntity;
|
||||
import com.snp.batch.jobs.shipMovementAnchorageCalls.batch.processor.AnchorageCallsProcessor;
|
||||
import com.snp.batch.jobs.shipMovementAnchorageCalls.batch.reader.AnchorageCallsReader;
|
||||
import com.snp.batch.jobs.shipMovementAnchorageCalls.batch.writer.AnchorageCallsWriter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.batch.core.Job;
|
||||
import org.springframework.batch.core.Step;
|
||||
import org.springframework.batch.core.repository.JobRepository;
|
||||
import org.springframework.batch.item.ItemProcessor;
|
||||
import org.springframework.batch.item.ItemReader;
|
||||
import org.springframework.batch.item.ItemWriter;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
/**
|
||||
* 선박 상세 정보 Import Job Config
|
||||
*
|
||||
* 특징:
|
||||
* - ship_data 테이블에서 IMO 번호 조회
|
||||
* - IMO 번호를 100개씩 배치로 분할
|
||||
* - Maritime API GetShipsByIHSLRorIMONumbers 호출
|
||||
* TODO : GetShipsByIHSLRorIMONumbersAll 호출로 변경
|
||||
* - 선박 상세 정보를 ship_detail 테이블에 저장 (UPSERT)
|
||||
*
|
||||
* 데이터 흐름:
|
||||
* AnchorageCallsReader (ship_data → Maritime API)
|
||||
* ↓ (AnchorageCallsDto)
|
||||
* AnchorageCallsProcessor
|
||||
* ↓ (AnchorageCallsEntity)
|
||||
* AnchorageCallsWriter
|
||||
* ↓ (t_anchoragecall 테이블)
|
||||
*/
|
||||
|
||||
@Slf4j
|
||||
@Configuration
|
||||
public class AnchorageCallsJobConfig extends BaseJobConfig<AnchorageCallsDto, AnchorageCallsEntity> {
|
||||
|
||||
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<AnchorageCallsDto> createReader() { // 타입 변경
|
||||
return new AnchorageCallsReader(maritimeApiWebClient, jdbcTemplate);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemProcessor<AnchorageCallsDto, AnchorageCallsEntity> createProcessor() {
|
||||
return anchorageCallsProcessor;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemWriter<AnchorageCallsEntity> 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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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<AnchorageCallsDto, AnchorageCallsEntity> {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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<AnchorageCallsDto> {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
// 배치 처리 상태
|
||||
private List<String> allImoNumbers;
|
||||
// DB 해시값을 저장할 맵
|
||||
private Map<String, String> 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<AnchorageCallsDto> 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<String> 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<AnchorageCallsDto> response = callApiWithBatch(imoParam);
|
||||
|
||||
// 다음 배치로 인덱스 이동
|
||||
currentBatchIndex = endIndex;
|
||||
|
||||
|
||||
// 응답 처리
|
||||
if (response != null ) {
|
||||
List<AnchorageCallsDto> 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<AnchorageCallsDto> 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<AnchorageCallsDto> data) {
|
||||
if (data == null) {
|
||||
int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize);
|
||||
log.info("[{}] 전체 {} 개 배치 처리 완료", getReaderName(), totalBatches);
|
||||
log.info("[{}] 총 {} 개의 IMO 번호에 대한 API 호출 종료",
|
||||
getReaderName(), allImoNumbers.size());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -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<AnchorageCallsEntity> entities);
|
||||
}
|
||||
@ -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<AnchorageCallsEntity, String>
|
||||
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<AnchorageCallsEntity> getRowMapper() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveAll(List<AnchorageCallsEntity> entities) {
|
||||
if (entities == null || entities.isEmpty()) return;
|
||||
|
||||
log.info("ShipMovement 저장 시작 = {}건", entities.size());
|
||||
batchInsert(entities);
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* ShipDetailEntity RowMapper
|
||||
*/
|
||||
private static class AnchorageCallsRowMapper implements RowMapper<AnchorageCallsEntity> {
|
||||
@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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<AnchorageCallsEntity> {
|
||||
|
||||
private final AnchorageCallsRepository anchorageCallsRepository;
|
||||
|
||||
|
||||
public AnchorageCallsWriter(AnchorageCallsRepository anchorageCallsRepository) {
|
||||
super("AnchorageCalls");
|
||||
this.anchorageCallsRepository = anchorageCallsRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void writeItems(List<AnchorageCallsEntity> items) throws Exception {
|
||||
|
||||
if (items.isEmpty()) { return; }
|
||||
|
||||
anchorageCallsRepository.saveAll(items);
|
||||
log.info("AnchorageCalls 데이터 저장: {} 건", items.size());
|
||||
}
|
||||
|
||||
}
|
||||
@ -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<BerthCallsDto, BerthCallsEntity> {
|
||||
|
||||
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<BerthCallsDto> createReader() { // 타입 변경
|
||||
return new BerthCallsReader(maritimeApiWebClient, jdbcTemplate);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemProcessor<BerthCallsDto, BerthCallsEntity> createProcessor() {
|
||||
return berthCallsProcessor;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemWriter<BerthCallsEntity> 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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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<BerthCallsDto, BerthCallsEntity> {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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<BerthCallsDto> {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
// 배치 처리 상태
|
||||
private List<String> allImoNumbers;
|
||||
// DB 해시값을 저장할 맵
|
||||
private Map<String, String> 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<BerthCallsDto> 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<String> 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<BerthCallsDto> response = callApiWithBatch(imoParam);
|
||||
|
||||
// 다음 배치로 인덱스 이동
|
||||
currentBatchIndex = endIndex;
|
||||
|
||||
|
||||
// 응답 처리
|
||||
if (response != null ) {
|
||||
List<BerthCallsDto> 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<BerthCallsDto> 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<BerthCallsDto> data) {
|
||||
if (data == null) {
|
||||
int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize);
|
||||
log.info("[{}] 전체 {} 개 배치 처리 완료", getReaderName(), totalBatches);
|
||||
log.info("[{}] 총 {} 개의 IMO 번호에 대한 API 호출 종료",
|
||||
getReaderName(), allImoNumbers.size());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -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<BerthCallsEntity> entities);
|
||||
}
|
||||
@ -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<BerthCallsEntity, String>
|
||||
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<BerthCallsEntity> getRowMapper() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveAll(List<BerthCallsEntity> entities) {
|
||||
if (entities == null || entities.isEmpty()) return;
|
||||
|
||||
log.info("BerthCalls 저장 시작 = {}건", entities.size());
|
||||
batchInsert(entities);
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* ShipDetailEntity RowMapper
|
||||
*/
|
||||
private static class BerthCallsRowMapper implements RowMapper<BerthCallsEntity> {
|
||||
@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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<BerthCallsEntity> {
|
||||
|
||||
private final BerthCallsRepository berthCallsRepository;
|
||||
|
||||
|
||||
public BerthCallsWriter(BerthCallsRepository berthCallsRepository) {
|
||||
super("BerthCalls");
|
||||
this.berthCallsRepository = berthCallsRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void writeItems(List<BerthCallsEntity> items) throws Exception {
|
||||
|
||||
if (items.isEmpty()) { return; }
|
||||
|
||||
berthCallsRepository.saveAll(items);
|
||||
log.info("BerthCalls 데이터 저장: {} 건", items.size());
|
||||
}
|
||||
|
||||
}
|
||||
@ -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<CurrentlyAtDto, CurrentlyAtEntity> {
|
||||
|
||||
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<CurrentlyAtDto> createReader() { // 타입 변경
|
||||
return new CurrentlyAtReader(maritimeApiWebClient, jdbcTemplate);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemProcessor<CurrentlyAtDto, CurrentlyAtEntity> createProcessor() {
|
||||
return currentlyAtProcessor;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemWriter<CurrentlyAtEntity> 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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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<CurrentlyAtDto, CurrentlyAtEntity> {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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 기반)
|
||||
* <p>
|
||||
* 기능:
|
||||
* 1. ship_data 테이블에서 IMO 번호 전체 조회 (최초 1회)
|
||||
* 2. IMO 번호를 100개씩 분할하여 배치 단위로 처리
|
||||
* 3. fetchNextBatch() 호출 시마다 100개씩 API 호출
|
||||
* 4. Spring Batch가 100건씩 Process → Write 수행
|
||||
* <p>
|
||||
* Chunk 처리 흐름:
|
||||
* - beforeFetch() → IMO 전체 조회 (1회)
|
||||
* - fetchNextBatch() → 100개 IMO로 API 호출 (1,718회)
|
||||
* - read() → 1건씩 반환 (100번)
|
||||
* - Processor/Writer → 100건 처리
|
||||
* - 반복... (1,718번의 Chunk)
|
||||
* <p>
|
||||
* 기존 방식과의 차이:
|
||||
* - 기존: 17만건 전체 메모리 로드 → Process → Write
|
||||
* - 신규: 100건씩 로드 → Process → Write (Chunk 1,718회)
|
||||
*/
|
||||
@Slf4j
|
||||
@StepScope
|
||||
public class CurrentlyAtReader extends BaseApiReader<CurrentlyAtDto> {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
// 배치 처리 상태
|
||||
private List<String> 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개 배치를 조회하여 반환
|
||||
* <p>
|
||||
* Spring Batch가 100건씩 read() 호출 완료 후 이 메서드 재호출
|
||||
*
|
||||
* @return 다음 배치 100건 (더 이상 없으면 null)
|
||||
*/
|
||||
@Override
|
||||
protected List<CurrentlyAtDto> 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<String> 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<CurrentlyAtDto> response = callApiWithBatch(imoParam);
|
||||
|
||||
// 다음 배치로 인덱스 이동
|
||||
currentBatchIndex = endIndex;
|
||||
|
||||
|
||||
// 응답 처리
|
||||
if (response != null) {
|
||||
List<CurrentlyAtDto> 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<CurrentlyAtDto> 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<CurrentlyAtDto> data) {
|
||||
if (data == null) {
|
||||
int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize);
|
||||
log.info("[{}] 전체 {} 개 배치 처리 완료", getReaderName(), totalBatches);
|
||||
log.info("[{}] 총 {} 개의 IMO 번호에 대한 API 호출 종료",
|
||||
getReaderName(), allImoNumbers.size());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -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<CurrentlyAtEntity> entities);
|
||||
}
|
||||
@ -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<CurrentlyAtEntity, String>
|
||||
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<CurrentlyAtEntity> getRowMapper() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveAll(List<CurrentlyAtEntity> entities) {
|
||||
if (entities == null || entities.isEmpty()) return;
|
||||
|
||||
log.info("CurrentltAt 저장 시작 = {}건", entities.size());
|
||||
batchInsert(entities);
|
||||
|
||||
}
|
||||
|
||||
|
||||
/*private static class ShipMovementRowMapper implements RowMapper<ShipMovementEntity> {
|
||||
@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);
|
||||
}
|
||||
}
|
||||
}*/
|
||||
}
|
||||
@ -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<CurrentlyAtEntity> {
|
||||
|
||||
private final CurrentlyAtRepository currentlyAtRepository;
|
||||
|
||||
|
||||
public CurrentlyAtWriter(CurrentlyAtRepository currentlyAtRepository) {
|
||||
super("CurrentlyAt");
|
||||
this.currentlyAtRepository = currentlyAtRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void writeItems(List<CurrentlyAtEntity> items) throws Exception {
|
||||
|
||||
if (items.isEmpty()) { return; }
|
||||
|
||||
currentlyAtRepository.saveAll(items);
|
||||
log.info("CurrentlyAt 데이터 저장: {} 건", items.size());
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@ -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<DarkActivityDto, DarkActivityEntity> {
|
||||
|
||||
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<DarkActivityDto> createReader() { // 타입 변경
|
||||
// Reader 생성자 수정: ObjectMapper를 전달합니다.
|
||||
return new DarkActivityReader(maritimeApiWebClient, jdbcTemplate);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemProcessor<DarkActivityDto, DarkActivityEntity> createProcessor() {
|
||||
return darkActivityProcessor;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemWriter<DarkActivityEntity> 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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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<DarkActivityDto, DarkActivityEntity> {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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<DarkActivityDto> {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
// 배치 처리 상태
|
||||
private List<String> allImoNumbers;
|
||||
// DB 해시값을 저장할 맵
|
||||
private Map<String, String> 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<DarkActivityDto> 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<String> 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<DarkActivityDto> response = callApiWithBatch(imoParam);
|
||||
|
||||
// 다음 배치로 인덱스 이동
|
||||
currentBatchIndex = endIndex;
|
||||
|
||||
|
||||
// 응답 처리
|
||||
if (response != null ) {
|
||||
List<DarkActivityDto> 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<DarkActivityDto> 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<DarkActivityDto> data) {
|
||||
if (data == null) {
|
||||
int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize);
|
||||
log.info("[{}] 전체 {} 개 배치 처리 완료", getReaderName(), totalBatches);
|
||||
log.info("[{}] 총 {} 개의 IMO 번호에 대한 API 호출 종료",
|
||||
getReaderName(), allImoNumbers.size());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -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<DarkActivityEntity> entities);
|
||||
}
|
||||
@ -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<DarkActivityEntity, String>
|
||||
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<DarkActivityEntity> getRowMapper() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveAll(List<DarkActivityEntity> entities) {
|
||||
if (entities == null || entities.isEmpty()) return;
|
||||
|
||||
log.info("DarkActivity 저장 시작 = {}건", entities.size());
|
||||
batchInsert(entities);
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* ShipDetailEntity RowMapper
|
||||
*/
|
||||
private static class DarkActivityRowMapper implements RowMapper<DarkActivityEntity> {
|
||||
@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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<DarkActivityEntity> {
|
||||
|
||||
private final DarkActivityRepository darkActivityRepository;
|
||||
|
||||
|
||||
public DarkActivityWriter(DarkActivityRepository darkActivityRepository) {
|
||||
super("DarkActivity");
|
||||
this.darkActivityRepository = darkActivityRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void writeItems(List<DarkActivityEntity> items) throws Exception {
|
||||
|
||||
if (items.isEmpty()) { return; }
|
||||
|
||||
darkActivityRepository.saveAll(items);
|
||||
log.info("DarkActivity 데이터 저장: {} 건", items.size());
|
||||
}
|
||||
|
||||
}
|
||||
@ -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<DestinationDto, DestinationEntity> {
|
||||
|
||||
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<DestinationDto> createReader() { // 타입 변경
|
||||
return new DestinationReader(maritimeApiWebClient, jdbcTemplate);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemProcessor<DestinationDto, DestinationEntity> createProcessor() {
|
||||
return destinationProcessor;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemWriter<DestinationEntity> 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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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<DestinationDto, DestinationEntity> {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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<DestinationDto> {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
// 배치 처리 상태
|
||||
private List<String> 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<DestinationDto> 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<String> 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<DestinationDto> response = callApiWithBatch(imoParam);
|
||||
|
||||
// 다음 배치로 인덱스 이동
|
||||
currentBatchIndex = endIndex;
|
||||
|
||||
|
||||
// 응답 처리
|
||||
if (response != null ) {
|
||||
List<DestinationDto> 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<DestinationDto> 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<DestinationDto> data) {
|
||||
if (data == null) {
|
||||
int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize);
|
||||
log.info("[{}] 전체 {} 개 배치 처리 완료", getReaderName(), totalBatches);
|
||||
log.info("[{}] 총 {} 개의 IMO 번호에 대한 API 호출 종료",
|
||||
getReaderName(), allImoNumbers.size());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -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<DestinationEntity> entities);
|
||||
}
|
||||
@ -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<DestinationEntity, String>
|
||||
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<DestinationEntity> getRowMapper() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveAll(List<DestinationEntity> entities) {
|
||||
if (entities == null || entities.isEmpty()) return;
|
||||
|
||||
log.info("Destinations 저장 시작 = {}건", entities.size());
|
||||
batchInsert(entities);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@ -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<DestinationEntity> {
|
||||
|
||||
private final DestinationRepository destinationRepository;
|
||||
|
||||
|
||||
public DestinationWriter(DestinationRepository destinationRepository) {
|
||||
super("Destinations");
|
||||
this.destinationRepository = destinationRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void writeItems(List<DestinationEntity> items) throws Exception {
|
||||
|
||||
if (items.isEmpty()) { return; }
|
||||
|
||||
destinationRepository.saveAll(items);
|
||||
log.info("Destinations 데이터 저장: {} 건", items.size());
|
||||
}
|
||||
|
||||
}
|
||||
@ -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<StsOperationDto, StsOperationEntity> {
|
||||
|
||||
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<StsOperationDto> createReader() { // 타입 변경
|
||||
// Reader 생성자 수정: ObjectMapper를 전달합니다.
|
||||
return new StsOperationReader(maritimeApiWebClient, jdbcTemplate);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemProcessor<StsOperationDto, StsOperationEntity> createProcessor() {
|
||||
return stsOperationProcessor;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemWriter<StsOperationEntity> 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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
불러오는 중...
Reference in New Issue
Block a user