[Scheduler] Implemented inspection, gps, odometer, safety-event

This commit is contained in:
Hyojin Ahn 2026-01-20 08:17:31 -05:00
parent 6a7882a028
commit 17164458a9
36 changed files with 2858 additions and 63 deletions

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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();
}
}

View File

@ -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<ExtSamsaraEngineSecondsRecordDto> records;
}

View File

@ -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;
}

View File

@ -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<ExtSamsaraOdometerRecordDto> records;
}

View File

@ -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;
}

View File

@ -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<ExtSamsaraSafetyEventRecordDto> records;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<String, Map<String, String>> config;
private List<String> vehicleExternalIds;
}

View File

@ -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();
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<ExtSamsaraRawEngineSeconds, Long> {
/**
* 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<ExtSamsaraRawEngineSeconds>
findByEsreVehicleExtIdAndEsreFetchedDateAndEsreProcessedFalse(
String esreVehicleExtId,
LocalDate esreFetchedDate
);
/**
* 날짜 기준, 아직 처리되지 않은 vehicleExternalIds
*/
@Query("""
select distinct r.esreVehicleExtId
from ExtSamsaraRawEngineSeconds r
where r.esreFetchedDate = :date
and r.esreProcessed = false
""")
List<String> findDistinctVehicleExtIdByDateAndUnprocessed(
@Param("date") LocalDate date
);
/**
* 아직 처리되지 않은 raw engine seconds (날짜 기준)
* stat process scheduler
*/
List<ExtSamsaraRawEngineSeconds>
findByEsreProcessedFalseAndEsreFetchedDate(
LocalDate esreFetchedDate
);
/**
* 특정 차량의 engine seconds snapshot 이력 (디버깅/분석용)
*/
List<ExtSamsaraRawEngineSeconds>
findByEsreVehicleExtIdOrderByEsreSampleTimeAsc(
String esreVehicleExtId
);
/**
* 특정 차량의 가장 최근 engine seconds snapshot
* (리셋 감지 / 기준값 확인용)
*/
Optional<ExtSamsaraRawEngineSeconds>
findTopByEsreVehicleExtIdOrderByEsreSampleTimeDesc(
String esreVehicleExtId
);
}

View File

@ -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<ExtSamsaraRawOdometer, Long> {
/* =====================================================
* 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<String> 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<ExtSamsaraRawOdometer> findUnprocessedByVehicleExtIdsAndDate(
@Param("vehicleExtIds") List<String> vehicleExtIds,
@Param("date") LocalDate date
);
/* =====================================================
* DEBUG / ANALYSIS
* ===================================================== */
List<ExtSamsaraRawOdometer>
findByEsroVehicleExtIdOrderByEsroSampleTimeAsc(
String esroVehicleExtId
);
Optional<ExtSamsaraRawOdometer>
findTopByEsroVehicleExtIdOrderByEsroSampleTimeDesc(
String esroVehicleExtId
);
@Query("""
SELECT r
FROM ExtSamsaraRawOdometer r
WHERE r.esroVehicleExtId = :vehicleExtId
AND r.esroProcessed = false
AND r.esroFetchedDate = :date
""")
List<ExtSamsaraRawOdometer> findByVehicleExtIdAndDateAndUnprocessed(
@Param("vehicleExtId") String vehicleExtId,
@Param("date") LocalDate date
);
}

View File

@ -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<ExtSamsaraRawSafetyEvent, Long> {
/**
* 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<ExtSamsaraRawSafetyEvent>
findByEsrsVehicleIdAndEsrsEventDateAndEsrsProcessedFalse(
String esrsVehicleId,
LocalDate esrsEventDate
);
/**
* 날짜 기준, 아직 처리되지 않은 vehicleIds
*/
@Query("""
select distinct r.esrsVehicleId
from ExtSamsaraRawSafetyEvent r
where r.esrsEventDate = :date
and r.esrsProcessed = false
""")
List<String> findDistinctVehicleIdByDateAndUnprocessed(
@Param("date") LocalDate date
);
/**
* 날짜 기준, 아직 처리되지 않은 safety events
* process scheduler
*/
List<ExtSamsaraRawSafetyEvent>
findByEsrsProcessedFalseAndEsrsEventDate(
LocalDate esrsEventDate
);
/**
* 특정 차량의 가장 최근 safety event
*/
Optional<ExtSamsaraRawSafetyEvent>
findTopByEsrsVehicleIdOrderByEsrsEventTimeDesc(
String esrsVehicleId
);
}

