From 17164458a99674bf91a89b222c0e74c8a4751a4b Mon Sep 17 00:00:00 2001 From: Hyojin Ahn Date: Tue, 20 Jan 2026 08:17:31 -0500 Subject: [PATCH] [Scheduler] Implemented inspection, gps, odometer, safety-event --- .../SamsaraStatEngineSecondsClient.java | 58 ++++ .../erp/client/SamsaraStatOdometerClient.java | 58 ++++ .../client/SamsaraStatSafetyEventClient.java | 58 ++++ .../controller/SchedulerWorkerController.java | 149 +++++++++ .../ExtSamsaraEngineSecondsFetchCommand.java | 19 ++ .../dto/ExtSamsaraEngineSecondsRecordDto.java | 22 ++ .../dto/ExtSamsaraOdometerFetchCommand.java | 19 ++ .../erp/dto/ExtSamsaraOdometerRecordDto.java | 23 ++ .../ExtSamsaraSafetyEventFetchCommand.java | 19 ++ .../dto/ExtSamsaraSafetyEventRecordDto.java | 31 ++ .../com/goi/erp/dto/ProcessResultDto.java | 11 + .../goi/erp/dto/ScheduleWorkerRequestDto.java | 25 ++ .../erp/dto/ScheduleWorkerResponseDto.java | 64 ++++ .../entity/ExtSamsaraRawEngineSeconds.java | 67 ++++ .../goi/erp/entity/ExtSamsaraRawOdometer.java | 83 +++++ .../erp/entity/ExtSamsaraRawSafetyEvent.java | 96 ++++++ .../erp/entity/VehicleDispatchDailyStat.java | 83 +++++ .../goi/erp/entity/VehicleSafetyEvent.java | 86 ++++++ .../ExtSamsaraRawEngineSecondsRepository.java | 110 +++++++ .../ExtSamsaraRawOdometerRepository.java | 123 ++++++++ .../ExtSamsaraRawSafetyEventRepository.java | 122 ++++++++ .../VehicleDispatchDailyStatRepository.java | 18 ++ .../repository/VehicleDispatchRepository.java | 208 +++++++++---- .../VehicleExternalMapRepository.java | 13 + .../VehicleSafetyEventRepository.java | 37 +++ .../ExtSamsaraEngineSecondsIngestService.java | 57 ++++ .../ExtSamsaraOdometerIngestService.java | 58 ++++ .../ExtSamsaraSafetyEventIngestService.java | 69 +++++ .../SchedulerEngineSecondsProcessor.java | 100 ++++++ ...SchedulerEngineSecondsRawOrchestrator.java | 111 +++++++ .../service/SchedulerOdometerProcessor.java | 100 ++++++ .../SchedulerOdometerRawOrchestrator.java | 112 +++++++ .../SchedulerSafetyEventProcessor.java | 251 +++++++++++++++ .../SchedulerSafetyEventRawOrchestrator.java | 111 +++++++ .../VehicleDispatchDailyStatService.java | 289 ++++++++++++++++++ .../goi/erp/token/SystemTokenProvider.java | 61 ++++ 36 files changed, 2858 insertions(+), 63 deletions(-) create mode 100644 src/main/java/com/goi/erp/client/SamsaraStatEngineSecondsClient.java create mode 100644 src/main/java/com/goi/erp/client/SamsaraStatOdometerClient.java create mode 100644 src/main/java/com/goi/erp/client/SamsaraStatSafetyEventClient.java create mode 100644 src/main/java/com/goi/erp/controller/SchedulerWorkerController.java create mode 100644 src/main/java/com/goi/erp/dto/ExtSamsaraEngineSecondsFetchCommand.java create mode 100644 src/main/java/com/goi/erp/dto/ExtSamsaraEngineSecondsRecordDto.java create mode 100644 src/main/java/com/goi/erp/dto/ExtSamsaraOdometerFetchCommand.java create mode 100644 src/main/java/com/goi/erp/dto/ExtSamsaraOdometerRecordDto.java create mode 100644 src/main/java/com/goi/erp/dto/ExtSamsaraSafetyEventFetchCommand.java create mode 100644 src/main/java/com/goi/erp/dto/ExtSamsaraSafetyEventRecordDto.java create mode 100644 src/main/java/com/goi/erp/dto/ProcessResultDto.java create mode 100644 src/main/java/com/goi/erp/dto/ScheduleWorkerRequestDto.java create mode 100644 src/main/java/com/goi/erp/dto/ScheduleWorkerResponseDto.java create mode 100644 src/main/java/com/goi/erp/entity/ExtSamsaraRawEngineSeconds.java create mode 100644 src/main/java/com/goi/erp/entity/ExtSamsaraRawOdometer.java create mode 100644 src/main/java/com/goi/erp/entity/ExtSamsaraRawSafetyEvent.java create mode 100644 src/main/java/com/goi/erp/entity/VehicleDispatchDailyStat.java create mode 100644 src/main/java/com/goi/erp/entity/VehicleSafetyEvent.java create mode 100644 src/main/java/com/goi/erp/repository/ExtSamsaraRawEngineSecondsRepository.java create mode 100644 src/main/java/com/goi/erp/repository/ExtSamsaraRawOdometerRepository.java create mode 100644 src/main/java/com/goi/erp/repository/ExtSamsaraRawSafetyEventRepository.java create mode 100644 src/main/java/com/goi/erp/repository/VehicleDispatchDailyStatRepository.java create mode 100644 src/main/java/com/goi/erp/repository/VehicleSafetyEventRepository.java create mode 100644 src/main/java/com/goi/erp/service/ExtSamsaraEngineSecondsIngestService.java create mode 100644 src/main/java/com/goi/erp/service/ExtSamsaraOdometerIngestService.java create mode 100644 src/main/java/com/goi/erp/service/ExtSamsaraSafetyEventIngestService.java create mode 100644 src/main/java/com/goi/erp/service/SchedulerEngineSecondsProcessor.java create mode 100644 src/main/java/com/goi/erp/service/SchedulerEngineSecondsRawOrchestrator.java create mode 100644 src/main/java/com/goi/erp/service/SchedulerOdometerProcessor.java create mode 100644 src/main/java/com/goi/erp/service/SchedulerOdometerRawOrchestrator.java create mode 100644 src/main/java/com/goi/erp/service/SchedulerSafetyEventProcessor.java create mode 100644 src/main/java/com/goi/erp/service/SchedulerSafetyEventRawOrchestrator.java create mode 100644 src/main/java/com/goi/erp/service/VehicleDispatchDailyStatService.java create mode 100644 src/main/java/com/goi/erp/token/SystemTokenProvider.java diff --git a/src/main/java/com/goi/erp/client/SamsaraStatEngineSecondsClient.java b/src/main/java/com/goi/erp/client/SamsaraStatEngineSecondsClient.java new file mode 100644 index 0000000..6d0bcdf --- /dev/null +++ b/src/main/java/com/goi/erp/client/SamsaraStatEngineSecondsClient.java @@ -0,0 +1,58 @@ +package com.goi.erp.client; + +import com.goi.erp.dto.ExtSamsaraEngineSecondsFetchCommand; +import com.goi.erp.dto.ScheduleWorkerRequestDto; + +import lombok.extern.slf4j.Slf4j; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +@Slf4j +@Service +public class SamsaraStatEngineSecondsClient { + + private final WebClient webClient; + + public SamsaraStatEngineSecondsClient( + WebClient.Builder webClientBuilder, + @Value("${integration.api.base-url}") String integrationBaseUrl + ) { + this.webClient = webClientBuilder + .baseUrl(integrationBaseUrl) + .build(); + } + + /** + * integration-service 호출 + * POST /vehicle/odometer/fetch + */ + public ExtSamsaraEngineSecondsFetchCommand fetchEngineSeconds( + ScheduleWorkerRequestDto request + ) { + + try { + return webClient.post() + .uri("/vehicle/stat/engine-seconds/fetch") + .bodyValue(request) + .retrieve() + .onStatus( + status -> status.is4xxClientError() || status.is5xxServerError(), + resp -> resp.bodyToMono(String.class) + .map(body -> + new RuntimeException( + "INTEGRATION_ERROR: " + body + ) + ) + ) + .bodyToMono(ExtSamsaraEngineSecondsFetchCommand.class) + .block(); + + } catch (Exception e) { + // 이 메시지가 OPR 서비스 레벨까지 올라감 + throw e; + } + } +} + diff --git a/src/main/java/com/goi/erp/client/SamsaraStatOdometerClient.java b/src/main/java/com/goi/erp/client/SamsaraStatOdometerClient.java new file mode 100644 index 0000000..4d9eabb --- /dev/null +++ b/src/main/java/com/goi/erp/client/SamsaraStatOdometerClient.java @@ -0,0 +1,58 @@ +package com.goi.erp.client; + +import com.goi.erp.dto.ExtSamsaraOdometerFetchCommand; +import com.goi.erp.dto.ScheduleWorkerRequestDto; + +import lombok.extern.slf4j.Slf4j; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +@Slf4j +@Service +public class SamsaraStatOdometerClient { + + private final WebClient webClient; + + public SamsaraStatOdometerClient( + WebClient.Builder webClientBuilder, + @Value("${integration.api.base-url}") String integrationBaseUrl + ) { + this.webClient = webClientBuilder + .baseUrl(integrationBaseUrl) + .build(); + } + + /** + * integration-service 호출 + * POST /vehicle/odometer/fetch + */ + public ExtSamsaraOdometerFetchCommand fetchOdometer( + ScheduleWorkerRequestDto request + ) { + + try { + return webClient.post() + .uri("/vehicle/stat/odometer/fetch") + .bodyValue(request) + .retrieve() + .onStatus( + status -> status.is4xxClientError() || status.is5xxServerError(), + resp -> resp.bodyToMono(String.class) + .map(body -> + new RuntimeException( + "INTEGRATION_ERROR: " + body + ) + ) + ) + .bodyToMono(ExtSamsaraOdometerFetchCommand.class) + .block(); + + } catch (Exception e) { + // 이 메시지가 OPR 서비스 레벨까지 올라감 + throw e; + } + } +} + diff --git a/src/main/java/com/goi/erp/client/SamsaraStatSafetyEventClient.java b/src/main/java/com/goi/erp/client/SamsaraStatSafetyEventClient.java new file mode 100644 index 0000000..c44c646 --- /dev/null +++ b/src/main/java/com/goi/erp/client/SamsaraStatSafetyEventClient.java @@ -0,0 +1,58 @@ +package com.goi.erp.client; + +import com.goi.erp.dto.ExtSamsaraSafetyEventFetchCommand; +import com.goi.erp.dto.ScheduleWorkerRequestDto; + +import lombok.extern.slf4j.Slf4j; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +@Slf4j +@Service +public class SamsaraStatSafetyEventClient { + + private final WebClient webClient; + + public SamsaraStatSafetyEventClient( + WebClient.Builder webClientBuilder, + @Value("${integration.api.base-url}") String integrationBaseUrl + ) { + this.webClient = webClientBuilder + .baseUrl(integrationBaseUrl) + .build(); + } + + /** + * integration-service 호출 + * POST /vehicle/safety-event/fetch + */ + public ExtSamsaraSafetyEventFetchCommand fetchSafetyEvent( + ScheduleWorkerRequestDto request + ) { + + try { + return webClient.post() + .uri("/vehicle/stat/safety-event/fetch") + .bodyValue(request) + .retrieve() + .onStatus( + status -> status.is4xxClientError() || status.is5xxServerError(), + resp -> resp.bodyToMono(String.class) + .map(body -> + new RuntimeException( + "INTEGRATION_ERROR: " + body + ) + ) + ) + .bodyToMono(ExtSamsaraSafetyEventFetchCommand.class) + .block(); + + } catch (Exception e) { + // 이 메시지가 OPR 서비스 레벨까지 올라감 + throw e; + } + } +} + diff --git a/src/main/java/com/goi/erp/controller/SchedulerWorkerController.java b/src/main/java/com/goi/erp/controller/SchedulerWorkerController.java new file mode 100644 index 0000000..809b999 --- /dev/null +++ b/src/main/java/com/goi/erp/controller/SchedulerWorkerController.java @@ -0,0 +1,149 @@ +package com.goi.erp.controller; + +import com.goi.erp.dto.ProcessResultDto; +import com.goi.erp.dto.ScheduleWorkerRequestDto; +import com.goi.erp.dto.ScheduleWorkerResponseDto; +import com.goi.erp.service.ExtSamsaraInspectionProcessor; +import com.goi.erp.service.SchedulerEngineSecondsProcessor; +import com.goi.erp.service.SchedulerEngineSecondsRawOrchestrator; +import com.goi.erp.service.SchedulerOdometerProcessor; +import com.goi.erp.service.SchedulerOdometerRawOrchestrator; +import com.goi.erp.service.SchedulerSafetyEventProcessor; +import com.goi.erp.service.SchedulerSafetyEventRawOrchestrator; +import com.goi.erp.service.VehicleDispatchAutoCloseService; + +import lombok.RequiredArgsConstructor; + +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/scheduler/worker") +@RequiredArgsConstructor +public class SchedulerWorkerController { + + private final ExtSamsaraInspectionProcessor inspectionProcessor; + private final VehicleDispatchAutoCloseService autoCloseService; + private final SchedulerOdometerRawOrchestrator odometerOrchestrator; + private final SchedulerOdometerProcessor odometerProcessor; + private final SchedulerEngineSecondsRawOrchestrator enginesecondsOrchestrator; + private final SchedulerEngineSecondsProcessor engineSecondsProcessor; + private final SchedulerSafetyEventRawOrchestrator safetyEventOrchestrator; + private final SchedulerSafetyEventProcessor safetyEventProcessor; + + + /** + * SAMSARA_INSPECTION_PROCESS + */ + @PostMapping("/samsara/inspection/process") + public ScheduleWorkerResponseDto processSamsaraInspection( + @RequestBody ScheduleWorkerRequestDto request + ) { + ProcessResultDto result = inspectionProcessor.processUnprocessed(); + + return ScheduleWorkerResponseDto.builder() + .success(true) + .processedCount(result.getProcessed() + result.getFailed()) + .successCount(result.getProcessed()) + .failCount(result.getFailed()) + .build(); + } + + /** + * 차량 배차 자동 종료 테스트 (오늘 날짜 기준) + */ + @PostMapping("/samsara/auto-close/process") + public ScheduleWorkerResponseDto processAutoClose( + @RequestBody ScheduleWorkerRequestDto request + ) { + ProcessResultDto result = autoCloseService.processAutoClose(request); + + return ScheduleWorkerResponseDto.builder() + .success(true) + .processedCount(result.getProcessed() + result.getFailed()) + .successCount(result.getProcessed()) + .failCount(result.getFailed()) + .build(); + } + + /** + * 차량 운행정보 odometer raw 입력 + */ + @PostMapping("/samsara/odometer/ingest") + public ScheduleWorkerResponseDto ingestOdometer( + @RequestBody ScheduleWorkerRequestDto request + ) { + return odometerOrchestrator.run(request); + } + + /** + * 차량 운행정보 odometer process + */ + @PostMapping("/samsara/odometer/process") + public ScheduleWorkerResponseDto processOdometer( + @RequestBody ScheduleWorkerRequestDto request + ) { + return odometerProcessor.run(request); + } + + /** + * 차량 운행정보 engine seconds raw 입력 + */ + @PostMapping("/samsara/engine-seconds/ingest") + public ScheduleWorkerResponseDto ingestEngineSeconds( + @RequestBody ScheduleWorkerRequestDto request + ) { + return enginesecondsOrchestrator.run(request); + } + + /** + * 차량 운행정보 odometer process + */ + @PostMapping("/samsara/engine-seconds/process") + public ScheduleWorkerResponseDto processEngineSeconds( + @RequestBody ScheduleWorkerRequestDto request + ) { + return engineSecondsProcessor.run(request); + } + + /** + * 차량 운행정보 safety event raw 입력 + */ + @PostMapping("/samsara/safety-event/ingest") + public ScheduleWorkerResponseDto ingestSafetyEvent( + @RequestBody ScheduleWorkerRequestDto request + ) { + return safetyEventOrchestrator.run(request); + } + + /** + * 차량 운행정보 safety event process + */ + @PostMapping("/samsara/safety-event/process") + public ScheduleWorkerResponseDto processSafetyEvent( + @RequestBody ScheduleWorkerRequestDto request + ) { + return safetyEventProcessor.run(request); + } + + /** + * 차량 운행정보 입력 process + */ + @PostMapping("/samsara/stat/process") + public ScheduleWorkerResponseDto processStat( + @RequestBody ScheduleWorkerRequestDto request + ) { + ProcessResultDto result = autoCloseService.processAutoClose(request); + + return ScheduleWorkerResponseDto.builder() + .success(true) + .processedCount(result.getProcessed() + result.getFailed()) + .successCount(result.getProcessed()) + .failCount(result.getFailed()) + .build(); + } + + +} diff --git a/src/main/java/com/goi/erp/dto/ExtSamsaraEngineSecondsFetchCommand.java b/src/main/java/com/goi/erp/dto/ExtSamsaraEngineSecondsFetchCommand.java new file mode 100644 index 0000000..6ec6de6 --- /dev/null +++ b/src/main/java/com/goi/erp/dto/ExtSamsaraEngineSecondsFetchCommand.java @@ -0,0 +1,19 @@ +package com.goi.erp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ExtSamsaraEngineSecondsFetchCommand { + private String source; // SAMSARA + private LocalDateTime fetchedAt; + private List records; +} diff --git a/src/main/java/com/goi/erp/dto/ExtSamsaraEngineSecondsRecordDto.java b/src/main/java/com/goi/erp/dto/ExtSamsaraEngineSecondsRecordDto.java new file mode 100644 index 0000000..b98a664 --- /dev/null +++ b/src/main/java/com/goi/erp/dto/ExtSamsaraEngineSecondsRecordDto.java @@ -0,0 +1,22 @@ +package com.goi.erp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +import com.fasterxml.jackson.databind.JsonNode; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ExtSamsaraEngineSecondsRecordDto { + private String vehicleExternalId; + private Instant sampleTime; + private Long engineSeconds; + private String payloadHash; + private JsonNode payloadJson; +} diff --git a/src/main/java/com/goi/erp/dto/ExtSamsaraOdometerFetchCommand.java b/src/main/java/com/goi/erp/dto/ExtSamsaraOdometerFetchCommand.java new file mode 100644 index 0000000..799f039 --- /dev/null +++ b/src/main/java/com/goi/erp/dto/ExtSamsaraOdometerFetchCommand.java @@ -0,0 +1,19 @@ +package com.goi.erp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ExtSamsaraOdometerFetchCommand { + private String source; + private LocalDateTime fetchedAt; + private List records; +} diff --git a/src/main/java/com/goi/erp/dto/ExtSamsaraOdometerRecordDto.java b/src/main/java/com/goi/erp/dto/ExtSamsaraOdometerRecordDto.java new file mode 100644 index 0000000..219fb28 --- /dev/null +++ b/src/main/java/com/goi/erp/dto/ExtSamsaraOdometerRecordDto.java @@ -0,0 +1,23 @@ +package com.goi.erp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +import com.fasterxml.jackson.databind.JsonNode; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ExtSamsaraOdometerRecordDto { + private String vehicleExternalId; + private Instant sampleTime; + private String odometerType; + private Long odometerMeters; + private String payloadHash; + private JsonNode payloadJson; +} diff --git a/src/main/java/com/goi/erp/dto/ExtSamsaraSafetyEventFetchCommand.java b/src/main/java/com/goi/erp/dto/ExtSamsaraSafetyEventFetchCommand.java new file mode 100644 index 0000000..c616cd4 --- /dev/null +++ b/src/main/java/com/goi/erp/dto/ExtSamsaraSafetyEventFetchCommand.java @@ -0,0 +1,19 @@ +package com.goi.erp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ExtSamsaraSafetyEventFetchCommand { + private String source; // SAMSARA + private LocalDateTime fetchedAt; + private List records; +} diff --git a/src/main/java/com/goi/erp/dto/ExtSamsaraSafetyEventRecordDto.java b/src/main/java/com/goi/erp/dto/ExtSamsaraSafetyEventRecordDto.java new file mode 100644 index 0000000..97aa62c --- /dev/null +++ b/src/main/java/com/goi/erp/dto/ExtSamsaraSafetyEventRecordDto.java @@ -0,0 +1,31 @@ +package com.goi.erp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.time.LocalDate; + +import com.fasterxml.jackson.databind.JsonNode; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ExtSamsaraSafetyEventRecordDto { + private String eventId; + private String vehicleId; + private String driverId; + private Instant eventTime; + private LocalDate eventDate; + private String coachingState; + private Double maxAccelerationG; + private Double latitude; + private Double longitude; + private String videoForwardUrl; + private String videoInwardUrl; + private JsonNode behaviorLabels; + private JsonNode rawPayload; +} diff --git a/src/main/java/com/goi/erp/dto/ProcessResultDto.java b/src/main/java/com/goi/erp/dto/ProcessResultDto.java new file mode 100644 index 0000000..1d4bc45 --- /dev/null +++ b/src/main/java/com/goi/erp/dto/ProcessResultDto.java @@ -0,0 +1,11 @@ +package com.goi.erp.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ProcessResultDto { + private int processed; + private int failed; +} diff --git a/src/main/java/com/goi/erp/dto/ScheduleWorkerRequestDto.java b/src/main/java/com/goi/erp/dto/ScheduleWorkerRequestDto.java new file mode 100644 index 0000000..8b7b34c --- /dev/null +++ b/src/main/java/com/goi/erp/dto/ScheduleWorkerRequestDto.java @@ -0,0 +1,25 @@ +package com.goi.erp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ScheduleWorkerRequestDto { + + private String jobCode; + private LocalDateTime from; + private LocalDateTime to; + private Integer maxRecords; + private Map> config; + + private List vehicleExternalIds; +} diff --git a/src/main/java/com/goi/erp/dto/ScheduleWorkerResponseDto.java b/src/main/java/com/goi/erp/dto/ScheduleWorkerResponseDto.java new file mode 100644 index 0000000..4e29ba7 --- /dev/null +++ b/src/main/java/com/goi/erp/dto/ScheduleWorkerResponseDto.java @@ -0,0 +1,64 @@ +package com.goi.erp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ScheduleWorkerResponseDto { + + private boolean success; + + private int processedCount; + private int successCount; + private int failCount; + + private String errorCode; + private String errorMessage; + + /** + * 성공했지만 처리할 대상이 없음 + */ + public static ScheduleWorkerResponseDto successEmpty() { + return ScheduleWorkerResponseDto.builder() + .success(true) + .processedCount(0) + .successCount(0) + .failCount(0) + .build(); + } + + /** + * 성공 + 수치 + */ + public static ScheduleWorkerResponseDto success( + int processed, + int successCount, + int failCount + ) { + return ScheduleWorkerResponseDto.builder() + .success(true) + .processedCount(processed) + .successCount(successCount) + .failCount(failCount) + .build(); + } + + /** + * 실패 응답 + */ + public static ScheduleWorkerResponseDto failure( + String errorCode, + String errorMessage + ) { + return ScheduleWorkerResponseDto.builder() + .success(false) + .errorCode(errorCode) + .errorMessage(errorMessage) + .build(); + } +} diff --git a/src/main/java/com/goi/erp/entity/ExtSamsaraRawEngineSeconds.java b/src/main/java/com/goi/erp/entity/ExtSamsaraRawEngineSeconds.java new file mode 100644 index 0000000..8b7707c --- /dev/null +++ b/src/main/java/com/goi/erp/entity/ExtSamsaraRawEngineSeconds.java @@ -0,0 +1,67 @@ +package com.goi.erp.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; + +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import com.fasterxml.jackson.databind.JsonNode; + +@Entity +@Table(name = "ext_samsara_raw_engine_seconds") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ExtSamsaraRawEngineSeconds { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "esre_id") + private Long esreId; + + @Column(name = "esre_source", nullable = false, length = 20) + private String esreSource; + + @Column(name = "esre_vehicle_ext_id", nullable = false, length = 50) + private String esreVehicleExtId; + + @Column(name = "esre_sample_time", nullable = false) + private Instant esreSampleTime; + + @Column(name = "esre_engine_seconds", nullable = false) + private Long esreEngineSeconds; + + @Column(name = "esre_hash", nullable = false, length = 64) + private String esreHash; + + @Column(name = "esre_fetched_at", nullable = false) + private LocalDateTime esreFetchedAt; + + @Column(name = "esre_fetched_date", insertable = false, updatable = false) + private LocalDate esreFetchedDate; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "esre_payload", columnDefinition = "jsonb", nullable = false) + private JsonNode esrePayload; + + @Column(name = "esre_processed") + private Boolean esreProcessed; + + @Column(name = "esre_processed_at") + private LocalDateTime esreProcessedAt; +} diff --git a/src/main/java/com/goi/erp/entity/ExtSamsaraRawOdometer.java b/src/main/java/com/goi/erp/entity/ExtSamsaraRawOdometer.java new file mode 100644 index 0000000..5d1c357 --- /dev/null +++ b/src/main/java/com/goi/erp/entity/ExtSamsaraRawOdometer.java @@ -0,0 +1,83 @@ +package com.goi.erp.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import com.fasterxml.jackson.databind.JsonNode; + +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table( + name = "ext_samsara_raw_odometer", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_ext_samsara_raw_odometer_hash", + columnNames = {"esro_hash"} + ) + } +) +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ExtSamsaraRawOdometer { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "esro_id") + private Long esroId; + + @Column(name = "esro_source", nullable = false, length = 20) + private String esroSource; + + @Column(name = "esro_vehicle_ext_id", nullable = false, length = 50) + private String esroVehicleExtId; + + @Column(name = "esro_sample_time", nullable = false) + private Instant esroSampleTime; + + @Column(name = "esro_odometer_type", nullable = false, length = 10) + private String esroOdometerType; + + @Column(name = "esro_odometer_meters") + private Long esroOdometerMeters; + + @Column(name = "esro_hash", nullable = false, length = 64) + private String esroHash; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "esro_payload", columnDefinition = "jsonb", nullable = false) + private JsonNode esroPayload; + + @Column(name = "esro_fetched_at", nullable = false) + private LocalDateTime esroFetchedAt; + + @Column( + name = "esro_fetched_date", + insertable = false, + updatable = false + ) + private LocalDate esroFetchedDate; + + @Column(name = "esro_processed") + private Boolean esroProcessed; + + @Column(name = "esro_processed_at") + private LocalDateTime esroProcessedAt; +} diff --git a/src/main/java/com/goi/erp/entity/ExtSamsaraRawSafetyEvent.java b/src/main/java/com/goi/erp/entity/ExtSamsaraRawSafetyEvent.java new file mode 100644 index 0000000..3240741 --- /dev/null +++ b/src/main/java/com/goi/erp/entity/ExtSamsaraRawSafetyEvent.java @@ -0,0 +1,96 @@ +package com.goi.erp.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; + +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import com.fasterxml.jackson.databind.JsonNode; + +@Entity +@Table(name = "ext_samsara_raw_safety_event") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ExtSamsaraRawSafetyEvent { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "esrs_id") + private Long esrsId; + + @Column(name = "esrs_event_id", nullable = false, length = 100) + private String esrsEventId; + + @Column(name = "esrs_vehicle_id", nullable = false, length = 100) + private String esrsVehicleId; + + @Column(name = "esrs_driver_id", length = 100) + private String esrsDriverId; + + @Column(name = "esrs_event_time", nullable = false) + private Instant esrsEventTime; + + @Column(name = "esrs_event_date", nullable = false) + private LocalDate esrsEventDate; + + @Column(name = "esrs_coaching_state", length = 30) + private String esrsCoachingState; + + @Column(name = "esrs_max_accel_g", precision = 9, scale = 6) + private BigDecimal esrsMaxAccelG; + + @Column(name = "esrs_latitude", precision = 9, scale = 6) + private BigDecimal esrsLatitude; + + @Column(name = "esrs_longitude", precision = 9, scale = 6) + private BigDecimal esrsLongitude; + + @Column(name = "esrs_video_forward_url") + private String esrsVideoForwardUrl; + + @Column(name = "esrs_video_inward_url") + private String esrsVideoInwardUrl; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "esrs_behavior_labels", columnDefinition = "jsonb") + private JsonNode esrsBehaviorLabels; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "esrs_raw_payload", columnDefinition = "jsonb", nullable = false) + private JsonNode esrsRawPayload; + + @Column(name = "esrs_processed") + private Boolean esrsProcessed; + + @Column(name = "esrs_processed_at") + private LocalDateTime esrsProcessedAt; + + @Column(name = "esrs_vehicle_safety_event_id") + private Long esrsVehicleSafetyEventId; + + @Column(name = "esrs_collected_at") + private LocalDateTime esrsCollectedAt; + + @Column(name = "esrs_created_at") + private LocalDateTime esrsCreatedAt; + + @Column(name = "esrs_updated_at") + private LocalDateTime esrsUpdatedAt; +} diff --git a/src/main/java/com/goi/erp/entity/VehicleDispatchDailyStat.java b/src/main/java/com/goi/erp/entity/VehicleDispatchDailyStat.java new file mode 100644 index 0000000..93de42f --- /dev/null +++ b/src/main/java/com/goi/erp/entity/VehicleDispatchDailyStat.java @@ -0,0 +1,83 @@ +package com.goi.erp.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table( + name = "vehicle_dispatch_daily_stat", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_vehicle_dispatch_daily_stat_dispatch_date", + columnNames = {"vdds_dispatch_id", "vdds_date"} + ) + } +) +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EntityListeners(AuditingEntityListener.class) +public class VehicleDispatchDailyStat { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "vdds_id") + private Long vddsId; + + @Column(name = "vdds_dispatch_id", nullable = false) + private Long vddsDispatchId; + + @Column(name = "vdds_date", nullable = false) + private LocalDate vddsDate; + + @Column(name = "vdds_odometer_total", precision = 12, scale = 2) + private BigDecimal vddsOdometerTotal; + + @Column(name = "vdds_odometer_increment", precision = 12, scale = 2) + private BigDecimal vddsOdometerIncrement; + + @Column(name = "vdds_odometer_source", length = 20) + private String vddsOdometerSource; + + @Column(name = "vdds_engine_seconds_total") + private Integer vddsEngineSecondsTotal; + + @Column(name = "vdds_engine_seconds_increment") + private Integer vddsEngineSecondsIncrement; + + @Column(name = "vdds_safety_event_count") + private Integer vddsSafetyEventCount; + + @Column(name = "vdds_collected_at") + private LocalDateTime vddsCollectedAt; + + @Column(name = "vdds_created_at") + private LocalDateTime vddsCreatedAt; + + @Column(name = "vdds_created_by", length = 50) + private String vddsCreatedBy; + + @Column(name = "vdds_updated_at") + private LocalDateTime vddsUpdatedAt; + + @Column(name = "vdds_updated_by", length = 50) + private String vddsUpdatedBy; +} diff --git a/src/main/java/com/goi/erp/entity/VehicleSafetyEvent.java b/src/main/java/com/goi/erp/entity/VehicleSafetyEvent.java new file mode 100644 index 0000000..8a6129e --- /dev/null +++ b/src/main/java/com/goi/erp/entity/VehicleSafetyEvent.java @@ -0,0 +1,86 @@ +package com.goi.erp.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; + +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import com.fasterxml.jackson.databind.JsonNode; + +@Entity +@Table(name = "vehicle_safety_event") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class VehicleSafetyEvent { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "vse_id") + private Long vseId; + + @Column(name = "vse_source", nullable = false, length = 20) + private String vseSource; // SAMSARA / MANUAL / ETC + + @Column(name = "vse_behavior_summary", length = 200) + private String vseBehaviorSummary; + + @Column(name = "vse_dispatch_id", nullable = false) + private Long vseDispatchId; + + @Column(name = "vse_vehicle_id", nullable = false) + private Long vseVehicleId; + + @Column(name = "vse_driver_id") + private Long vseDriverId; + + @Column(name = "vse_event_time", nullable = false) + private Instant vseEventTime; + + @Column(name = "vse_event_date", nullable = false) + private LocalDate vseEventDate; + + @Column(name = "vse_coaching_state", length = 30) + private String vseCoachingState; + + @Column(name = "vse_max_accel_g", precision = 9, scale = 6) + private BigDecimal vseMaxAccelG; + + @Column(name = "vse_latitude", precision = 9, scale = 6) + private BigDecimal vseLatitude; + + @Column(name = "vse_longitude", precision = 9, scale = 6) + private BigDecimal vseLongitude; + + @Column(name = "vse_video_forward_url") + private String vseVideoForwardUrl; + + @Column(name = "vse_video_inward_url") + private String vseVideoInwardUrl; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "vse_behavior_labels", columnDefinition = "jsonb") + private JsonNode vseBehaviorLabels; + + @Column(name = "vse_created_at") + private LocalDateTime vseCreatedAt; + + @Column(name = "vse_created_by", length = 50) + private String vseCreatedBy; +} diff --git a/src/main/java/com/goi/erp/repository/ExtSamsaraRawEngineSecondsRepository.java b/src/main/java/com/goi/erp/repository/ExtSamsaraRawEngineSecondsRepository.java new file mode 100644 index 0000000..109443c --- /dev/null +++ b/src/main/java/com/goi/erp/repository/ExtSamsaraRawEngineSecondsRepository.java @@ -0,0 +1,110 @@ +package com.goi.erp.repository; + +import com.goi.erp.entity.ExtSamsaraRawEngineSeconds; + +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 org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +public interface ExtSamsaraRawEngineSecondsRepository + extends JpaRepository { + + /** + * raw engine seconds insert (중복 hash 무시) + */ + @Transactional + @Modifying + @Query( + value = """ + INSERT INTO ext_samsara_raw_engine_seconds ( + esre_source, + esre_vehicle_ext_id, + esre_sample_time, + esre_engine_seconds, + esre_hash, + esre_fetched_at, + esre_payload, + esre_processed + ) VALUES ( + :source, + :vehicleExtId, + :sampleTime, + :engineSeconds, + :hash, + :fetchedAt, + CAST(:payload AS jsonb), + FALSE + ) + ON CONFLICT (esre_hash) DO NOTHING + """, + nativeQuery = true + ) + int insertIgnore( + @Param("source") String source, + @Param("vehicleExtId") String vehicleExtId, + @Param("sampleTime") Instant sampleTime, + @Param("engineSeconds") Long engineSeconds, + @Param("hash") String hash, + @Param("fetchedAt") LocalDateTime fetchedAt, + @Param("payload") String payload + ); + + /** + * 특정 vehicle + 날짜 기준, 아직 처리되지 않은 raw engine seconds + * daily stat process 용 + */ + List + findByEsreVehicleExtIdAndEsreFetchedDateAndEsreProcessedFalse( + String esreVehicleExtId, + LocalDate esreFetchedDate + ); + + /** + * 날짜 기준, 아직 처리되지 않은 vehicleExternalIds + */ + @Query(""" + select distinct r.esreVehicleExtId + from ExtSamsaraRawEngineSeconds r + where r.esreFetchedDate = :date + and r.esreProcessed = false + """) + List findDistinctVehicleExtIdByDateAndUnprocessed( + @Param("date") LocalDate date + ); + + /** + * 아직 처리되지 않은 raw engine seconds (날짜 기준) + * stat process scheduler 용 + */ + List + findByEsreProcessedFalseAndEsreFetchedDate( + LocalDate esreFetchedDate + ); + + /** + * 특정 차량의 engine seconds snapshot 이력 (디버깅/분석용) + */ + List + findByEsreVehicleExtIdOrderByEsreSampleTimeAsc( + String esreVehicleExtId + ); + + /** + * 특정 차량의 가장 최근 engine seconds snapshot + * (리셋 감지 / 기준값 확인용) + */ + Optional + findTopByEsreVehicleExtIdOrderByEsreSampleTimeDesc( + String esreVehicleExtId + ); +} diff --git a/src/main/java/com/goi/erp/repository/ExtSamsaraRawOdometerRepository.java b/src/main/java/com/goi/erp/repository/ExtSamsaraRawOdometerRepository.java new file mode 100644 index 0000000..006cd52 --- /dev/null +++ b/src/main/java/com/goi/erp/repository/ExtSamsaraRawOdometerRepository.java @@ -0,0 +1,123 @@ +package com.goi.erp.repository; + +import com.goi.erp.entity.ExtSamsaraRawOdometer; + +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 org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +public interface ExtSamsaraRawOdometerRepository + extends JpaRepository { + + /* ===================================================== + * INGEST (idempotent insert) + * ===================================================== */ + @Transactional + @Modifying + @Query( + value = """ + INSERT INTO ext_samsara_raw_odometer ( + esro_source, + esro_vehicle_ext_id, + esro_sample_time, + esro_odometer_type, + esro_odometer_meters, + esro_hash, + esro_fetched_at, + esro_payload, + esro_processed + ) VALUES ( + :source, + :vehicleExtId, + :sampleTime, + :odometerType, + :odometerMeters, + :hash, + :fetchedAt, + CAST(:payload AS jsonb), + FALSE + ) + ON CONFLICT (esro_hash) DO NOTHING + """, + nativeQuery = true + ) + int insertIgnore( + @Param("source") String source, + @Param("vehicleExtId") String vehicleExtId, + @Param("sampleTime") Instant sampleTime, + @Param("odometerType") String odometerType, + @Param("odometerMeters") Long odometerMeters, + @Param("hash") String hash, + @Param("fetchedAt") LocalDateTime fetchedAt, + @Param("payload") String payload + ); + + /* ===================================================== + * PROCESS (stat scheduler) + * ===================================================== */ + + /** + * 날짜 기준, 아직 처리되지 않은 raw가 존재하는 vehicleExternalId 목록 + */ + @Query(""" + SELECT DISTINCT r.esroVehicleExtId + FROM ExtSamsaraRawOdometer r + WHERE r.esroProcessed = false + AND r.esroFetchedDate = :date + """) + List findDistinctVehicleExtIdByDateAndUnprocessed( + @Param("date") LocalDate date + ); + + /** + * 특정 날짜 + vehicleExternalIds 기준 미처리 raw 조회 + * (dispatch 단위 grouping은 service 레벨에서 수행) + */ + @Query(""" + SELECT r + FROM ExtSamsaraRawOdometer r + WHERE r.esroProcessed = false + AND r.esroFetchedDate = :date + AND r.esroVehicleExtId IN :vehicleExtIds + """) + List findUnprocessedByVehicleExtIdsAndDate( + @Param("vehicleExtIds") List vehicleExtIds, + @Param("date") LocalDate date + ); + + /* ===================================================== + * DEBUG / ANALYSIS + * ===================================================== */ + + List + findByEsroVehicleExtIdOrderByEsroSampleTimeAsc( + String esroVehicleExtId + ); + + Optional + findTopByEsroVehicleExtIdOrderByEsroSampleTimeDesc( + String esroVehicleExtId + ); + + @Query(""" + SELECT r + FROM ExtSamsaraRawOdometer r + WHERE r.esroVehicleExtId = :vehicleExtId + AND r.esroProcessed = false + AND r.esroFetchedDate = :date + """) + List findByVehicleExtIdAndDateAndUnprocessed( + @Param("vehicleExtId") String vehicleExtId, + @Param("date") LocalDate date + ); +} diff --git a/src/main/java/com/goi/erp/repository/ExtSamsaraRawSafetyEventRepository.java b/src/main/java/com/goi/erp/repository/ExtSamsaraRawSafetyEventRepository.java new file mode 100644 index 0000000..f4118bd --- /dev/null +++ b/src/main/java/com/goi/erp/repository/ExtSamsaraRawSafetyEventRepository.java @@ -0,0 +1,122 @@ +package com.goi.erp.repository; + +import com.goi.erp.entity.ExtSamsaraRawSafetyEvent; + +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 org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +public interface ExtSamsaraRawSafetyEventRepository + extends JpaRepository { + + /** + * raw safety event insert (중복 event_id 무시) + */ + @Transactional + @Modifying + @Query( + value = """ + INSERT INTO ext_samsara_raw_safety_event ( + esrs_event_id, + esrs_vehicle_id, + esrs_driver_id, + esrs_event_time, + esrs_event_date, + esrs_coaching_state, + esrs_max_accel_g, + esrs_latitude, + esrs_longitude, + esrs_video_forward_url, + esrs_video_inward_url, + esrs_behavior_labels, + esrs_raw_payload, + esrs_processed, + esrs_collected_at + ) VALUES ( + :eventId, + :vehicleId, + :driverId, + :eventTime, + :eventDate, + :coachingState, + :maxAccelG, + :latitude, + :longitude, + :videoForwardUrl, + :videoInwardUrl, + CAST(:behaviorLabels AS jsonb), + CAST(:rawPayload AS jsonb), + FALSE, + :collectedAt + ) + ON CONFLICT (esrs_event_id) DO NOTHING + """, + nativeQuery = true + ) + int insertIgnore( + @Param("eventId") String eventId, + @Param("vehicleId") String vehicleId, + @Param("driverId") String driverId, + @Param("eventTime") Instant eventTime, + @Param("eventDate") LocalDate eventDate, + @Param("coachingState") String coachingState, + @Param("maxAccelG") Double maxAccelG, + @Param("latitude") Double latitude, + @Param("longitude") Double longitude, + @Param("videoForwardUrl") String videoForwardUrl, + @Param("videoInwardUrl") String videoInwardUrl, + @Param("behaviorLabels") String behaviorLabels, + @Param("rawPayload") String rawPayload, + @Param("collectedAt") LocalDateTime collectedAt + ); + + /** + * 특정 vehicle + 날짜 기준, 아직 처리되지 않은 safety events + * daily stat / process 용 + */ + List + findByEsrsVehicleIdAndEsrsEventDateAndEsrsProcessedFalse( + String esrsVehicleId, + LocalDate esrsEventDate + ); + + /** + * 날짜 기준, 아직 처리되지 않은 vehicleIds + */ + @Query(""" + select distinct r.esrsVehicleId + from ExtSamsaraRawSafetyEvent r + where r.esrsEventDate = :date + and r.esrsProcessed = false + """) + List findDistinctVehicleIdByDateAndUnprocessed( + @Param("date") LocalDate date + ); + + /** + * 날짜 기준, 아직 처리되지 않은 safety events + * process scheduler 용 + */ + List + findByEsrsProcessedFalseAndEsrsEventDate( + LocalDate esrsEventDate + ); + + /** + * 특정 차량의 가장 최근 safety event + */ + Optional + findTopByEsrsVehicleIdOrderByEsrsEventTimeDesc( + String esrsVehicleId + ); +} diff --git a/src/main/java/com/goi/erp/repository/VehicleDispatchDailyStatRepository.java b/src/main/java/com/goi/erp/repository/VehicleDispatchDailyStatRepository.java new file mode 100644 index 0000000..97cd8a4 --- /dev/null +++ b/src/main/java/com/goi/erp/repository/VehicleDispatchDailyStatRepository.java @@ -0,0 +1,18 @@ +package com.goi.erp.repository; + +import com.goi.erp.entity.VehicleDispatchDailyStat; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.Optional; + +public interface VehicleDispatchDailyStatRepository + extends JpaRepository { + + Optional + findByVddsDispatchIdAndVddsDate( + Long vddsDispatchId, + LocalDate vddsDate + ); +} diff --git a/src/main/java/com/goi/erp/repository/VehicleDispatchRepository.java b/src/main/java/com/goi/erp/repository/VehicleDispatchRepository.java index e3fdc62..7a89c5d 100644 --- a/src/main/java/com/goi/erp/repository/VehicleDispatchRepository.java +++ b/src/main/java/com/goi/erp/repository/VehicleDispatchRepository.java @@ -14,77 +14,159 @@ import java.util.List; import java.util.Optional; import java.util.UUID; -public interface VehicleDispatchRepository extends JpaRepository { - - List findByVedVehIdAndVedDispatchDate(Long vedVehId, LocalDate vedDispatchDate); - List findByVedDriverIdAndVedDispatchDate(Long vedDriverId, LocalDate vedDispatchDate); +public interface VehicleDispatchRepository + extends JpaRepository { + + /* ===================================================== + * BASIC + * ===================================================== */ + List findByVedVehIdAndVedDispatchDate( + Long vedVehId, + LocalDate vedDispatchDate + ); + + List findByVedDriverIdAndVedDispatchDate( + Long vedDriverId, + LocalDate vedDispatchDate + ); + List findByVedDispatchDate(LocalDate vedDispatchDate); + Page findAll(Pageable pageable); - Optional findByVedUuid(UUID vedUuid); + Optional findByVedUuid(UUID vedUuid); - // - @Query(""" - SELECT d - FROM VehicleDispatch d - WHERE d.vedVehId = :vehId - AND d.vedDispatchDate = :dispatchDate - AND d.vedStatus <> 'C' - """) - Optional findOpenDispatchByVehId(Long vehId, LocalDate dispatchDate); - - // - @Query(""" - SELECT d - FROM VehicleDispatch d - WHERE d.vedDispatchDate = :dispatchDate - AND d.vedStatus <> 'C' - """) - List findAllNotClosed(LocalDate dispatchDate); + /* ===================================================== + * STATUS BASED + * ===================================================== */ - // - @Query(""" - SELECT d - FROM VehicleDispatch d - WHERE d.vedStatus = 'P' - AND d.vedPausedAt <= :threshold - """) - List findPausedBefore(LocalDateTime threshold); + @Query(""" + SELECT d + FROM VehicleDispatch d + WHERE d.vedVehId = :vehId + AND d.vedDispatchDate = :dispatchDate + AND d.vedStatus <> 'C' + """) + Optional findOpenDispatchByVehId( + @Param("vehId") Long vehId, + @Param("dispatchDate") LocalDate dispatchDate + ); - // - @Query(""" - SELECT COALESCE(MAX(d.vedShift), -1) - FROM VehicleDispatch d - WHERE d.vedVehId = :vehId - AND d.vedDispatchDate = :date - """) - Integer findMaxShift(Long vehId, LocalDate date); + @Query(""" + SELECT d + FROM VehicleDispatch d + WHERE d.vedDispatchDate = :dispatchDate + AND d.vedStatus <> 'C' + """) + List findAllNotClosed( + @Param("dispatchDate") LocalDate dispatchDate + ); - // - default Integer findNextShift(Long vehId, LocalDate date) { - Integer max = findMaxShift(vehId, date); - return max == null ? 0 : max + 1; - } - - @Query(""" - SELECT d - FROM VehicleDispatch d - WHERE d.vedDispatchDate = :dispatchDate - AND d.vedStatus = 'C' - AND d.vedEndReason = :closeReason - AND d.vedEndAt >= :closedAfter - AND NOT EXISTS ( - SELECT 1 - FROM VehicleDispatch x - WHERE x.vedVehId = d.vedVehId - AND x.vedDispatchDate = :dispatchDate - AND x.vedStatus <> 'C' - ) + @Query(""" + SELECT d + FROM VehicleDispatch d + WHERE d.vedStatus = 'P' + AND d.vedPausedAt <= :threshold + """) + List findPausedBefore( + @Param("threshold") LocalDateTime threshold + ); + /* ===================================================== + * SHIFT + * ===================================================== */ + + @Query(""" + SELECT COALESCE(MAX(d.vedShift), -1) + FROM VehicleDispatch d + WHERE d.vedVehId = :vehId + AND d.vedDispatchDate = :date + """) + Integer findMaxShift( + @Param("vehId") Long vehId, + @Param("date") LocalDate date + ); + + default Integer findNextShift(Long vehId, LocalDate date) { + Integer max = findMaxShift(vehId, date); + return max == null ? 0 : max + 1; + } + + /* ===================================================== + * REOPEN + * ===================================================== */ + + @Query(""" + SELECT d + FROM VehicleDispatch d + WHERE d.vedDispatchDate = :dispatchDate + AND d.vedStatus = 'C' + AND d.vedEndReason = :closeReason + AND d.vedEndAt >= :closedAfter + AND NOT EXISTS ( + SELECT 1 + FROM VehicleDispatch x + WHERE x.vedVehId = d.vedVehId + AND x.vedDispatchDate = :dispatchDate + AND x.vedStatus <> 'C' + ) + """) + List findAutoClosedCandidatesForReopen( + @Param("dispatchDate") LocalDate dispatchDate, + @Param("closeReason") String closeReason, + @Param("closedAfter") LocalDateTime closedAfter + ); + + /* ===================================================== + * ODOMETER / STAT PROCESS SUPPORT + * ===================================================== */ + + /** + * vehicleId 목록 → dispatchId 목록 (stat processor 핵심) + */ + @Query(""" + SELECT DISTINCT d.vedId + FROM VehicleDispatch d + WHERE d.vedVehId IN :vehicleIds + AND d.vedDispatchDate = :date + """) + List findDispatchIdsByVehicleIds( + @Param("vehicleIds") List vehicleIds, + @Param("date") LocalDate date + ); + + /** + * 단건: externalId → dispatchId + * (디버깅 / 보조용, bulk 로직에는 사용 안 함) + */ + @Query( + value = """ + SELECT vd.ved_id + FROM vehicle_dispatch vd + JOIN vehicle_external_map vex + ON vex.vex_vehicle_id = vd.ved_veh_id + WHERE vex.vex_solution_type = 'SAMSARA' + AND vex.vex_external_id = :vehicleExtId + AND vex.vex_status = 'A' + AND vd.ved_dispatch_date = :dispatchDate + """, + nativeQuery = true + ) + Optional findDispatchId( + @Param("vehicleExtId") String vehicleExtId, + @Param("dispatchDate") LocalDate dispatchDate + ); + + @Query(""" + SELECT vex.vexExternalId + FROM VehicleDispatch d + JOIN VehicleExternalMap vex + ON vex.vexVehicleId = d.vedVehId + WHERE d.vedId = :dispatchId + AND vex.vexSolutionType = 'SAMSARA' + AND vex.vexStatus = 'A' """) - List findAutoClosedCandidatesForReopen( - @Param("dispatchDate") LocalDate dispatchDate, - @Param("closeReason") String closeReason, - @Param("closedAfter") LocalDateTime closedAfter + Optional findVehicleExternalIdByDispatchId( + @Param("dispatchId") Long dispatchId ); } diff --git a/src/main/java/com/goi/erp/repository/VehicleExternalMapRepository.java b/src/main/java/com/goi/erp/repository/VehicleExternalMapRepository.java index a34c64a..fbfd738 100644 --- a/src/main/java/com/goi/erp/repository/VehicleExternalMapRepository.java +++ b/src/main/java/com/goi/erp/repository/VehicleExternalMapRepository.java @@ -2,6 +2,7 @@ package com.goi.erp.repository; import com.goi.erp.entity.VehicleExternalMap; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import java.util.List; @@ -21,5 +22,17 @@ public interface VehicleExternalMapRepository extends JpaRepository vexVehicleId, String vexStatus ); + + @Query(""" + SELECT m.vexVehicleId + FROM VehicleExternalMap m + WHERE m.vexSolutionType = :solutionType + AND m.vexExternalId IN :externalIds + AND m.vexStatus = 'A' + """) + List findVehicleIdsByExternalIds( + String solutionType, + List externalIds + ); } diff --git a/src/main/java/com/goi/erp/repository/VehicleSafetyEventRepository.java b/src/main/java/com/goi/erp/repository/VehicleSafetyEventRepository.java new file mode 100644 index 0000000..2a4d84a --- /dev/null +++ b/src/main/java/com/goi/erp/repository/VehicleSafetyEventRepository.java @@ -0,0 +1,37 @@ +package com.goi.erp.repository; + +import com.goi.erp.entity.VehicleSafetyEvent; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; + +@Repository +public interface VehicleSafetyEventRepository + extends JpaRepository { + + /** + * 특정 dispatch + 날짜 기준 safety event 목록 + * (daily stat 재계산 / 디버깅용) + */ + List findByVseDispatchIdAndVseEventDate(Long vseDispatchId, LocalDate vseEventDate); + + /** + * 특정 dispatch 의 safety event 전체 + */ + List findByVseDispatchId(Long vseDispatchId); + + /** + * 특정 차량 + 날짜 기준 safety event + * (dispatch 매핑 검증 / 분석용) + */ + List findByVseVehicleIdAndVseEventDate(Long vseVehicleId, LocalDate vseEventDate); + + /** + * 특정 source 의 safety event + * (SAMSARA / MANUAL 구분용) + */ + List findByVseSource(String vseSource); +} diff --git a/src/main/java/com/goi/erp/service/ExtSamsaraEngineSecondsIngestService.java b/src/main/java/com/goi/erp/service/ExtSamsaraEngineSecondsIngestService.java new file mode 100644 index 0000000..73761fc --- /dev/null +++ b/src/main/java/com/goi/erp/service/ExtSamsaraEngineSecondsIngestService.java @@ -0,0 +1,57 @@ +package com.goi.erp.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.goi.erp.dto.ExtSamsaraEngineSecondsFetchCommand; +import com.goi.erp.dto.ExtSamsaraEngineSecondsRecordDto; +import com.goi.erp.repository.ExtSamsaraRawEngineSecondsRepository; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ExtSamsaraEngineSecondsIngestService { + + private final ExtSamsaraRawEngineSecondsRepository repository; + private final ObjectMapper objectMapper; + + /** + * Raw engine seconds ingest + * - append-only + * - idempotent by esre_hash + * - obdEngineSeconds only + */ + @Transactional + public int ingest(ExtSamsaraEngineSecondsFetchCommand command) { + + int inserted = 0; + + for (ExtSamsaraEngineSecondsRecordDto record : command.getRecords()) { + + int result = repository.insertIgnore( + command.getSource(), // SAMSARA + record.getVehicleExternalId(), // samsara vehicle id + record.getSampleTime(), // Instant + record.getEngineSeconds(), // Long + record.getPayloadHash(), // vehicle + time + value + command.getFetchedAt(), // LocalDateTime + toJson(record.getPayloadJson()) // ✅ payload 저장 + ); + + inserted += result; // 1 or 0 + } + + return inserted; + } + + private String toJson(JsonNode node) { + try { + return objectMapper.writeValueAsString(node); + } catch (Exception e) { + throw new RuntimeException("Failed to serialize payloadJson", e); + } + } +} diff --git a/src/main/java/com/goi/erp/service/ExtSamsaraOdometerIngestService.java b/src/main/java/com/goi/erp/service/ExtSamsaraOdometerIngestService.java new file mode 100644 index 0000000..ef3cfe3 --- /dev/null +++ b/src/main/java/com/goi/erp/service/ExtSamsaraOdometerIngestService.java @@ -0,0 +1,58 @@ +package com.goi.erp.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.goi.erp.dto.ExtSamsaraOdometerFetchCommand; +import com.goi.erp.dto.ExtSamsaraOdometerRecordDto; +import com.goi.erp.repository.ExtSamsaraRawOdometerRepository; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ExtSamsaraOdometerIngestService { + + private final ExtSamsaraRawOdometerRepository repository; + private final ObjectMapper objectMapper; + + /** + * Raw odometer ingest (A안) + * - append-only + * - idempotent by esro_hash + */ + @Transactional + public int ingest(ExtSamsaraOdometerFetchCommand command) { + + int inserted = 0; + + for (ExtSamsaraOdometerRecordDto record : command.getRecords()) { + + int result = repository.insertIgnore( + command.getSource(), + record.getVehicleExternalId(), + record.getSampleTime(), // Instant + record.getOdometerType(), + record.getOdometerMeters(), + record.getPayloadHash(), + command.getFetchedAt(), // LocalDateTime + toJson(record.getPayloadJson()) + ); + + inserted += result; // 1 or 0 + } + + return inserted; + } + + private String toJson(JsonNode node) { + try { + return objectMapper.writeValueAsString(node); + } catch (Exception e) { + throw new RuntimeException("Failed to serialize payloadJson", e); + } + } +} + diff --git a/src/main/java/com/goi/erp/service/ExtSamsaraSafetyEventIngestService.java b/src/main/java/com/goi/erp/service/ExtSamsaraSafetyEventIngestService.java new file mode 100644 index 0000000..1a9bbbf --- /dev/null +++ b/src/main/java/com/goi/erp/service/ExtSamsaraSafetyEventIngestService.java @@ -0,0 +1,69 @@ +package com.goi.erp.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.goi.erp.dto.ExtSamsaraSafetyEventFetchCommand; +import com.goi.erp.dto.ExtSamsaraSafetyEventRecordDto; +import com.goi.erp.repository.ExtSamsaraRawSafetyEventRepository; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ExtSamsaraSafetyEventIngestService { + + private final ExtSamsaraRawSafetyEventRepository repository; + private final ObjectMapper objectMapper; + + /** + * Raw safety event ingest + * + * rules: + * - append-only + * - idempotent by esrs_event_id + * - samsara safety-events API + */ + @Transactional + public int ingest(ExtSamsaraSafetyEventFetchCommand command) { + + int inserted = 0; + + for (ExtSamsaraSafetyEventRecordDto record : command.getRecords()) { + + int result = repository.insertIgnore( + record.getEventId(), // samsara event id + record.getVehicleId(), // samsara vehicle id + record.getDriverId(), // nullable + record.getEventTime(), // Instant + record.getEventDate(), // LocalDate + record.getCoachingState(), + record.getMaxAccelerationG(), + record.getLatitude(), + record.getLongitude(), + record.getVideoForwardUrl(), + record.getVideoInwardUrl(), + toJson(record.getBehaviorLabels()), // jsonb + toJson(record.getRawPayload()), // jsonb + command.getFetchedAt() // LocalDateTime + ); + + inserted += result; // 1 or 0 + } + + return inserted; + } + + private String toJson(JsonNode node) { + if (node == null) { + return null; + } + try { + return objectMapper.writeValueAsString(node); + } catch (Exception e) { + throw new RuntimeException("Failed to serialize JsonNode", e); + } + } +} diff --git a/src/main/java/com/goi/erp/service/SchedulerEngineSecondsProcessor.java b/src/main/java/com/goi/erp/service/SchedulerEngineSecondsProcessor.java new file mode 100644 index 0000000..499f47e --- /dev/null +++ b/src/main/java/com/goi/erp/service/SchedulerEngineSecondsProcessor.java @@ -0,0 +1,100 @@ +package com.goi.erp.service; + +import com.goi.erp.dto.ScheduleWorkerRequestDto; +import com.goi.erp.dto.ScheduleWorkerResponseDto; +import com.goi.erp.repository.ExtSamsaraRawEngineSecondsRepository; +import com.goi.erp.repository.VehicleDispatchRepository; +import com.goi.erp.repository.VehicleExternalMapRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SchedulerEngineSecondsProcessor { + + private final ExtSamsaraRawEngineSecondsRepository rawRepository; + private final VehicleExternalMapRepository vehicleExternalMapRepository; + private final VehicleDispatchRepository vehicleDispatchRepository; + private final VehicleDispatchDailyStatService dailyStatService; + + public ScheduleWorkerResponseDto run(ScheduleWorkerRequestDto request) { + + LocalDate statDate = request.getFrom().toLocalDate(); + + int processed = 0; + int success = 0; + int failed = 0; + + /* ===================================================== + * 1. 해당 날짜에 unprocessed raw가 있는 vehicleExternalIds + * ===================================================== */ + List vehicleExtIds = + rawRepository.findDistinctVehicleExtIdByDateAndUnprocessed(statDate); + + if (vehicleExtIds.isEmpty()) { + log.info("[ENGINE_SECONDS_PROCESS] no raw to process date={}", statDate); + return ScheduleWorkerResponseDto.successEmpty(); + } + + /* ===================================================== + * 2. externalId → vehicleId + * ===================================================== */ + List vehicleIds = + vehicleExternalMapRepository.findVehicleIdsByExternalIds( + "SAMSARA", + vehicleExtIds + ); + + if (vehicleIds.isEmpty()) { + log.info("[ENGINE_SECONDS_PROCESS] no vehicle mapping date={}", statDate); + return ScheduleWorkerResponseDto.successEmpty(); + } + + /* ===================================================== + * 3. vehicleId → dispatchId + * ===================================================== */ + List dispatchIds = + vehicleDispatchRepository.findDispatchIdsByVehicleIds( + vehicleIds, + statDate + ); + + if (dispatchIds.isEmpty()) { + log.info("[ENGINE_SECONDS_PROCESS] no dispatch date={}", statDate); + return ScheduleWorkerResponseDto.successEmpty(); + } + + /* ===================================================== + * 4. dispatch 단위 stat 처리 + * ===================================================== */ + for (Long dispatchId : dispatchIds) { + processed++; + try { + dailyStatService.processEngineSecondsDaily(dispatchId, statDate); + success++; + } catch (Exception e) { + failed++; + log.error( + "[ENGINE_SECONDS_PROCESS_FAIL] dispatchId={} date={}", + dispatchId, + statDate, + e + ); + } + } + + return ScheduleWorkerResponseDto.builder() + .success(failed == 0) + .processedCount(processed) + .successCount(success) + .failCount(failed) + .build(); + } +} diff --git a/src/main/java/com/goi/erp/service/SchedulerEngineSecondsRawOrchestrator.java b/src/main/java/com/goi/erp/service/SchedulerEngineSecondsRawOrchestrator.java new file mode 100644 index 0000000..abe500b --- /dev/null +++ b/src/main/java/com/goi/erp/service/SchedulerEngineSecondsRawOrchestrator.java @@ -0,0 +1,111 @@ +package com.goi.erp.service; + +import com.goi.erp.client.SamsaraStatEngineSecondsClient; +import com.goi.erp.dto.ExtSamsaraEngineSecondsFetchCommand; +import com.goi.erp.dto.ScheduleWorkerRequestDto; +import com.goi.erp.dto.ScheduleWorkerResponseDto; +import com.goi.erp.entity.VehicleDispatch; +import com.goi.erp.repository.VehicleDispatchRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class SchedulerEngineSecondsRawOrchestrator { + + private final SamsaraStatEngineSecondsClient statEngineSecondsClient; + private final ExtSamsaraEngineSecondsIngestService ingestService; + + private final VehicleDispatchRepository vehicleDispatchRepository; + private final VehicleExternalMapService vehicleExternalMapService; + + public ScheduleWorkerResponseDto run(ScheduleWorkerRequestDto request) { + + try { + LocalDate dispatchDate = request.getFrom().toLocalDate(); + + // 1. 대상 dispatch 조회 (NOT CLOSED) + List targets = + vehicleDispatchRepository.findAllNotClosed(dispatchDate); + + if (targets.isEmpty()) { + log.info("[ENGINE_SECONDS_RAW] no dispatch to process"); + return ScheduleWorkerResponseDto.successEmpty(); + } + + // 2. internal vehicleId 수집 + List vehicleIds = targets.stream() + .map(VehicleDispatch::getVedVehId) + .filter(Objects::nonNull) + .distinct() + .toList(); + + if (vehicleIds.isEmpty()) { + log.info("[ENGINE_SECONDS_RAW] no vehicles to process"); + return ScheduleWorkerResponseDto.successEmpty(); + } + + // 3. internal → external (Samsara) 매핑 + Map vehicleIdToExternalId = + vehicleExternalMapService.findExternalIdsByVehicleIds( + "SAMSARA", + vehicleIds + ); + + List externalIds = vehicleIdToExternalId.values().stream() + .filter(Objects::nonNull) + .distinct() + .toList(); + + if (externalIds.isEmpty()) { + log.info("[ENGINE_SECONDS_RAW] no external vehicle ids"); + return ScheduleWorkerResponseDto.successEmpty(); + } + + // 4. request 에 externalIds 세팅 + request.setVehicleExternalIds(externalIds); + + // 5. integration-service fetch (obdEngineSeconds) + ExtSamsaraEngineSecondsFetchCommand fetchResult = + statEngineSecondsClient.fetchEngineSeconds(request); + + if (fetchResult == null || fetchResult.getRecords() == null) { + return ScheduleWorkerResponseDto.successEmpty(); + } + + // 6. raw ingest + int inserted = ingestService.ingest(fetchResult); + + log.info( + "[ENGINE_SECONDS_RAW][DONE] fetched={} inserted={}", + fetchResult.getRecords().size(), + inserted + ); + + return ScheduleWorkerResponseDto.builder() + .success(true) + .processedCount(fetchResult.getRecords().size()) + .successCount(inserted) + .failCount(fetchResult.getRecords().size() - inserted) + .build(); + + } catch (Exception e) { + log.error("[ENGINE_SECONDS_RAW][RUN_FAIL]", e); + + return ScheduleWorkerResponseDto.builder() + .success(false) + .errorCode("ENGINE_SECONDS_RAW_INGEST_ERROR") + .errorMessage(e.getMessage()) + .build(); + } + } +} diff --git a/src/main/java/com/goi/erp/service/SchedulerOdometerProcessor.java b/src/main/java/com/goi/erp/service/SchedulerOdometerProcessor.java new file mode 100644 index 0000000..545307d --- /dev/null +++ b/src/main/java/com/goi/erp/service/SchedulerOdometerProcessor.java @@ -0,0 +1,100 @@ +package com.goi.erp.service; + +import com.goi.erp.dto.ScheduleWorkerRequestDto; +import com.goi.erp.dto.ScheduleWorkerResponseDto; +import com.goi.erp.repository.ExtSamsaraRawOdometerRepository; +import com.goi.erp.repository.VehicleDispatchRepository; +import com.goi.erp.repository.VehicleExternalMapRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SchedulerOdometerProcessor { + + private final ExtSamsaraRawOdometerRepository rawRepository; + private final VehicleExternalMapRepository vehicleExternalMapRepository; + private final VehicleDispatchRepository vehicleDispatchRepository; + private final VehicleDispatchDailyStatService dailyStatService; + + public ScheduleWorkerResponseDto run(ScheduleWorkerRequestDto request) { + + LocalDate statDate = request.getFrom().toLocalDate(); + + int processed = 0; + int success = 0; + int failed = 0; + + /* ===================================================== + * 1. 해당 날짜에 unprocessed raw가 있는 vehicleExternalIds + * ===================================================== */ + List vehicleExtIds = + rawRepository.findDistinctVehicleExtIdByDateAndUnprocessed(statDate); + + if (vehicleExtIds.isEmpty()) { + log.info("[ODOMETER_PROCESS] no raw to process date={}", statDate); + return ScheduleWorkerResponseDto.successEmpty(); + } + + /* ===================================================== + * 2. externalId → vehicleId + * ===================================================== */ + List vehicleIds = + vehicleExternalMapRepository.findVehicleIdsByExternalIds( + "SAMSARA", + vehicleExtIds + ); + + if (vehicleIds.isEmpty()) { + log.info("[ODOMETER_PROCESS] no vehicle mapping date={}", statDate); + return ScheduleWorkerResponseDto.successEmpty(); + } + + /* ===================================================== + * 3. vehicleId → dispatchId + * ===================================================== */ + List dispatchIds = + vehicleDispatchRepository.findDispatchIdsByVehicleIds( + vehicleIds, + statDate + ); + + if (dispatchIds.isEmpty()) { + log.info("[ODOMETER_PROCESS] no dispatch date={}", statDate); + return ScheduleWorkerResponseDto.successEmpty(); + } + + /* ===================================================== + * 4. dispatch 단위 stat 처리 + * ===================================================== */ + for (Long dispatchId : dispatchIds) { + processed++; + try { + dailyStatService.processOdometerDaily(dispatchId, statDate); + success++; + } catch (Exception e) { + failed++; + log.error( + "[ODOMETER_PROCESS_FAIL] dispatchId={} date={}", + dispatchId, + statDate, + e + ); + } + } + + return ScheduleWorkerResponseDto.builder() + .success(failed == 0) + .processedCount(processed) + .successCount(success) + .failCount(failed) + .build(); + } +} diff --git a/src/main/java/com/goi/erp/service/SchedulerOdometerRawOrchestrator.java b/src/main/java/com/goi/erp/service/SchedulerOdometerRawOrchestrator.java new file mode 100644 index 0000000..6e0a0eb --- /dev/null +++ b/src/main/java/com/goi/erp/service/SchedulerOdometerRawOrchestrator.java @@ -0,0 +1,112 @@ +package com.goi.erp.service; + +import com.goi.erp.client.SamsaraStatOdometerClient; +import com.goi.erp.dto.ExtSamsaraOdometerFetchCommand; +import com.goi.erp.dto.ScheduleWorkerRequestDto; +import com.goi.erp.dto.ScheduleWorkerResponseDto; +import com.goi.erp.entity.VehicleDispatch; +import com.goi.erp.repository.VehicleDispatchRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class SchedulerOdometerRawOrchestrator { + + private final SamsaraStatOdometerClient statOdometerClient; + private final ExtSamsaraOdometerIngestService ingestService; + + private final VehicleDispatchRepository vehicleDispatchRepository; + private final VehicleExternalMapService vehicleExternalMapService; + + public ScheduleWorkerResponseDto run(ScheduleWorkerRequestDto request) { + + try { + LocalDate dispatchDate = request.getFrom().toLocalDate(); + + // 1. 대상 dispatch 조회 (NOT CLOSED) + List targets = vehicleDispatchRepository.findAllNotClosed(dispatchDate); + + if (targets.isEmpty()) { + log.info("[ODOMETER_RAW] no dispatch to process"); + return ScheduleWorkerResponseDto.successEmpty(); + } + + // 2. internal vehicleId 수집 + List vehicleIds = targets.stream() + .map(VehicleDispatch::getVedVehId) + .filter(Objects::nonNull) + .distinct() + .toList(); + + if (vehicleIds.isEmpty()) { + log.info("[ODOMETER_RAW] no vehicles to process"); + return ScheduleWorkerResponseDto.successEmpty(); + } + + // 3. internal → external (Samsara) 매핑 + Map vehicleIdToExternalId = + vehicleExternalMapService.findExternalIdsByVehicleIds( + "SAMSARA", + vehicleIds + ); + + List externalIds = vehicleIdToExternalId.values().stream() + .filter(Objects::nonNull) + .distinct() + .toList(); + + if (externalIds.isEmpty()) { + log.info("[ODOMETER_RAW] no external vehicle ids"); + return ScheduleWorkerResponseDto.successEmpty(); + } + + // 4. ★ request 에 externalIds 세팅 (중요) + request.setVehicleExternalIds(externalIds); + + // 5. integration-service fetch + ExtSamsaraOdometerFetchCommand fetchResult = + statOdometerClient.fetchOdometer(request); + + if (fetchResult == null || fetchResult.getRecords() == null) { + return ScheduleWorkerResponseDto.successEmpty(); + } + + // 6. raw ingest + int inserted = ingestService.ingest(fetchResult); + + log.info( + "[ODOMETER_RAW][DONE] fetched={} inserted={}", + fetchResult.getRecords().size(), + inserted + ); + + return ScheduleWorkerResponseDto.builder() + .success(true) + .processedCount(fetchResult.getRecords().size()) + .successCount(inserted) + .failCount(fetchResult.getRecords().size() - inserted) + .build(); + + } catch (Exception e) { + log.error("[ODOMETER_RAW][RUN_FAIL]", e); + + return ScheduleWorkerResponseDto.builder() + .success(false) + .errorCode("ODOMETER_RAW_INGEST_ERROR") + .errorMessage(e.getMessage()) + .build(); + } + } +} + + diff --git a/src/main/java/com/goi/erp/service/SchedulerSafetyEventProcessor.java b/src/main/java/com/goi/erp/service/SchedulerSafetyEventProcessor.java new file mode 100644 index 0000000..57e2218 --- /dev/null +++ b/src/main/java/com/goi/erp/service/SchedulerSafetyEventProcessor.java @@ -0,0 +1,251 @@ +package com.goi.erp.service; + +import com.goi.erp.dto.ScheduleWorkerRequestDto; +import com.goi.erp.dto.ScheduleWorkerResponseDto; +import com.goi.erp.entity.ExtSamsaraRawSafetyEvent; +import com.goi.erp.entity.VehicleDispatch; +import com.goi.erp.entity.VehicleSafetyEvent; +import com.goi.erp.repository.ExtSamsaraRawSafetyEventRepository; +import com.goi.erp.repository.VehicleDispatchRepository; +import com.goi.erp.repository.VehicleExternalMapRepository; +import com.goi.erp.repository.VehicleSafetyEventRepository; +import com.fasterxml.jackson.databind.JsonNode; +import com.goi.erp.client.HcmEmployeeClient; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SchedulerSafetyEventProcessor { + + private final ExtSamsaraRawSafetyEventRepository rawRepository; + private final VehicleExternalMapRepository vehicleExternalMapRepository; + private final VehicleDispatchRepository vehicleDispatchRepository; + private final VehicleSafetyEventRepository safetyEventRepository; + private final HcmEmployeeClient hcmEmployeeClient; + + @Transactional + public ScheduleWorkerResponseDto run(ScheduleWorkerRequestDto request) { + + LocalDate statDate = request.getFrom().toLocalDate(); + + int processed = 0; + int success = 0; + int failed = 0; + + /* ===================================================== + * 1. 날짜 기준 unprocessed raw 가 있는 samsaraVehicleIds + * ===================================================== */ + List samsaraVehicleIds = + rawRepository.findDistinctVehicleIdByDateAndUnprocessed(statDate); + + if (samsaraVehicleIds.isEmpty()) { + log.info("[SAFETY_EVENT_PROCESS] no raw to process date={}", statDate); + return ScheduleWorkerResponseDto.successEmpty(); + } + + /* ===================================================== + * 2. externalId → internal vehicleId 매핑 + * ===================================================== */ + List vehicleIds = + vehicleExternalMapRepository.findVehicleIdsByExternalIds( + "SAMSARA", + samsaraVehicleIds + ); + + if (vehicleIds.isEmpty()) { + log.info("[SAFETY_EVENT_PROCESS] no vehicle mapping date={}", statDate); + return ScheduleWorkerResponseDto.successEmpty(); + } + + /* ===================================================== + * 3. vehicleId → dispatchId + * ===================================================== */ + List dispatchIds = + vehicleDispatchRepository.findDispatchIdsByVehicleIds( + vehicleIds, + statDate + ); + + if (dispatchIds.isEmpty()) { + log.info("[SAFETY_EVENT_PROCESS] no dispatch date={}", statDate); + return ScheduleWorkerResponseDto.successEmpty(); + } + + /* ===================================================== + * 4. dispatch 단위 safety event 처리 + * ===================================================== */ + for (Long dispatchId : dispatchIds) { + processed++; + try { + success += processDispatch(dispatchId, statDate); + } catch (Exception e) { + failed++; + log.error( + "[SAFETY_EVENT_PROCESS_FAIL] dispatchId={} date={}", + dispatchId, + statDate, + e + ); + } + } + + return ScheduleWorkerResponseDto.builder() + .success(failed == 0) + .processedCount(processed) + .successCount(success) + .failCount(failed) + .build(); + } + + /** + * dispatch 1건에 대한 safety event 처리 + */ + private int processDispatch(Long dispatchId, LocalDate statDate) { + + VehicleDispatch dispatch = + vehicleDispatchRepository.findById(dispatchId).orElseThrow(); + + /* ===================================================== + * internal vehicleId → external samsaraVehicleId + * ===================================================== */ + String samsaraVehicleId = + vehicleExternalMapRepository + .findAllByVexSolutionTypeAndVexVehicleIdInAndVexStatus( + "SAMSARA", + List.of(dispatch.getVedVehId()), + "A" + ) + .stream() + .findFirst() + .map(m -> m.getVexExternalId()) + .orElse(null); + + if (samsaraVehicleId == null) { + return 0; + } + + /* ===================================================== + * raw 조회 (external id 기준) + * ===================================================== */ + List raws = + rawRepository.findByEsrsVehicleIdAndEsrsEventDateAndEsrsProcessedFalse( + samsaraVehicleId, + statDate + ); + + int inserted = 0; + + for (ExtSamsaraRawSafetyEvent raw : raws) { + try { + if (processSingle(raw, dispatch)) { + inserted++; + } + } catch (Exception e) { + log.error( + "[SAFETY_EVENT_RAW_PROCESS_FAIL] rawId={} dispatchId={}", + raw.getEsrsId(), + dispatchId, + e + ); + } + } + + return inserted; + } + + /** + * raw 1건 → vehicle_safety_event + */ + private boolean processSingle( + ExtSamsaraRawSafetyEvent raw, + VehicleDispatch dispatch + ) { + // driver resolve + Long driverId = resolveDriverId(raw); + // behavior summary 생성 + String behaviorSummary = buildBehaviorSummary(raw.getEsrsBehaviorLabels()); + + VehicleSafetyEvent event = + VehicleSafetyEvent.builder() + .vseSource("SAMSARA") + .vseBehaviorSummary(behaviorSummary) + .vseDispatchId(dispatch.getVedId()) + .vseVehicleId(dispatch.getVedVehId()) + .vseDriverId(driverId) + .vseEventTime(raw.getEsrsEventTime()) + .vseEventDate(raw.getEsrsEventDate()) + .vseCoachingState(raw.getEsrsCoachingState()) + .vseMaxAccelG(raw.getEsrsMaxAccelG()) + .vseLatitude(raw.getEsrsLatitude()) + .vseLongitude(raw.getEsrsLongitude()) + .vseVideoForwardUrl(raw.getEsrsVideoForwardUrl()) + .vseVideoInwardUrl(raw.getEsrsVideoInwardUrl()) + .vseBehaviorLabels(raw.getEsrsBehaviorLabels()) + .build(); + + VehicleSafetyEvent saved = safetyEventRepository.save(event); + + raw.setEsrsProcessed(true); + raw.setEsrsProcessedAt(LocalDateTime.now()); + raw.setEsrsVehicleSafetyEventId(saved.getVseId()); + + rawRepository.save(raw); + + return true; + } + + private String buildBehaviorSummary(JsonNode behaviorLabels) { + + if (behaviorLabels == null || !behaviorLabels.isArray() || behaviorLabels.isEmpty()) { + return null; + } + + // name 기준 추출 + List names = new ArrayList<>(); + + for (JsonNode label : behaviorLabels) { + String name = label.path("name").asText(null); + if (name != null) { + names.add(name); + } + } + + if (names.isEmpty()) { + return null; + } + + // 여러 개면 ", "로 join + return String.join(", ", names); + } + + + /** + * Samsara driver id → internal emp id + */ + private Long resolveDriverId(ExtSamsaraRawSafetyEvent raw) { + + if (raw.getEsrsDriverId() == null) { + return null; + } + + try { + return hcmEmployeeClient.getEmpIdFromExternalId( + "SAMSARA", + String.valueOf(raw.getEsrsDriverId()) + ); + } catch (Exception e) { + return null; + } + } +} diff --git a/src/main/java/com/goi/erp/service/SchedulerSafetyEventRawOrchestrator.java b/src/main/java/com/goi/erp/service/SchedulerSafetyEventRawOrchestrator.java new file mode 100644 index 0000000..1aa2565 --- /dev/null +++ b/src/main/java/com/goi/erp/service/SchedulerSafetyEventRawOrchestrator.java @@ -0,0 +1,111 @@ +package com.goi.erp.service; + +import com.goi.erp.client.SamsaraStatSafetyEventClient; +import com.goi.erp.dto.ExtSamsaraSafetyEventFetchCommand; +import com.goi.erp.dto.ScheduleWorkerRequestDto; +import com.goi.erp.dto.ScheduleWorkerResponseDto; +import com.goi.erp.entity.VehicleDispatch; +import com.goi.erp.repository.VehicleDispatchRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class SchedulerSafetyEventRawOrchestrator { + + private final SamsaraStatSafetyEventClient statSafetyEventClient; + private final ExtSamsaraSafetyEventIngestService ingestService; + + private final VehicleDispatchRepository vehicleDispatchRepository; + private final VehicleExternalMapService vehicleExternalMapService; + + public ScheduleWorkerResponseDto run(ScheduleWorkerRequestDto request) { + + try { + LocalDate dispatchDate = request.getFrom().toLocalDate(); + + // 1. 대상 dispatch 조회 (NOT CLOSED) + List targets = + vehicleDispatchRepository.findAllNotClosed(dispatchDate); + + if (targets.isEmpty()) { + log.info("[ENGINE_SECONDS_RAW] no dispatch to process"); + return ScheduleWorkerResponseDto.successEmpty(); + } + + // 2. internal vehicleId 수집 + List vehicleIds = targets.stream() + .map(VehicleDispatch::getVedVehId) + .filter(Objects::nonNull) + .distinct() + .toList(); + + if (vehicleIds.isEmpty()) { + log.info("[ENGINE_SECONDS_RAW] no vehicles to process"); + return ScheduleWorkerResponseDto.successEmpty(); + } + + // 3. internal → external (Samsara) 매핑 + Map vehicleIdToExternalId = + vehicleExternalMapService.findExternalIdsByVehicleIds( + "SAMSARA", + vehicleIds + ); + + List externalIds = vehicleIdToExternalId.values().stream() + .filter(Objects::nonNull) + .distinct() + .toList(); + + if (externalIds.isEmpty()) { + log.info("[ENGINE_SECONDS_RAW] no external vehicle ids"); + return ScheduleWorkerResponseDto.successEmpty(); + } + + // 4. request 에 externalIds 세팅 + request.setVehicleExternalIds(externalIds); + + // 5. integration-service fetch (obdEngineSeconds) + ExtSamsaraSafetyEventFetchCommand fetchResult = + statSafetyEventClient.fetchSafetyEvent(request); + + if (fetchResult == null || fetchResult.getRecords() == null) { + return ScheduleWorkerResponseDto.successEmpty(); + } + + // 6. raw ingest + int inserted = ingestService.ingest(fetchResult); + + log.info( + "[ENGINE_SECONDS_RAW][DONE] fetched={} inserted={}", + fetchResult.getRecords().size(), + inserted + ); + + return ScheduleWorkerResponseDto.builder() + .success(true) + .processedCount(fetchResult.getRecords().size()) + .successCount(inserted) + .failCount(fetchResult.getRecords().size() - inserted) + .build(); + + } catch (Exception e) { + log.error("[ENGINE_SECONDS_RAW][RUN_FAIL]", e); + + return ScheduleWorkerResponseDto.builder() + .success(false) + .errorCode("ENGINE_SECONDS_RAW_INGEST_ERROR") + .errorMessage(e.getMessage()) + .build(); + } + } +} diff --git a/src/main/java/com/goi/erp/service/VehicleDispatchDailyStatService.java b/src/main/java/com/goi/erp/service/VehicleDispatchDailyStatService.java new file mode 100644 index 0000000..af00910 --- /dev/null +++ b/src/main/java/com/goi/erp/service/VehicleDispatchDailyStatService.java @@ -0,0 +1,289 @@ +package com.goi.erp.service; + +import com.goi.erp.entity.ExtSamsaraRawEngineSeconds; +import com.goi.erp.entity.ExtSamsaraRawOdometer; +import com.goi.erp.entity.VehicleDispatchDailyStat; +import com.goi.erp.repository.ExtSamsaraRawEngineSecondsRepository; +import com.goi.erp.repository.ExtSamsaraRawOdometerRepository; +import com.goi.erp.repository.VehicleDispatchDailyStatRepository; +import com.goi.erp.repository.VehicleDispatchRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class VehicleDispatchDailyStatService { + + private final VehicleDispatchDailyStatRepository repository; + private final VehicleDispatchRepository vehicleDispatchRepository; + private final ExtSamsaraRawOdometerRepository rawOdometerRepository; + private final ExtSamsaraRawEngineSecondsRepository rawEngineSecondsRepository; + + public Optional getDailyStat(Long dispatchId, LocalDate date) { + + return repository.findByVddsDispatchIdAndVddsDate(dispatchId, date); + } + + /** + * Odometer 업데이트 + * + * rule: + * - 이전 값보다 작아지면 (리셋) + * → increment 유지 + * → total 만 새 값으로 교체 + */ + public VehicleDispatchDailyStat updateOdometerStat( + Long dispatchId, + LocalDate date, + BigDecimal newTotalOdometer, + String newSource + ) { + + VehicleDispatchDailyStat stat = repository + .findByVddsDispatchIdAndVddsDate(dispatchId, date) + .orElseThrow(() -> + new IllegalStateException( + "Daily stat not found for dispatchId=" + dispatchId + ) + ); + + BigDecimal prevTotal = stat.getVddsOdometerTotal(); + BigDecimal prevIncrement = stat.getVddsOdometerIncrement(); + String prevSource = stat.getVddsOdometerSource(); + + // 최초 + if (prevTotal == null) { + stat.setVddsOdometerTotal(newTotalOdometer); + stat.setVddsOdometerIncrement(BigDecimal.ZERO); + stat.setVddsOdometerSource(newSource); + stat.setVddsCollectedAt(LocalDateTime.now()); + return repository.save(stat); + } + + // source 변경 + if (prevSource != null && !prevSource.equals(newSource)) { + // total만 교체, increment 유지 + stat.setVddsOdometerTotal(newTotalOdometer); + stat.setVddsOdometerSource(newSource); + stat.setVddsCollectedAt(LocalDateTime.now()); + return repository.save(stat); + } + + // 같은 source + if (newTotalOdometer != null) { + if (newTotalOdometer.compareTo(prevTotal) >= 0) { + BigDecimal delta = newTotalOdometer.subtract(prevTotal); + stat.setVddsOdometerIncrement( + prevIncrement == null ? delta : prevIncrement.add(delta) + ); + } + } + // 감소(리셋)면 increment 유지 + stat.setVddsOdometerTotal(newTotalOdometer); + stat.setVddsOdometerSource(newSource); + stat.setVddsCollectedAt(LocalDateTime.now()); + return repository.save(stat); + } + + @Transactional + public void processOdometerDaily(Long dispatchId, LocalDate statDate) { + + // 1. dispatch → vehicleExternalId 조회 + String vehicleExtId = + vehicleDispatchRepository + .findVehicleExternalIdByDispatchId(dispatchId) + .orElseThrow(() -> + new IllegalStateException( + "Vehicle externalId not found. dispatchId=" + dispatchId + ) + ); + + // 2. 해당 날짜의 미처리 raw 조회 + List raws = + rawOdometerRepository.findByVehicleExtIdAndDateAndUnprocessed( + vehicleExtId, + statDate + ); + + if (raws.isEmpty()) { + log.info( + "[ODOMETER_STAT] no raw to process dispatchId={} date={}", + dispatchId, + statDate + ); + return; + } + + // 3. 타입별 분리 + var obdSamples = raws.stream() + .filter(r -> "OBD".equals(r.getEsroOdometerType())) + .sorted(Comparator.comparing(ExtSamsaraRawOdometer::getEsroSampleTime)) + .toList(); + + var gpsSamples = raws.stream() + .filter(r -> "GPS".equals(r.getEsroOdometerType())) + .sorted(Comparator.comparing(ExtSamsaraRawOdometer::getEsroSampleTime)) + .toList(); + + // 4. 사용할 샘플 결정 + var samplesToUse = !obdSamples.isEmpty() ? obdSamples : gpsSamples; + if (samplesToUse.isEmpty()) { + return; + } + + // 5. last odometer + BigDecimal last = + BigDecimal.valueOf( + samplesToUse.get(samplesToUse.size() - 1).getEsroOdometerMeters() + ); + + updateOdometerStat( + dispatchId, + statDate, + last, + "SAMSARA_" + samplesToUse.get(0).getEsroOdometerType() + ); + + // 6. raw 처리 완료 + raws.forEach(r -> { + r.setEsroProcessed(true); + r.setEsroProcessedAt(LocalDateTime.now()); + }); + + rawOdometerRepository.saveAll(raws); + + log.info( + "[ODOMETER_STAT] done dispatchId={} date={} samples={} type={}", + dispatchId, + statDate, + samplesToUse.size(), + samplesToUse.get(0).getEsroOdometerType() + ); + } + + /** + * Engine seconds 업데이트 + * + * rule: + * - total 이 null 이면 최초 집계 → increment = 0 + * - 이전 값보다 작아지면 (리셋) + * → increment 유지 + * → total 만 새 값으로 교체 + */ + public VehicleDispatchDailyStat updateEngineSecondsStat( + Long dispatchId, + LocalDate date, + Integer newTotalEngineSeconds + ) { + + VehicleDispatchDailyStat stat = repository + .findByVddsDispatchIdAndVddsDate(dispatchId, date) + .orElseThrow(() -> + new IllegalStateException( + "Daily stat not found for dispatchId=" + dispatchId + ) + ); + + Integer prevTotal = stat.getVddsEngineSecondsTotal(); + Integer prevIncrement = stat.getVddsEngineSecondsIncrement(); + + if (prevTotal == null) { + // 최초 집계 + stat.setVddsEngineSecondsTotal(newTotalEngineSeconds); + stat.setVddsEngineSecondsIncrement(0); + } else if (newTotalEngineSeconds != null) { + + if (newTotalEngineSeconds >= prevTotal) { + int delta = newTotalEngineSeconds - prevTotal; + + stat.setVddsEngineSecondsIncrement( + prevIncrement == null + ? delta + : prevIncrement + delta + ); + } + // else: 리셋 → increment 유지 + stat.setVddsEngineSecondsTotal(newTotalEngineSeconds); + } + + stat.setVddsCollectedAt(LocalDateTime.now()); + return repository.save(stat); + } + + @Transactional + public void processEngineSecondsDaily(Long dispatchId, LocalDate statDate) { + + // 1. dispatch → vehicleExternalId + String vehicleExtId = + vehicleDispatchRepository + .findVehicleExternalIdByDispatchId(dispatchId) + .orElseThrow(() -> + new IllegalStateException( + "Vehicle externalId not found. dispatchId=" + dispatchId + ) + ); + + // 2. 해당 날짜의 미처리 raw 조회 + List raws = + rawEngineSecondsRepository.findByEsreVehicleExtIdAndEsreFetchedDateAndEsreProcessedFalse( + vehicleExtId, + statDate + ); + + if (raws.isEmpty()) { + log.info( + "[ENGINE_SECONDS_STAT] no raw to process dispatchId={} date={}", + dispatchId, + statDate + ); + return; + } + + // 3. 시간순 정렬 + raws.sort( + Comparator.comparing(ExtSamsaraRawEngineSeconds::getEsreSampleTime) + ); + + // 4. last engine seconds + Integer last = + raws.get(raws.size() - 1).getEsreEngineSeconds().intValue(); + + updateEngineSecondsStat( + dispatchId, + statDate, + last + ); + + // 5. raw 처리 완료 + raws.forEach(r -> { + r.setEsreProcessed(true); + r.setEsreProcessedAt(LocalDateTime.now()); + }); + + rawEngineSecondsRepository.saveAll(raws); + + log.info( + "[ENGINE_SECONDS_STAT] done dispatchId={} date={} samples={}", + dispatchId, + statDate, + raws.size() + ); + } + + + +} + + diff --git a/src/main/java/com/goi/erp/token/SystemTokenProvider.java b/src/main/java/com/goi/erp/token/SystemTokenProvider.java new file mode 100644 index 0000000..810d2ae --- /dev/null +++ b/src/main/java/com/goi/erp/token/SystemTokenProvider.java @@ -0,0 +1,61 @@ +package com.goi.erp.token; + +import lombok.RequiredArgsConstructor; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class SystemTokenProvider { + + private final RestTemplate restTemplate; + + @Value("${auth.api.base-url}") + private String authBaseUrl; + + @Value("${application.security.system.client-id}") + private String clientId; + + @Value("${application.security.system.client-secret}") + private String clientSecret; + + private volatile String cachedToken; + private volatile long expiresAt; + + public String getToken() { + long now = System.currentTimeMillis(); + + if (cachedToken != null && now < expiresAt - 30_000) { + return cachedToken; + } + + synchronized (this) { + if (cachedToken != null && now < expiresAt - 30_000) { + return cachedToken; + } + + Map body = Map.of( + "clientId", clientId, + "clientSecret", clientSecret + ); + + @SuppressWarnings("unchecked") + Map response = + restTemplate.postForObject( + authBaseUrl + "/auth/authenticate/system", + body, + Map.class + ); + + cachedToken = (String) response.get("access_token"); + // expiresAt는 jwt exp 파싱하거나, 고정 TTL 쓰면 됨 + expiresAt = now + 10 * 60 * 1000; + + return cachedToken; + } + } +}