[Schedule]

- Removed schedule (schedules are managed in sys-rest-api)
[Odometer]
- Created call Samsara to get odometer
This commit is contained in:
Hyojin Ahn 2026-01-14 13:40:58 -05:00
parent ce146cd41a
commit dc10f8b1d7
19 changed files with 377 additions and 150 deletions

View File

@ -1,29 +0,0 @@
package com.goi.integration.common.config;
import com.goi.integration.common.config.ScheduleJobConfigDto;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.Optional;
@Component
public class InMemoryScheduleJobConfigProvider implements ScheduleJobConfigProvider {
private final Map<String, ScheduleJobConfigDto> configs = Map.of(
"SAMSARA_DVIR",
ScheduleJobConfigDto.builder()
.sjcJobCode("SAMSARA_DVIR")
.sjcEnabled(true)
.sjcCronExpression("0 */10 * * * *")
.sjcLookbackHours(24)
.sjcOverlapMinutes(10)
.sjcMaxRecords(252)
.sjcTimezone("UTC")
.build()
);
@Override
public Optional<ScheduleJobConfigDto> getJobConfig(String jobCode) {
return Optional.ofNullable(configs.get(jobCode));
}
}

View File

@ -1,20 +0,0 @@
package com.goi.integration.common.config;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ScheduleJobConfigDto {
private String sjcJobCode; // SAMSARA_DVIR
private String sjcCronExpression; // "0 */10 * * * *"
private Integer sjcLookbackHours; // 24
private Integer sjcOverlapMinutes;// 10
private Integer sjcMaxRecords; // 252
private Boolean sjcEnabled; // true
private String sjcTimezone; // "UTC"
}

View File

@ -1,10 +0,0 @@
package com.goi.integration.common.config;
import com.goi.integration.common.config.ScheduleJobConfigDto;
import java.util.Optional;
public interface ScheduleJobConfigProvider {
Optional<ScheduleJobConfigDto> getJobConfig(String jobCode);
}

View File

@ -11,16 +11,14 @@ import lombok.NoArgsConstructor;
@Builder @Builder
public class ExtIngestResult { public class ExtIngestResult {
private String source; private String source;
private String recordType;
private int received; private int received;
private int inserted; private int inserted;
private int updated; private int updated;
private int skipped; private int skipped;
public static ExtIngestResult empty(String source, String recordType) { public static ExtIngestResult empty(String source) {
return ExtIngestResult.builder() return ExtIngestResult.builder()
.source(source) .source(source)
.recordType(recordType)
.received(0) .received(0)
.inserted(0) .inserted(0)
.updated(0) .updated(0)

View File

@ -39,6 +39,37 @@ public final class DateTimeUtil {
} }
} }
/**
* JsonNode LocalDateTime (UTC)
*/
public static LocalDateTime parseToUTC(JsonNode node) {
if (node == null || node.isMissingNode() || node.isNull()) {
return null;
}
return parseToUTC(node.asText(null));
}
/**
* String LocalDateTime (UTC)
* ex) 2025-12-22T13:30:24.365Z
* ex) 2025-12-22T13:30:24+00:00
* ex) 2025-12-22T08:30:24-05:00
*/
public static LocalDateTime parseToUTC(String value) {
if (value == null || value.isBlank()) {
return null;
}
try {
return OffsetDateTime
.parse(value)
.atZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime();
} catch (DateTimeParseException e) {
return null; // ingest 안정성 우선
}
}
public static LocalDateTime parseToToronto(String value) { public static LocalDateTime parseToToronto(String value) {
if (value == null || value.isBlank()) return null; if (value == null || value.isBlank()) return null;

View File

@ -36,6 +36,25 @@ public final class ExtPayloadHashUtil {
} }
} }
/**
* 문자열 조합 기반 SHA-256
* (odometer snapshot idempotent / 변경 감지용)
*/
public static String sha256FromStrings(String... values) {
try {
StringBuilder sb = new StringBuilder();
for (String v : values) {
if (v != null) {
sb.append(v);
}
sb.append('|'); // 구분자
}
return sha256(sb.toString());
} catch (Exception e) {
throw new RuntimeException("Failed to hash from strings", e);
}
}
/* ---------- internal ---------- */ /* ---------- internal ---------- */
@SuppressWarnings({ "serial", "deprecation" }) @SuppressWarnings({ "serial", "deprecation" })

View File