View File

@ -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<VehicleDispatchDailyStat, Long> {
Optional<VehicleDispatchDailyStat>
findByVddsDispatchIdAndVddsDate(
Long vddsDispatchId,
LocalDate vddsDate
);
}

View File

@ -14,77 +14,159 @@ import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface VehicleDispatchRepository extends JpaRepository<VehicleDispatch, Long> {
public interface VehicleDispatchRepository
extends JpaRepository<VehicleDispatch, Long> {
/* =====================================================
* BASIC
* ===================================================== */
List<VehicleDispatch> findByVedVehIdAndVedDispatchDate(
Long vedVehId,
LocalDate vedDispatchDate
);
List<VehicleDispatch> findByVedDriverIdAndVedDispatchDate(
Long vedDriverId,
LocalDate vedDispatchDate
);
List<VehicleDispatch> findByVedVehIdAndVedDispatchDate(Long vedVehId, LocalDate vedDispatchDate);
List<VehicleDispatch> findByVedDriverIdAndVedDispatchDate(Long vedDriverId, LocalDate vedDispatchDate);
List<VehicleDispatch> findByVedDispatchDate(LocalDate vedDispatchDate);
Page<VehicleDispatch> findAll(Pageable pageable);
Optional<VehicleDispatch> findByVedUuid(UUID vedUuid);
Optional<VehicleDispatch> findByVedUuid(UUID vedUuid);
//
@Query("""
SELECT d
FROM VehicleDispatch d
WHERE d.vedVehId = :vehId
AND d.vedDispatchDate = :dispatchDate
AND d.vedStatus <> 'C'
""")
Optional<VehicleDispatch> findOpenDispatchByVehId(Long vehId, LocalDate dispatchDate);
/* =====================================================
* STATUS BASED
* ===================================================== */
//
@Query("""
SELECT d
FROM VehicleDispatch d
WHERE d.vedDispatchDate = :dispatchDate
AND d.vedStatus <> 'C'
""")
List<VehicleDispatch> findAllNotClosed(LocalDate dispatchDate);
@Query("""
SELECT d
FROM VehicleDispatch d
WHERE d.vedVehId = :vehId
AND d.vedDispatchDate = :dispatchDate
AND d.vedStatus <> 'C'
""")
Optional<VehicleDispatch> findOpenDispatchByVehId(
@Param("vehId") Long vehId,
@Param("dispatchDate") LocalDate dispatchDate
);
//
@Query("""
SELECT d
FROM VehicleDispatch d
WHERE d.vedStatus = 'P'
AND d.vedPausedAt <= :threshold
""")
List<VehicleDispatch> findPausedBefore(LocalDateTime threshold);
@Query("""
SELECT d
FROM VehicleDispatch d
WHERE d.vedDispatchDate = :dispatchDate
AND d.vedStatus <> 'C'
""")
List<VehicleDispatch> findAllNotClosed(
@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.vedStatus = 'P'
AND d.vedPausedAt <= :threshold
""")
List<VehicleDispatch> findPausedBefore(
@Param("threshold") LocalDateTime threshold
);
//
default Integer findNextShift(Long vehId, LocalDate date) {
Integer max = findMaxShift(vehId, date);
return max == null ? 0 : max + 1;
}
/* =====================================================
* SHIFT
* ===================================================== */
@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 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<VehicleDispatch> 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<Long> findDispatchIdsByVehicleIds(
@Param("vehicleIds") List<Long> 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<Long> 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<VehicleDispatch> findAutoClosedCandidatesForReopen(
@Param("dispatchDate") LocalDate dispatchDate,
@Param("closeReason") String closeReason,
@Param("closedAfter") LocalDateTime closedAfter
Optional<String> findVehicleExternalIdByDispatchId(
@Param("dispatchId") Long dispatchId
);
}

View File

@ -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;
@ -22,4 +23,16 @@ public interface VehicleExternalMapRepository extends JpaRepository<VehicleExter
String vexStatus
);
@Query("""
SELECT m.vexVehicleId
FROM VehicleExternalMap m
WHERE m.vexSolutionType = :solutionType
AND m.vexExternalId IN :externalIds
AND m.vexStatus = 'A'
""")
List<Long> findVehicleIdsByExternalIds(
String solutionType,
List<String> externalIds
);
}

View File

@ -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<VehicleSafetyEvent, Long> {
/**
* 특정 dispatch + 날짜 기준 safety event 목록
* (daily stat 재계산 / 디버깅용)
*/
List<VehicleSafetyEvent> findByVseDispatchIdAndVseEventDate(Long vseDispatchId, LocalDate vseEventDate);
/**
* 특정 dispatch safety event 전체
*/
List<VehicleSafetyEvent> findByVseDispatchId(Long vseDispatchId);
/**
* 특정 차량 + 날짜 기준 safety event
* (dispatch 매핑 검증 / 분석용)
*/
List<VehicleSafetyEvent> findByVseVehicleIdAndVseEventDate(Long vseVehicleId, LocalDate vseEventDate);
/**
* 특정 source safety event
* (SAMSARA / MANUAL 구분용)
*/
List<VehicleSafetyEvent> findByVseSource(String vseSource);
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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<String> 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<Long> 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<Long> 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();
}
}

View File

@ -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<VehicleDispatch> targets =
vehicleDispatchRepository.findAllNotClosed(dispatchDate);
if (targets.isEmpty()) {
log.info("[ENGINE_SECONDS_RAW] no dispatch to process");
return ScheduleWorkerResponseDto.successEmpty();
}
// 2. internal vehicleId 수집
List<Long> 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<Long, String> vehicleIdToExternalId =
vehicleExternalMapService.findExternalIdsByVehicleIds(
"SAMSARA",
vehicleIds
);
List<String> 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();
}
}
}

View File

@ -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<String> vehicleExtIds =
rawRepository.findDistinctVehicleExtIdByDateAndUnprocessed(statDate);
if (vehicleExtIds.isEmpty()) {
log.info("[ODOMETER_PROCESS] no raw to process date={}", statDate);
return ScheduleWorkerResponseDto.successEmpty();
}
/* =====================================================
* 2. externalId vehicleId
* ===================================================== */
List<Long> vehicleIds =
vehicleExternalMapRepository.findVehicleIdsByExternalIds(
"SAMSARA",
vehicleExtIds
);
if (vehicleIds.isEmpty()) {
log.info("[ODOMETER_PROCESS] no vehicle mapping date={}", statDate);
return ScheduleWorkerResponseDto.successEmpty();
}
/* =====================================================
* 3. vehicleId dispatchId
* ===================================================== */
List<Long> 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();
}
}

View File

@ -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<VehicleDispatch> targets = vehicleDispatchRepository.findAllNotClosed(dispatchDate);
if (targets.isEmpty()) {
log.info("[ODOMETER_RAW] no dispatch to process");
return ScheduleWorkerResponseDto.successEmpty();
}
// 2. internal vehicleId 수집
List<Long> 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<Long, String> vehicleIdToExternalId =
vehicleExternalMapService.findExternalIdsByVehicleIds(
"SAMSARA",
vehicleIds
);
List<String> 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();
}
}
}

View File

@ -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<String> 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<Long> 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<Long> 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<ExtSamsaraRawSafetyEvent> 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<String> 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;
}
}
}

View File

@ -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<VehicleDispatch> targets =
vehicleDispatchRepository.findAllNotClosed(dispatchDate);
if (targets.isEmpty()) {
log.info("[ENGINE_SECONDS_RAW] no dispatch to process");
return ScheduleWorkerResponseDto.successEmpty();
}
// 2. internal vehicleId 수집
List<Long> 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<Long, String> vehicleIdToExternalId =
vehicleExternalMapService.findExternalIdsByVehicleIds(
"SAMSARA",
vehicleIds
);
List<String> 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();
}
}
}

View File

@ -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<VehicleDispatchDailyStat> 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<ExtSamsaraRawOdometer> 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<ExtSamsaraRawEngineSeconds> 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()
);
}
}

View File

@ -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<String, String> body = Map.of(
"clientId", clientId,
"clientSecret", clientSecret
);
@SuppressWarnings("unchecked")
Map<String, Object> 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;
}
}
}