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

This commit is contained in:
Hyojin Ahn 2026-01-20 08:16:46 -05:00
parent dc10f8b1d7
commit e1d17b7af7
21 changed files with 792 additions and 321 deletions

View File

@ -1,7 +1,8 @@
package com.goi.integration.samsara.client;
import com.goi.integration.common.dto.ExtIngestResult;
import com.goi.integration.samsara.dto.ExtInspectionIngestCommand;
import com.goi.integration.samsara.dto.VehicleInspectionIngestCommand;
import com.goi.integration.samsara.dto.VehicleStatOdometerCommand;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -27,11 +28,7 @@ public class OprIngestClient {
// @Autowired
// private ObjectMapper objectMapper;
public ExtIngestResult ingestInspection(ExtInspectionIngestCommand command) {
// String json = objectMapper
// .writerWithDefaultPrettyPrinter()
// .writeValueAsString(command);
//
public ExtIngestResult ingestInspection(VehicleInspectionIngestCommand command) {
log.info(
"[OPR_INGEST][REQUEST] source={}, type={}, fetchedAt={}",
command.getSource(),
@ -39,19 +36,6 @@ public class OprIngestClient {
command.getFetchedAt()
);
/*
try {
log.debug(
"[OPR_INGEST][REQUEST_BODY]\n{}",
new com.fasterxml.jackson.databind.ObjectMapper()
.writerWithDefaultPrettyPrinter()
.writeValueAsString(command)
);
} catch (Exception e) {
log.debug("[OPR_INGEST][REQUEST_BODY] failed to serialize", e);
}
*/
try {
ExtIngestResult result = oprWebClient.post()
.uri("/ext/samsara/inspections/ingest")
@ -97,4 +81,63 @@ public class OprIngestClient {
throw e;
}
}
/* =========================
* Odometer ingest
* ========================= */
public ExtIngestResult ingestVehicleOdometer(
VehicleStatOdometerCommand command
) {
log.info(
"[OPR_INGEST][ODOMETER][REQUEST] source={}, records={}, fetchedAt={}",
command.getSource(),
command.getRecords() != null ? command.getRecords().size() : 0,
command.getFetchedAt()
);
try {
ExtIngestResult result = oprWebClient.post()
.uri("/ext/samsara/odometer/ingest")
.header(INTERNAL_TOKEN_HEADER, token)
.bodyValue(command)
.retrieve()
.onStatus(
status -> status.is4xxClientError() || status.is5xxServerError(),
resp -> resp.bodyToMono(String.class)
.map(body -> {
if (resp.statusCode().value() == 403) {
return new IllegalStateException(
"OPR_AUTH_FAILED: " + body
);
}
return new RuntimeException(
"OPR_INGEST_HTTP_ERROR: " +
resp.statusCode() + " body=" + body
);
})
)
.bodyToMono(ExtIngestResult.class)
.block();
log.info(
"[OPR_INGEST][ODOMETER][SUCCESS] received={}, inserted={}, updated={}, skipped={}",
result.getReceived(),
result.getInserted(),
result.getUpdated(),
result.getSkipped()
);
return result;
} catch (Exception e) {
log.error(
"[OPR_INGEST][ODOMETER][FAILED] source={}, error={}",
command.getSource(),
e.getMessage(),
e
);
throw e;
}
}
}

View File

@ -77,7 +77,32 @@ public class SamsaraClient {
.queryParam("vehicleIds", vehicleIdsParam)
.queryParam("startTime", startTime.toString())
.queryParam("endTime", endTime.toString())
.queryParam("types", "obdOdometerMeters")
.queryParam("types", "obdOdometerMeters,gpsOdometerMeters")
.build())
.retrieve()
.bodyToMono(String.class)
.block();
}
/**
* Vehicle odometer history (OBD odometer meters)
* Raw JSON 반환
*/
public String getVehicleEngineSeconds(
List<String> vehicleExternalIds,
Instant startTime,
Instant endTime
) {
String vehicleIdsParam = String.join(",", vehicleExternalIds);
return webClient.get()
.uri(uriBuilder -> uriBuilder
.path("/fleet/vehicles/stats/history")
.queryParam("vehicleIds", vehicleIdsParam)
.queryParam("startTime", startTime.toString())
.queryParam("endTime", endTime.toString())
.queryParam("types", "obdEngineSeconds")
.build())
.retrieve()
.bodyToMono(String.class)
@ -89,7 +114,7 @@ public class SamsaraClient {
* Raw JSON 반환
*
*/
public String getVehicleSafetyEvents(Instant startTime, Instant endTime, List<String> vehicleExternalIds) {
public String getVehicleSafetyEvents(List<String> vehicleExternalIds, Instant startTime, Instant endTime) {
String vehicleIdsParam = String.join(",", vehicleExternalIds);

View File

@ -7,7 +7,7 @@ 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 com.goi.integration.samsara.service.VehicleInspectionIngestWorker;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -20,7 +20,7 @@ import lombok.extern.slf4j.Slf4j;
@Slf4j
public class InspectionWorkerController {
private final InspectionIngestWorker inspectionIngestWorker;
private final VehicleInspectionIngestWorker inspectionIngestWorker;
@PostMapping("/worker")
public ScheduleWorkerResponseDto runDvir(

View File

@ -1,15 +1,17 @@
package com.goi.integration.samsara.controller;
import com.goi.integration.samsara.dto.VehicleOdometerHistoryResponseDto;
import com.goi.integration.samsara.dto.ScheduleWorkerRequestDto;
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.VehicleOdometerService;
import com.goi.integration.samsara.dto.VehicleStatEngineSecondsCommand;
import com.goi.integration.samsara.dto.VehicleStatOdometerCommand;
import com.goi.integration.samsara.dto.VehicleStatSafetyEventCommand;
import com.goi.integration.samsara.service.VehicleGpsService;
import com.goi.integration.samsara.service.VehicleStatEngineSecondsService;
import com.goi.integration.samsara.service.VehicleStatOdometerService;
import com.goi.integration.samsara.service.VehicleStatSafetyEventService;
import lombok.RequiredArgsConstructor;
import java.time.Instant;
import java.util.List;
import org.springframework.web.bind.annotation.*;
@ -20,8 +22,9 @@ import org.springframework.web.bind.annotation.*;
public class VehicleController {
private final VehicleGpsService vehicleStatService;
private final VehicleOdometerHistoryService vehicleOdometerHistoryService;
private final VehicleOdometerService vehicleOdometerService;
private final VehicleStatOdometerService statOdometerService;
private final VehicleStatEngineSecondsService statEngineSecondsService;
private final VehicleStatSafetyEventService statSafetyEventService;
@GetMapping("/stat/gps")
public List<VehicleGpsResponseDto> getGps(
@ -30,42 +33,34 @@ public class VehicleController {
return vehicleStatService.getVehicleGps(vehicleIds);
}
/**
* Vehicle odometer history summary (window-based)
* odometer fetch 리턴
*/
@GetMapping("/stat/odometer/history")
public List<VehicleOdometerHistoryResponseDto> getOdometerHistory(
@RequestParam List<String> vehicleIds,
@RequestParam Instant startTime,
@RequestParam Instant endTime
@PostMapping("/stat/odometer/fetch")
public VehicleStatOdometerCommand fetchStatOdometer(
@RequestBody ScheduleWorkerRequestDto request
) {
return vehicleOdometerHistoryService.getOdometerHistory(
vehicleIds, startTime, endTime
);
return statOdometerService.fetchCommand(request);
}
/**
* Vehicle odometer raw fetch (for opr-rest-api ingest)
* odometer fetch 리턴
*/
@PostMapping("/odometer/fetch")
public VehicleOdometerCommand fetchOdometer(
@RequestParam List<String> vehicleIds,
@RequestParam Instant startTime,
@RequestParam Instant endTime
@PostMapping("/stat/engine-seconds/fetch")
public VehicleStatEngineSecondsCommand fetchStatEngineSeconds(
@RequestBody ScheduleWorkerRequestDto request
) {
return vehicleOdometerService.fetchOdometerCommand(
vehicleIds,
startTime,
endTime
);
return statEngineSecondsService.fetchCommand(request);
}
// @GetMapping("/safety/events")
// public List<VehicleSafetyEventResponseDto> getSafetyEvents(
// @RequestParam Instant startTime,
// @RequestParam Instant endTime,
// @RequestParam List<String> vehicleIds
// ) {
// return vehicleSafetyEventService.getSafetyEvents(vehicleIds, startTime, endTime);
// }
/**
* odometer fetch 리턴
*/
@PostMapping("/stat/safety-event/fetch")
public VehicleStatSafetyEventCommand fetchStatSafetyEvent(
@RequestBody ScheduleWorkerRequestDto request
) {
return statSafetyEventService.fetchCommand(request);
}
}

View File

@ -6,6 +6,7 @@ import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@Data
@ -19,4 +20,6 @@ public class ScheduleWorkerRequestDto {
private LocalDateTime to;
private Integer maxRecords;
private Map<String, Map<String, String>> config;
private List<String> vehicleExternalIds;
}

View File

@ -12,8 +12,8 @@ import java.util.List;
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class VehicleOdometerCommand {
public class VehicleInspectionIngestCommand {
private String source;
private LocalDateTime fetchedAt;
private List<VehicleOdometerRecordDto> records;
private List<VehicleInspectionRecordDto> records;
}

View File

@ -13,7 +13,7 @@ import com.fasterxml.jackson.databind.JsonNode;
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ExtInspectionRecordDto {
public class VehicleInspectionRecordDto {
private String externalId; // inspection id
private String vehicleExternalId; // vehicle.id
private String driverExternalId; // signatoryUser.id

View File

@ -1,21 +0,0 @@
package com.goi.integration.samsara.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class VehicleOdometerHistoryResponseDto {
private String vohVehicleExternalId;
private Instant vohFirstSampleTime;
private Instant vohLastSampleTime;
private Long vohFirstOdometerMeters;
private Long vohLastOdometerMeters;
private Integer vohSampleCount;
}

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 VehicleStatEngineSecondsCommand {
private String source;
private LocalDateTime fetchedAt;
private List<VehicleStatEngineSecondsRecordDto> 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.Instant;
import com.fasterxml.jackson.databind.JsonNode;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class VehicleStatEngineSecondsRecordDto {
private String vehicleExternalId;
private Instant sampleTime;
private Long engineSeconds;
private String payloadHash;
private JsonNode payloadJson;
}

View File

@ -12,8 +12,8 @@ import java.util.List;
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ExtInspectionIngestCommand {
public class VehicleStatOdometerCommand {
private String source;
private LocalDateTime fetchedAt;
private List<ExtInspectionRecordDto> records;
private List<VehicleStatOdometerRecordDto> records;
}

View File

@ -5,7 +5,7 @@ import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.time.Instant;
import com.fasterxml.jackson.databind.JsonNode;
@ -13,10 +13,10 @@ import com.fasterxml.jackson.databind.JsonNode;
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class VehicleOdometerRecordDto {
private String source; // SAMSARA
public class VehicleStatOdometerRecordDto {
private String vehicleExternalId; // samsara vehicle.id
private LocalDateTime sampleTime; // snapshot time (UTC)
private Instant sampleTime; // snapshot time (UTC)
private String odometerType; // "OBD" | "GPS"
private Long odometerMeters; // cumulative meters
private String payloadHash; // idempotent hash
private JsonNode payloadJson; // minimal raw payload

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 VehicleStatSafetyEventCommand {
private String source;
private LocalDateTime fetchedAt;
private List<VehicleStatSafetyEventRecordDto> records;
}

View File

@ -0,0 +1,31 @@
package com.goi.integration.samsara.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 VehicleStatSafetyEventRecordDto {
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

@ -6,8 +6,8 @@ import com.goi.integration.common.dto.ExtIngestResult;
import com.goi.integration.common.util.DateTimeUtil;
import com.goi.integration.common.util.ExtPayloadHashUtil;
import com.goi.integration.samsara.client.OprIngestClient;
import com.goi.integration.samsara.dto.ExtInspectionIngestCommand;
import com.goi.integration.samsara.dto.ExtInspectionRecordDto;
import com.goi.integration.samsara.dto.VehicleInspectionIngestCommand;
import com.goi.integration.samsara.dto.VehicleInspectionRecordDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@ -19,7 +19,7 @@ import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class InspectionIngestService {
public class VehicleInspectionIngestService {
private final ObjectMapper objectMapper;
private final OprIngestClient oprIngestClient;
@ -44,7 +44,7 @@ public class InspectionIngestService {
}
//
List<ExtInspectionRecordDto> records = new ArrayList<>();
List<VehicleInspectionRecordDto> records = new ArrayList<>();
log.info("inspection data size={}", data.size());
// data[] node 하나의 inspection (preTrip / postTrip)
@ -67,7 +67,7 @@ public class InspectionIngestService {
// record DTO
records.add(
ExtInspectionRecordDto.builder()
VehicleInspectionRecordDto.builder()
.externalId(externalId)
.vehicleExternalId(vehicleExtId)
.driverExternalId(driverExtId)
@ -82,8 +82,8 @@ public class InspectionIngestService {
}
// ingest command 생성
ExtInspectionIngestCommand command =
ExtInspectionIngestCommand.builder()
VehicleInspectionIngestCommand command =
VehicleInspectionIngestCommand.builder()
.source("SAMSARA")
.fetchedAt(LocalDateTime.now())
.records(records)

View File

@ -15,10 +15,10 @@ import java.time.ZoneOffset;
@Slf4j
@Component
@RequiredArgsConstructor
public class InspectionIngestWorker {
public class VehicleInspectionIngestWorker {
private final SamsaraClient samsaraClient;
private final InspectionIngestService ingestService;
private final VehicleInspectionIngestService ingestService;
public ScheduleWorkerResponseDto execute(ScheduleWorkerRequestDto request) {

View File

@ -1,99 +0,0 @@
package com.goi.integration.samsara.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.goi.integration.samsara.client.SamsaraClient;
import com.goi.integration.samsara.dto.VehicleOdometerHistoryResponseDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class VehicleOdometerHistoryService {
private final SamsaraClient samsaraClient;
private final ObjectMapper objectMapper = new ObjectMapper();
public List<VehicleOdometerHistoryResponseDto> getOdometerHistory(
List<String> vehicleExternalIds,
Instant startTime,
Instant endTime
) {
// api 호출
String rawJson = samsaraClient.getVehicleOdometerHistory(vehicleExternalIds, startTime, endTime);
return parseOdometerHistory(rawJson);
}
private List<VehicleOdometerHistoryResponseDto> parseOdometerHistory(String rawJson) {
List<VehicleOdometerHistoryResponseDto> results = new ArrayList<>();
try {
JsonNode root = objectMapper.readTree(rawJson);
JsonNode dataArray = root.path("data");
for (JsonNode vehicleNode : dataArray) {
String vehicleId = vehicleNode.path("id").asText();
JsonNode odoArray = vehicleNode.path("obdOdometerMeters");
if (!odoArray.isArray() || odoArray.isEmpty()) {
// 데이터 없음
results.add(
VehicleOdometerHistoryResponseDto.builder()
.vohVehicleExternalId(vehicleId)
.vohSampleCount(0)
.build()
);
continue;
}
Iterator<JsonNode> it = odoArray.elements();
JsonNode first = it.next();
JsonNode last = first;
int count = 1;
while (it.hasNext()) {
last = it.next();
count++;
}
// 거리 계산은 opr 에서. 여기선 이전 누적치를 모름.
long firstValue = first.path("value").asLong();
long lastValue = last.path("value").asLong();
Instant firstTime = Instant.parse(first.path("time").asText());
Instant lastTime = Instant.parse(last.path("time").asText());
results.add(
VehicleOdometerHistoryResponseDto.builder()
.vohVehicleExternalId(vehicleId)
.vohFirstSampleTime(firstTime)
.vohLastSampleTime(lastTime)
.vohFirstOdometerMeters(firstValue)
.vohLastOdometerMeters(lastValue)
.vohSampleCount(count)
.build()
);
}
} catch (Exception e) {
log.error("[ODOMETER][PARSE_FAIL]", e);
throw new RuntimeException("Failed to parse vehicle odometer history", e);
}
return results;
}
}

View File

@ -1,125 +0,0 @@
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;
}
}

View File

@ -0,0 +1,181 @@
package com.goi.integration.samsara.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.goi.integration.common.util.ExtPayloadHashUtil;
import com.goi.integration.samsara.client.SamsaraClient;
import com.goi.integration.samsara.dto.ScheduleWorkerRequestDto;
import com.goi.integration.samsara.dto.VehicleStatEngineSecondsCommand;
import com.goi.integration.samsara.dto.VehicleStatEngineSecondsRecordDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
/**
* - Samsara API 호출
* - raw JSON 파싱
* - "사실 데이터" record 단위로 변환
*
* 판단 / 선택 / 병합 없음
* obdEngineSeconds 단일 타입
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class VehicleStatEngineSecondsService {
private final SamsaraClient samsaraClient;
private final ObjectMapper objectMapper;
/**
* Controller 진입점
*/
public VehicleStatEngineSecondsCommand fetchCommand(ScheduleWorkerRequestDto request) {
if (request.getVehicleExternalIds() == null || request.getVehicleExternalIds().isEmpty()) {
throw new IllegalArgumentException("vehicleExternalIds is required");
}
if (request.getFrom() == null || request.getTo() == null) {
throw new IllegalArgumentException("from/to is required");
}
Instant start = request.getFrom().toInstant(ZoneOffset.UTC);
Instant end = request.getTo().toInstant(ZoneOffset.UTC);
log.info(
"[{}][ENGINE_SECONDS][FETCH] start={} end={} vehicles={}",
request.getJobCode(), start, end, request.getVehicleExternalIds().size()
);
String rawJson = samsaraClient.getVehicleEngineSeconds(
request.getVehicleExternalIds(),
start,
end
);
VehicleStatEngineSecondsCommand command = buildCommandFromRawJson(rawJson);
log.info(
"[{}][ENGINE_SECONDS][FETCH_OK] records={} fetchedAt={}",
request.getJobCode(),
command.getRecords().size(),
command.getFetchedAt()
);
return command;
}
/**
* raw JSON records command
*
* response sample:
* {
* "data": [
* {
* "id": "...",
* "obdEngineSeconds": [{"time":"...Z","value":123}, ...]
* }, ...
* ]
* }
*/
private VehicleStatEngineSecondsCommand buildCommandFromRawJson(String rawJson) {
try {
JsonNode root = objectMapper.readTree(rawJson);
JsonNode dataArray = root.path("data");
if (!dataArray.isArray()) {
log.warn("[ENGINE_SECONDS] samsara response has no data[]");
return VehicleStatEngineSecondsCommand.builder()
.source("SAMSARA")
.fetchedAt(LocalDateTime.now())
.records(List.of())
.build();
}
List<VehicleStatEngineSecondsRecordDto> records = new ArrayList<>();
for (JsonNode vehicleNode : dataArray) {
String vehicleExtId = vehicleNode.path("id").asText(null);
if (vehicleExtId == null) {
continue;
}
// obdEngineSeconds samples only
parseSamples(
records,
vehicleExtId,
vehicleNode.path("obdEngineSeconds")
);
}
return VehicleStatEngineSecondsCommand.builder()
.source("SAMSARA")
.fetchedAt(LocalDateTime.now())
.records(records)
.build();
} catch (Exception e) {
log.error("[ENGINE_SECONDS][PARSE_FAIL]", e);
throw new RuntimeException("ENGINE_SECONDS_FETCH_PARSE_FAIL", e);
}
}
/**
* samplesArray: [{"time": "...Z", "value": 123}, ...]
*/
private void parseSamples(
List<VehicleStatEngineSecondsRecordDto> records,
String vehicleExtId,
JsonNode arrayNode
) {
if (arrayNode == null || !arrayNode.isArray() || arrayNode.isEmpty()) {
return;
}
for (JsonNode sample : arrayNode) {
String timeStr = sample.path("time").asText(null);
if (timeStr == null) {
continue;
}
Instant sampleTime;
try {
sampleTime = Instant.parse(timeStr);
} catch (Exception e) {
continue;
}
long engineSeconds = sample.path("value").asLong();
// 단일 타입이지만 해시 혹시 모를 확장을 위해 "obdEngineSeconds" 고정 문자열 포함 권장
String hash = ExtPayloadHashUtil.sha256FromStrings(
vehicleExtId,
timeStr,
"obdEngineSeconds",
String.valueOf(engineSeconds)
);
records.add(
VehicleStatEngineSecondsRecordDto.builder()
.vehicleExternalId(vehicleExtId)
.sampleTime(sampleTime)
.engineSeconds(engineSeconds)
.payloadHash(hash)
.payloadJson(sample) // 원본 sample 그대로
.build()
);
}
}
}

View File

@ -0,0 +1,189 @@
package com.goi.integration.samsara.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.goi.integration.common.util.ExtPayloadHashUtil;
import com.goi.integration.samsara.client.SamsaraClient;
import com.goi.integration.samsara.dto.ScheduleWorkerRequestDto;
import com.goi.integration.samsara.dto.VehicleStatOdometerCommand;
import com.goi.integration.samsara.dto.VehicleStatOdometerRecordDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
/**
* integration-service 책임:
* - Samsara API 호출
* - raw JSON 파싱
* - "사실 데이터" record 단위로 변환
*
* 판단 / 선택 / 병합 없음
* OBD / GPS type 으로 구분된 독립 record
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class VehicleStatOdometerService {
private final SamsaraClient samsaraClient;
private final ObjectMapper objectMapper;
/**
* Controller 진입점
*/
public VehicleStatOdometerCommand fetchCommand(ScheduleWorkerRequestDto request) {
if (request.getVehicleExternalIds() == null || request.getVehicleExternalIds().isEmpty()) {
throw new IllegalArgumentException("vehicleExternalIds is required");
}
if (request.getFrom() == null || request.getTo() == null) {
throw new IllegalArgumentException("from/to is required");
}
Instant start = request.getFrom().toInstant(ZoneOffset.UTC);
Instant end = request.getTo().toInstant(ZoneOffset.UTC);
log.info(
"[{}][ODOMETER][FETCH] start={} end={} vehicles={}",
request.getJobCode(), start, end, request.getVehicleExternalIds().size()
);
String rawJson = samsaraClient.getVehicleOdometerHistory(
request.getVehicleExternalIds(),
start,
end
);
VehicleStatOdometerCommand command = buildCommandFromRawJson(rawJson);
log.info(
"[{}][ODOMETER][FETCH_OK] records={} fetchedAt={}",
request.getJobCode(),
command.getRecords().size(),
command.getFetchedAt()
);
return command;
}
/**
* raw JSON records command
*
* - OBD / GPS 각각 독립 record
* - time 달라도 정상 처리
* - hash type 포함
*/
private VehicleStatOdometerCommand buildCommandFromRawJson(String rawJson) {
try {
JsonNode root = objectMapper.readTree(rawJson);
JsonNode dataArray = root.path("data");
if (!dataArray.isArray()) {
log.warn("[ODOMETER] samsara response has no data[]");
return VehicleStatOdometerCommand.builder()
.source("SAMSARA")
.fetchedAt(LocalDateTime.now())
.records(List.of())
.build();
}
List<VehicleStatOdometerRecordDto> records = new ArrayList<>();
for (JsonNode vehicleNode : dataArray) {
String vehicleExtId = vehicleNode.path("id").asText(null);
if (vehicleExtId == null) {
continue;
}
// OBD samples
parseSamples(
records,
vehicleExtId,
vehicleNode.path("obdOdometerMeters"),
"OBD"
);
// GPS samples
parseSamples(
records,
vehicleExtId,
vehicleNode.path("gpsOdometerMeters"),
"GPS"
);
}
return VehicleStatOdometerCommand.builder()
.source("SAMSARA")
.fetchedAt(LocalDateTime.now())
.records(records)
.build();
} catch (Exception e) {
log.error("[ODOMETER][PARSE_FAIL]", e);
throw new RuntimeException("ODOMETER_FETCH_PARSE_FAIL", e);
}
}
/**
* samplesArray: [{"time": "...Z", "value": 123}, ...]
* type: "OBD" | "GPS"
*/
private void parseSamples(
List<VehicleStatOdometerRecordDto> records,
String vehicleExtId,
JsonNode arrayNode,
String type
) {
if (arrayNode == null || !arrayNode.isArray() || arrayNode.isEmpty()) {
return;
}
for (JsonNode sample : arrayNode) {
String timeStr = sample.path("time").asText(null);
if (timeStr == null) {
continue;
}
Instant sampleTime;
try {
sampleTime = Instant.parse(timeStr);
} catch (Exception e) {
// invalid timestamp skip
continue;
}
long meters = sample.path("value").asLong();
// type 포함 hash (idempotent)
String hash = ExtPayloadHashUtil.sha256FromStrings(
vehicleExtId,
timeStr,
type,
String.valueOf(meters)
);
records.add(
VehicleStatOdometerRecordDto.builder()
.vehicleExternalId(vehicleExtId)
.sampleTime(sampleTime)
.odometerType(type) // "OBD" or "GPS"
.odometerMeters(meters)
.payloadHash(hash)
.payloadJson(sample) // 원본 sample 그대로
.build()
);
}
}
}

View File

@ -0,0 +1,189 @@
package com.goi.integration.samsara.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.goi.integration.samsara.client.SamsaraClient;
import com.goi.integration.samsara.dto.ScheduleWorkerRequestDto;
import com.goi.integration.samsara.dto.VehicleStatSafetyEventCommand;
import com.goi.integration.samsara.dto.VehicleStatSafetyEventRecordDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
/**
* - Samsara /fleet/safety-events API 호출
* - raw JSON 파싱
* - safety event 단위 record 생성
*
* 판단 / 병합 / dispatch 매핑 없음
* 사실 데이터 그대로 전달
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class VehicleStatSafetyEventService {
private final SamsaraClient samsaraClient;
private final ObjectMapper objectMapper;
/**
* Controller 진입점
*/
public VehicleStatSafetyEventCommand fetchCommand(ScheduleWorkerRequestDto request) {
if (request.getVehicleExternalIds() == null || request.getVehicleExternalIds().isEmpty()) {
throw new IllegalArgumentException("vehicleExternalIds is required");
}
if (request.getFrom() == null || request.getTo() == null) {
throw new IllegalArgumentException("from/to is required");
}
Instant start = request.getFrom().toInstant(ZoneOffset.UTC);
Instant end = request.getTo().toInstant(ZoneOffset.UTC);
log.info(
"[{}][SAFETY_EVENT][FETCH] start={} end={} vehicles={}",
request.getJobCode(), start, end, request.getVehicleExternalIds().size()
);
String rawJson = samsaraClient.getVehicleSafetyEvents(
request.getVehicleExternalIds(),
start,
end
);
VehicleStatSafetyEventCommand command = buildCommandFromRawJson(rawJson);
log.info(
"[{}][SAFETY_EVENT][FETCH_OK] records={} fetchedAt={}",
request.getJobCode(),
command.getRecords().size(),
command.getFetchedAt()
);
return command;
}
/**
* raw JSON records command
*
* response sample:
* {
* "data": [
* {
* "id": "...",
* "vehicle": { "id": "..." },
* "driver": { "id": "..." },
* "time": "...Z",
* "location": { "latitude": 0, "longitude": 0 },
* "coachingState": "...",
* "maxAccelerationGForce": 0.58,
* "behaviorLabels": [...]
* }
* ]
* }
*/
private VehicleStatSafetyEventCommand buildCommandFromRawJson(String rawJson) {
try {
JsonNode root = objectMapper.readTree(rawJson);
JsonNode dataArray = root.path("data");
if (!dataArray.isArray()) {
log.warn("[SAFETY_EVENT] samsara response has no data[]");
return VehicleStatSafetyEventCommand.builder()
.source("SAMSARA")
.fetchedAt(LocalDateTime.now())
.records(List.of())
.build();
}
List<VehicleStatSafetyEventRecordDto> records = new ArrayList<>();
for (JsonNode eventNode : dataArray) {
String eventId = eventNode.path("id").asText(null);
if (eventId == null) {
continue;
}
JsonNode vehicleNode = eventNode.path("vehicle");
String vehicleId = vehicleNode.path("id").asText(null);
if (vehicleId == null) {
continue;
}
JsonNode driverNode = eventNode.path("driver");
String driverId = driverNode.isMissingNode()
? null
: driverNode.path("id").asText(null);
String timeStr = eventNode.path("time").asText(null);
if (timeStr == null) {
continue;
}
Instant eventTime;
try {
eventTime = Instant.parse(timeStr);
} catch (Exception e) {
continue;
}
JsonNode locationNode = eventNode.path("location");
records.add(
VehicleStatSafetyEventRecordDto.builder()
.eventId(eventId)
.vehicleId(vehicleId)
.driverId(driverId)
.eventTime(eventTime)
.eventDate(eventTime.atZone(ZoneOffset.UTC).toLocalDate())
.coachingState(eventNode.path("coachingState").asText(null))
.maxAccelerationG(
eventNode.hasNonNull("maxAccelerationGForce")
? eventNode.get("maxAccelerationGForce").asDouble()
: null
)
.latitude(
locationNode.hasNonNull("latitude")
? locationNode.get("latitude").asDouble()
: null
)
.longitude(
locationNode.hasNonNull("longitude")
? locationNode.get("longitude").asDouble()
: null
)
.videoForwardUrl(
eventNode.path("downloadForwardVideoUrl").asText(null)
)
.videoInwardUrl(
eventNode.path("downloadInwardVideoUrl").asText(null)
)
.behaviorLabels(eventNode.path("behaviorLabels"))
.rawPayload(eventNode)
.build()
);
}
return VehicleStatSafetyEventCommand.builder()
.source("SAMSARA")
.fetchedAt(LocalDateTime.now())
.records(records)
.build();
} catch (Exception e) {
log.error("[SAFETY_EVENT][PARSE_FAIL]", e);
throw new RuntimeException("SAFETY_EVENT_FETCH_PARSE_FAIL", e);
}
}
}