@ -33,9 +33,8 @@ public class OprIngestClient {
// .writeValueAsString(command); // .writeValueAsString(command);
// //
log.info( log.info(
"[OPR_INGEST][REQUEST] source={}, type={}, records={}, fetchedAt={}", "[OPR_INGEST][REQUEST] source={}, type={}, fetchedAt={}",
command.getSource(), command.getSource(),
command.getRecordType(),
command.getRecords() != null ? command.getRecords().size() : 0, command.getRecords() != null ? command.getRecords().size() : 0,
command.getFetchedAt() command.getFetchedAt()
); );
@ -90,9 +89,8 @@ public class OprIngestClient {
} catch (Exception e) { } catch (Exception e) {
// 실패 로그 // 실패 로그
log.error( log.error(
"[OPR_INGEST][FAILED] source={}, type={}, error={}", "[OPR_INGEST][FAILED] source={}, error={}",
command.getSource(), command.getSource(),
command.getRecordType(),
e.getMessage(), e.getMessage(),
e e
); );

View File

@ -0,0 +1,31 @@
package com.goi.integration.samsara.controller;
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;
import com.goi.integration.samsara.dto.ScheduleWorkerRequestDto;
import com.goi.integration.samsara.dto.ScheduleWorkerResponseDto;
import com.goi.integration.samsara.service.InspectionIngestWorker;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@RestController
@RequestMapping("/samsara/inspection")
@RequiredArgsConstructor
@Slf4j
public class InspectionWorkerController {
private final InspectionIngestWorker inspectionIngestWorker;
@PostMapping("/worker")
public ScheduleWorkerResponseDto runDvir(
@RequestBody ScheduleWorkerRequestDto request
) {
return inspectionIngestWorker.execute(request);
}
}

View File

@ -1,19 +0,0 @@
package com.goi.integration.samsara.controller;
import com.goi.integration.samsara.job.DvirIngestJob;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/internal/test")
@RequiredArgsConstructor
public class SamsaraTestController {
private final DvirIngestJob dvirJob;
@PostMapping("/samsara-dvir")
public String runDvirNow() {
dvirJob.run();
return "SAMSARA_DVIR_TRIGGERED";
}
}

View File

@ -2,7 +2,9 @@ package com.goi.integration.samsara.controller;
import com.goi.integration.samsara.dto.VehicleOdometerHistoryResponseDto; import com.goi.integration.samsara.dto.VehicleOdometerHistoryResponseDto;
import com.goi.integration.samsara.dto.VehicleGpsResponseDto; import com.goi.integration.samsara.dto.VehicleGpsResponseDto;
import com.goi.integration.samsara.dto.VehicleOdometerCommand;
import com.goi.integration.samsara.service.VehicleOdometerHistoryService; import com.goi.integration.samsara.service.VehicleOdometerHistoryService;
import com.goi.integration.samsara.service.VehicleOdometerService;
import com.goi.integration.samsara.service.VehicleGpsService; import com.goi.integration.samsara.service.VehicleGpsService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -19,6 +21,7 @@ public class VehicleController {
private final VehicleGpsService vehicleStatService; private final VehicleGpsService vehicleStatService;
private final VehicleOdometerHistoryService vehicleOdometerHistoryService; private final VehicleOdometerHistoryService vehicleOdometerHistoryService;
private final VehicleOdometerService vehicleOdometerService;
@GetMapping("/stat/gps") @GetMapping("/stat/gps")
public List<VehicleGpsResponseDto> getGps( public List<VehicleGpsResponseDto> getGps(
@ -41,6 +44,22 @@ public class VehicleController {
); );
} }
/**
* Vehicle odometer raw fetch (for opr-rest-api ingest)
*/
@PostMapping("/odometer/fetch")
public VehicleOdometerCommand fetchOdometer(
@RequestParam List<String> vehicleIds,
@RequestParam Instant startTime,
@RequestParam Instant endTime
) {
return vehicleOdometerService.fetchOdometerCommand(
vehicleIds,
startTime,
endTime
);
}
// @GetMapping("/safety/events") // @GetMapping("/safety/events")
// public List<VehicleSafetyEventResponseDto> getSafetyEvents( // public List<VehicleSafetyEventResponseDto> getSafetyEvents(
// @RequestParam Instant startTime, // @RequestParam Instant startTime,

View File

@ -14,7 +14,6 @@ import java.util.List;
@Builder @Builder
public class ExtInspectionIngestCommand { public class ExtInspectionIngestCommand {
private String source; private String source;
private String recordType;
private LocalDateTime fetchedAt; private LocalDateTime fetchedAt;
private List<ExtInspectionRecordDto> records; private List<ExtInspectionRecordDto> records;
} }

View File

@ -0,0 +1,22 @@
package com.goi.integration.samsara.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
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;
}

View File

@ -0,0 +1,22 @@
package com.goi.integration.samsara.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;
}

