[INSPECTION]
- Ingest inspection raw data via opr-rest-api
This commit is contained in:
parent
777cb5bd13
commit
26d05d35f0
8
pom.xml
8
pom.xml
|
|
@ -41,10 +41,6 @@
|
||||||
</profiles>
|
</profiles>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<!-- spring -->
|
<!-- spring -->
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-web</artifactId>
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
|
@ -106,6 +102,10 @@
|
||||||
<version>${allure.version}</version>
|
<version>${allure.version}</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package com.goi.integration;
|
||||||
|
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
public class IntegrationServiceApplication {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(IntegrationServiceApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
package com.goi.integration.common.config;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class InternalWebClientFactory {
|
||||||
|
|
||||||
|
public WebClient create(String baseUrl, String token) {
|
||||||
|
return WebClient.builder()
|
||||||
|
.baseUrl(baseUrl)
|
||||||
|
.defaultHeader("X-INTERNAL-SERVICE", "integration-service")
|
||||||
|
.defaultHeader("X-INTERNAL-TOKEN", token)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
package com.goi.integration.common.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class ExtIngestResult {
|
||||||
|
private String source;
|
||||||
|
private String recordType;
|
||||||
|
private int received;
|
||||||
|
private int inserted;
|
||||||
|
private int updated;
|
||||||
|
private int skipped;
|
||||||
|
|
||||||
|
public static ExtIngestResult empty(String source, String recordType) {
|
||||||
|
return ExtIngestResult.builder()
|
||||||
|
.source(source)
|
||||||
|
.recordType(recordType)
|
||||||
|
.received(0)
|
||||||
|
.inserted(0)
|
||||||
|
.updated(0)
|
||||||
|
.skipped(0)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
package com.goi.integration.common.util;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.format.DateTimeParseException;
|
||||||
|
|
||||||
|
public final class DateTimeUtil {
|
||||||
|
|
||||||
|
private DateTimeUtil() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JsonNode → LocalDateTime
|
||||||
|
* (ISO-8601, Z / offset 지원)
|
||||||
|
*/
|
||||||
|
public static LocalDateTime parse(JsonNode node) {
|
||||||
|
if (node == null || node.isMissingNode() || node.isNull()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parse(node.asText(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* String → LocalDateTime
|
||||||
|
* ex) 2025-12-22T13:30:24.365Z
|
||||||
|
* ex) 2025-12-22T13:30:24+00:00
|
||||||
|
*/
|
||||||
|
public static LocalDateTime parse(String value) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return OffsetDateTime.parse(value).toLocalDateTime();
|
||||||
|
} catch (DateTimeParseException e) {
|
||||||
|
return null; // ingest 안정성 우선
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LocalDateTime parseToToronto(String value) {
|
||||||
|
if (value == null || value.isBlank()) return null;
|
||||||
|
|
||||||
|
return OffsetDateTime
|
||||||
|
.parse(value)
|
||||||
|
.atZoneSameInstant(ZoneId.of("America/Toronto"))
|
||||||
|
.toLocalDateTime();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
package com.goi.integration.common.util;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.util.TreeMap;
|
||||||
|
|
||||||
|
public final class ExtPayloadHashUtil {
|
||||||
|
|
||||||
|
private static final ObjectMapper mapper = new ObjectMapper();
|
||||||
|
|
||||||
|
private ExtPayloadHashUtil() {}
|
||||||
|
|
||||||
|
/** record(JsonNode) → canonical hash */
|
||||||
|
public static String sha256FromNode(JsonNode node) {
|
||||||
|
try {
|
||||||
|
JsonNode normalized = normalize(node);
|
||||||
|
String canonicalJson = mapper.writeValueAsString(normalized);
|
||||||
|
return sha256(canonicalJson);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to hash payload", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** raw JSON string → hash (fallback 용) */
|
||||||
|
public static String sha256FromJson(String rawJson) {
|
||||||
|
try {
|
||||||
|
JsonNode node = mapper.readTree(rawJson);
|
||||||
|
return sha256FromNode(node);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to hash raw json", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- internal ---------- */
|
||||||
|
|
||||||
|
@SuppressWarnings({ "serial", "deprecation" })
|
||||||
|
private static JsonNode normalize(JsonNode node) {
|
||||||
|
if (node.isObject()) {
|
||||||
|
ObjectNode obj = mapper.createObjectNode();
|
||||||
|
new TreeMap<String, JsonNode>() {{
|
||||||
|
node.fields().forEachRemaining(e -> put(e.getKey(), normalize(e.getValue())));
|
||||||
|
}}.forEach(obj::set);
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
if (node.isArray()) {
|
||||||
|
ArrayNode arr = mapper.createArrayNode();
|
||||||
|
node.forEach(n -> arr.add(normalize(n)));
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String sha256(String value) {
|
||||||
|
try {
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8));
|
||||||
|
StringBuilder hex = new StringBuilder();
|
||||||
|
for (byte b : hash) hex.append(String.format("%02x", b));
|
||||||
|
return hex.toString();
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String sha256FromJsonNode(JsonNode node) {
|
||||||
|
try {
|
||||||
|
JsonNode normalized = normalize(node);
|
||||||
|
String canonicalJson = mapper.writeValueAsString(normalized);
|
||||||
|
return sha256(canonicalJson);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to hash JsonNode payload", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
package com.goi.integration.samsara.client;
|
||||||
|
|
||||||
|
import com.goi.integration.common.dto.ExtIngestResult;
|
||||||
|
import com.goi.integration.samsara.dto.ExtSamsaraInspectionIngestCommand;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class OprIngestClient {
|
||||||
|
|
||||||
|
private static final String INTERNAL_TOKEN_HEADER = "X-INTERNAL-TOKEN";
|
||||||
|
|
||||||
|
@Qualifier("oprWebClient")
|
||||||
|
private final WebClient oprWebClient;
|
||||||
|
|
||||||
|
@Value("${ext.opr.internal-token}")
|
||||||
|
private String token;
|
||||||
|
|
||||||
|
// @Autowired
|
||||||
|
// private ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public ExtIngestResult ingestInspection(ExtSamsaraInspectionIngestCommand command) {
|
||||||
|
// String json = objectMapper
|
||||||
|
// .writerWithDefaultPrettyPrinter()
|
||||||
|
// .writeValueAsString(command);
|
||||||
|
//
|
||||||
|
log.info(
|
||||||
|
"[OPR_INGEST][REQUEST] source={}, type={}, records={}, fetchedAt={}",
|
||||||
|
command.getSource(),
|
||||||
|
command.getRecordType(),
|
||||||
|
command.getRecords() != null ? command.getRecords().size() : 0,
|
||||||
|
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")
|
||||||
|
.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][SUCCESS] received={}, inserted={}, updated={}, skipped={}",
|
||||||
|
result.getReceived(),
|
||||||
|
result.getInserted(),
|
||||||
|
result.getUpdated(),
|
||||||
|
result.getSkipped()
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 실패 로그
|
||||||
|
log.error(
|
||||||
|
"[OPR_INGEST][FAILED] source={}, type={}, error={}",
|
||||||
|
command.getSource(),
|
||||||
|
command.getRecordType(),
|
||||||
|
e.getMessage(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
package com.goi.integration.samsara.client;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class SamsaraClient {
|
||||||
|
|
||||||
|
private final WebClient webClient;
|
||||||
|
|
||||||
|
public SamsaraClient(
|
||||||
|
@Value("${ext.samsara.base-url:https://api.samsara.com}") String baseUrl,
|
||||||
|
@Value("${ext.samsara.api-token}") String apiToken
|
||||||
|
) {
|
||||||
|
this.webClient = WebClient.builder()
|
||||||
|
.baseUrl(baseUrl)
|
||||||
|
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + apiToken)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DVIR history raw JSON (string)로 받아도 되고, DTO로 매핑해도 됨.
|
||||||
|
* 일단 초기엔 String으로 받아서 JsonNode로 파싱하는게 가장 유연함.
|
||||||
|
*/
|
||||||
|
public String getDvirHistory(int limit, Instant startTime, Instant endTime) {
|
||||||
|
return webClient.get()
|
||||||
|
.uri(uriBuilder -> uriBuilder
|
||||||
|
.path("/fleet/dvirs/history")
|
||||||
|
.queryParam("limit", limit)
|
||||||
|
.queryParam("startTime", startTime.toString())
|
||||||
|
.queryParam("endTime", endTime.toString())
|
||||||
|
.build())
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(String.class)
|
||||||
|
.block();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
package com.goi.integration.samsara.config;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class OprClientConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public WebClient oprWebClient(
|
||||||
|
WebClient.Builder builder,
|
||||||
|
@Value("${ext.opr.base-url}") String baseUrl
|
||||||
|
) {
|
||||||
|
return builder
|
||||||
|
.baseUrl(baseUrl)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
package com.goi.integration.samsara.controller;
|
||||||
|
|
||||||
|
import com.goi.integration.samsara.job.SamsaraDvirIngestJob;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/internal/test")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SamsaraTestController {
|
||||||
|
|
||||||
|
private final SamsaraDvirIngestJob dvirJob;
|
||||||
|
|
||||||
|
@PostMapping("/samsara-dvir")
|
||||||
|
public String runDvirNow() {
|
||||||
|
dvirJob.run();
|
||||||
|
return "SAMSARA_DVIR_TRIGGERED";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
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 ExtSamsaraInspectionIngestCommand {
|
||||||
|
private String source;
|
||||||
|
private String recordType;
|
||||||
|
private LocalDateTime fetchedAt;
|
||||||
|
private List<ExtSamsaraInspectionRecordDto> records;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
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 ExtSamsaraInspectionRecordDto {
|
||||||
|
private String externalId; // inspection id
|
||||||
|
private String vehicleExternalId; // vehicle.id
|
||||||
|
private String driverExternalId; // signatoryUser.id
|
||||||
|
private String inspectionType; // preTrip / postTrip
|
||||||
|
private LocalDateTime startTime;
|
||||||
|
private LocalDateTime endTime;
|
||||||
|
private LocalDateTime signedAt;
|
||||||
|
private String payloadHash; // SHA-256
|
||||||
|
private JsonNode payloadJson; // raw inspection JSON
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
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.SamsaraInspectionIngestService;
|
||||||
|
|
||||||
|
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 SamsaraDvirIngestJob {
|
||||||
|
|
||||||
|
private static final String JOB_CODE = "SAMSARA_DVIR";
|
||||||
|
|
||||||
|
private final ScheduleJobConfigProvider configProvider;
|
||||||
|
private final SamsaraClient samsaraClient;
|
||||||
|
private final SamsaraInspectionIngestService 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
package com.goi.integration.samsara.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
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.ExtSamsaraInspectionIngestCommand;
|
||||||
|
import com.goi.integration.samsara.dto.ExtSamsaraInspectionRecordDto;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SamsaraInspectionIngestService {
|
||||||
|
|
||||||
|
private static final String JOB_CODE = "SAMSARA_DVIR";
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final OprIngestClient oprIngestClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Samsara DVIR raw JSON 응답을 받아
|
||||||
|
* 1) record 단위로 분해
|
||||||
|
* 2) hash 생성
|
||||||
|
* 3) opr-rest-api ingest endpoint 호출
|
||||||
|
*/
|
||||||
|
public ExtIngestResult ingestFromRawJson(String rawJson) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 전체 JSON 파싱
|
||||||
|
JsonNode root = objectMapper.readTree(rawJson);
|
||||||
|
JsonNode data = root.path("data");
|
||||||
|
|
||||||
|
// data[] 없음
|
||||||
|
if (!data.isArray()) {
|
||||||
|
log.warn("Samsara DVIR response has no data[] array");
|
||||||
|
return ExtIngestResult.empty("SAMSARA", "DVIR");
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
List<ExtSamsaraInspectionRecordDto> records = new ArrayList<>();
|
||||||
|
log.info("[{}] data={}", JOB_CODE, data.size());
|
||||||
|
|
||||||
|
// data[] 각 node 가 하나의 inspection (preTrip / postTrip)
|
||||||
|
for (JsonNode node : data) {
|
||||||
|
// parsing
|
||||||
|
String externalId = node.path("id").asText(null);
|
||||||
|
String vehicleExtId = node.path("vehicle").path("id").asText(null);
|
||||||
|
String driverExtId = node.path("authorSignature")
|
||||||
|
.path("signatoryUser")
|
||||||
|
.path("id")
|
||||||
|
.asText(null);
|
||||||
|
String inspectionType = node.path("type").asText(null);
|
||||||
|
LocalDateTime startTime = DateTimeUtil.parseToToronto(node.path("startTime").asText(null));
|
||||||
|
LocalDateTime endTime = DateTimeUtil.parseToToronto(node.path("endTime").asText(null));
|
||||||
|
LocalDateTime signedAt = DateTimeUtil.parseToToronto(
|
||||||
|
node.path("authorSignature").path("signedAtTime").asText(null)
|
||||||
|
);
|
||||||
|
// record 단위 hash 생성 (opr-rest-api 에서 idempotent ingest 판단용)
|
||||||
|
String hash = ExtPayloadHashUtil.sha256FromJsonNode(node);
|
||||||
|
|
||||||
|
// record DTO
|
||||||
|
records.add(
|
||||||
|
ExtSamsaraInspectionRecordDto.builder()
|
||||||
|
.externalId(externalId)
|
||||||
|
.vehicleExternalId(vehicleExtId)
|
||||||
|
.driverExternalId(driverExtId)
|
||||||
|
.inspectionType(inspectionType)
|
||||||
|
.startTime(startTime)
|
||||||
|
.endTime(endTime)
|
||||||
|
.signedAt(signedAt)
|
||||||
|
.payloadJson(node) // 원본 payload (JSON 그대로 저장)
|
||||||
|
.payloadHash(hash)
|
||||||
|
.build()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ingest command 생성
|
||||||
|
ExtSamsaraInspectionIngestCommand command =
|
||||||
|
ExtSamsaraInspectionIngestCommand.builder()
|
||||||
|
.source("SAMSARA")
|
||||||
|
.recordType("DVIR")
|
||||||
|
.fetchedAt(LocalDateTime.now())
|
||||||
|
.records(records)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// opr-rest-api ingest endpoint 호출
|
||||||
|
return oprIngestClient.ingestInspection(command);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to ingest samsara DVIR payload", e);
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,5 +14,39 @@ management:
|
||||||
exposure:
|
exposure:
|
||||||
include: health
|
include: health
|
||||||
server:
|
server:
|
||||||
|
port: 8091
|
||||||
servlet:
|
servlet:
|
||||||
context-path: /integration-service
|
context-path: /integration-service
|
||||||
|
# ============================
|
||||||
|
# External Integrations
|
||||||
|
# ============================
|
||||||
|
ext:
|
||||||
|
opr:
|
||||||
|
base-url: http://localhost:8083/opr-rest-api # opr-rest-api 주소
|
||||||
|
ingest-path: /ext/samsara/inspections/ingest
|
||||||
|
internal-token: ${OPR_INTERNAL_TOKEN}
|
||||||
|
hcm:
|
||||||
|
base-url: http://localhost:8081/hcm-rest-api
|
||||||
|
internal-token: ${HCM_INTERNAL_TOKEN}
|
||||||
|
acc:
|
||||||
|
base-url: http://localhost:8084/acc-rest-api
|
||||||
|
internal-token: ${ACC_INTERNAL_TOKEN}
|
||||||
|
samsara:
|
||||||
|
base-url: https://api.samsara.com
|
||||||
|
api-token: ${SAMSARA_API_TOKEN} # 반드시 env 로
|
||||||
|
timeout:
|
||||||
|
connect-ms: 5000
|
||||||
|
read-ms: 10000
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
dvir:
|
||||||
|
cron: "0 */10 * * * *" # 10분
|
||||||
|
# ============================
|
||||||
|
# logging
|
||||||
|
# ============================
|
||||||
|
logging:
|
||||||
|
file:
|
||||||
|
name: logs/integration-service.log
|
||||||
|
level:
|
||||||
|
root: INFO
|
||||||
|
com.goi.integration: INFO
|
||||||
Loading…
Reference in New Issue