View File

@ -0,0 +1,19 @@
package com.goi.integration.samsara.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 VehicleOdometerCommand {
private String source;
private LocalDateTime fetchedAt;
private List<VehicleOdometerRecordDto> records;
}

View File

@ -0,0 +1,23 @@
package com.goi.integration.samsara.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import com.fasterxml.jackson.databind.JsonNode;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class VehicleOdometerRecordDto {
private String source; // SAMSARA
private String vehicleExternalId; // samsara vehicle.id
private LocalDateTime sampleTime; // snapshot time (UTC)
private Long odometerMeters; // cumulative meters
private String payloadHash; // idempotent hash
private JsonNode payloadJson; // minimal raw payload
}

View File

@ -1,59 +0,0 @@
package com.goi.integration.samsara.job;
import com.goi.integration.common.config.ScheduleJobConfigDto;
import com.goi.integration.common.config.ScheduleJobConfigProvider;
import com.goi.integration.common.dto.ExtIngestResult;
import com.goi.integration.samsara.client.SamsaraClient;
import com.goi.integration.samsara.service.InspectionIngestService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.Instant;
@Slf4j
@Component
@RequiredArgsConstructor
public class DvirIngestJob {
private static final String JOB_CODE = "SAMSARA_DVIR";
private final ScheduleJobConfigProvider configProvider;
private final SamsaraClient samsaraClient;
private final InspectionIngestService ingestService;
@Scheduled(cron = "${ext.samsara.jobs.dvir.cron:0 */10 * * * *}")
public void run() {
ScheduleJobConfigDto cfg = configProvider.getJobConfig(JOB_CODE)
.filter(ScheduleJobConfigDto::getSjcEnabled)
.orElse(null);
if (cfg == null) {
log.info("[{}] disabled or config missing", JOB_CODE);
return;
}
int limit = (cfg.getSjcMaxRecords() != null) ? cfg.getSjcMaxRecords() : 252;
Instant now = Instant.now();
long lookbackSec = Duration.ofHours(cfg.getSjcLookbackHours() != null ? cfg.getSjcLookbackHours() : 24).getSeconds();
long overlapSec = Duration.ofMinutes(cfg.getSjcOverlapMinutes() != null ? cfg.getSjcOverlapMinutes() : 0).getSeconds();
Instant start = now.minusSeconds(lookbackSec + overlapSec);
Instant end = now;
log.info("[{}] calling samsara dvir history start={} end={} limit={}", JOB_CODE, start, end, limit);
String rawJson = samsaraClient.getDvirHistory(limit, start, end);
// opr-rest-api ingest 호출
ExtIngestResult result = ingestService.ingestFromRawJson(rawJson);
log.info("[{}] ingest result received={}, inserted={}, updated={}, skipped={}",
JOB_CODE, result.getReceived(), result.getInserted(), result.getUpdated(), result.getSkipped());
}
}

View File

@ -21,8 +21,6 @@ import java.util.List;
@RequiredArgsConstructor @RequiredArgsConstructor
public class InspectionIngestService { public class InspectionIngestService {
private static final String JOB_CODE = "SAMSARA_DVIR";
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final OprIngestClient oprIngestClient; private final OprIngestClient oprIngestClient;
@ -42,12 +40,12 @@ public class InspectionIngestService {
// data[] 없음 // data[] 없음
if (!data.isArray()) { if (!data.isArray()) {
log.warn("Samsara DVIR response has no data[] array"); log.warn("Samsara DVIR response has no data[] array");
return ExtIngestResult.empty("SAMSARA", "DVIR"); return ExtIngestResult.empty("SAMSARA");
} }
// //
List<ExtInspectionRecordDto> records = new ArrayList<>(); List<ExtInspectionRecordDto> records = new ArrayList<>();
log.info("[{}] data={}", JOB_CODE, data.size()); log.info("inspection data size={}", data.size());
// data[] node 하나의 inspection (preTrip / postTrip) // data[] node 하나의 inspection (preTrip / postTrip)
for (JsonNode node : data) { for (JsonNode node : data) {
@ -87,7 +85,6 @@ public class InspectionIngestService {
ExtInspectionIngestCommand command = ExtInspectionIngestCommand command =
ExtInspectionIngestCommand.builder() ExtInspectionIngestCommand.builder()
.source("SAMSARA") .source("SAMSARA")
.recordType("DVIR")
.fetchedAt(LocalDateTime.now()) .fetchedAt(LocalDateTime.now())
.records(records) .records(records)
.build(); .build();

View File

@ -0,0 +1,61 @@
package com.goi.integration.samsara.service;
import com.goi.integration.common.dto.ExtIngestResult;
import com.goi.integration.samsara.client.SamsaraClient;
import com.goi.integration.samsara.dto.ScheduleWorkerRequestDto;
import com.goi.integration.samsara.dto.ScheduleWorkerResponseDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.time.ZoneOffset;
@Slf4j
@Component
@RequiredArgsConstructor
public class InspectionIngestWorker {
private final SamsaraClient samsaraClient;
private final InspectionIngestService ingestService;
public ScheduleWorkerResponseDto execute(ScheduleWorkerRequestDto request) {
Instant start = request.getFrom().toInstant(ZoneOffset.UTC);
Instant end = request.getTo().toInstant(ZoneOffset.UTC);
int limit = request.getMaxRecords() != null ? request.getMaxRecords() : 252;
log.info("[{}] worker start={} end={} limit={}", request.getJobCode(), start, end, limit);
try {
String rawJson = samsaraClient.getDvirHistory(limit, start, end);
ExtIngestResult result = ingestService.ingestFromRawJson(rawJson);
log.info("[{}] ingest success received={}, inserted={}, updated={}, skipped={}",
request.getJobCode(),
result.getReceived(),
result.getInserted(),
result.getUpdated(),
result.getSkipped()
);
return ScheduleWorkerResponseDto.builder()
.success(true)
.processedCount(result.getReceived())
.successCount(result.getInserted() + result.getUpdated())
.failCount(result.getSkipped())
.build();
} catch (Exception e) {
log.error("[{}] ingest failed", request.getJobCode(), e);
return ScheduleWorkerResponseDto.builder()
.success(false)
.errorCode("INSPECTION_INGEST_ERROR")
.errorMessage(e.getMessage())
.build();
}
}
}

View File

@ -0,0 +1,125 @@
package com.goi.integration.samsara.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.goi.integration.common.util.DateTimeUtil;
import com.goi.integration.common.util.ExtPayloadHashUtil;
import com.goi.integration.samsara.client.SamsaraClient;
import com.goi.integration.samsara.dto.VehicleOdometerCommand;
import com.goi.integration.samsara.dto.VehicleOdometerRecordDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class VehicleOdometerService {
private final SamsaraClient samsaraClient;
private final ObjectMapper objectMapper;
/**
* Fetch samsara odometer data and build command
*/
public VehicleOdometerCommand fetchOdometerCommand(
List<String> vehicleExternalIds,
Instant startTime,
Instant endTime
) {
String rawJson =
samsaraClient.getVehicleOdometerHistory(
vehicleExternalIds,
startTime,
endTime
);
List<VehicleOdometerRecordDto> records =
parseRawOdometer(rawJson);
return VehicleOdometerCommand.builder()
.source("SAMSARA")
.fetchedAt(LocalDateTime.now())
.records(records)
.build();
}
/**
* Parse samsara raw JSON odometer snapshot records
*/
private List<VehicleOdometerRecordDto> parseRawOdometer(String rawJson) {
List<VehicleOdometerRecordDto> records = new ArrayList<>();
try {
JsonNode root = objectMapper.readTree(rawJson);
JsonNode dataArray = root.path("data");
if (!dataArray.isArray()) {
log.warn("[ODOMETER] no data[] in samsara response");
return records;
}
for (JsonNode vehicleNode : dataArray) {
String vehicleExtId =
vehicleNode.path("id").asText(null);
JsonNode odoArray =
vehicleNode.path("obdOdometerMeters");
if (!odoArray.isArray() || odoArray.isEmpty()) {
continue;
}
for (JsonNode sample : odoArray) {
String timeStr =
sample.path("time").asText(null);
if (timeStr == null) {
continue;
}
LocalDateTime sampleTime =
DateTimeUtil.parseToUTC(timeStr);
long meters =
sample.path("value").asLong();
String hash =
ExtPayloadHashUtil.sha256FromStrings(
vehicleExtId,
timeStr,
String.valueOf(meters)
);
records.add(
VehicleOdometerRecordDto.builder()
.source("SAMSARA")
.vehicleExternalId(vehicleExtId)
.sampleTime(sampleTime)
.odometerMeters(meters)
.payloadHash(hash)
.payloadJson(sample)
.build()
);
}
}
} catch (Exception e) {
log.error("[ODOMETER][PARSE_FAIL]", e);
throw new RuntimeException("Failed to parse samsara odometer payload", e);
}
return records;
}
}