diff --git a/pom.xml b/pom.xml
index 70ae13d..9d7099a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -75,6 +75,11 @@
spring-security-test
test
+
+ com.vladmihalcea
+ hibernate-types-60
+ 2.21.1
+
template
layered-architecture-template
diff --git a/src/main/java/com/goi/erp/common/util/DateTimeUtil.java b/src/main/java/com/goi/erp/common/util/DateTimeUtil.java
new file mode 100644
index 0000000..7ef1e52
--- /dev/null
+++ b/src/main/java/com/goi/erp/common/util/DateTimeUtil.java
@@ -0,0 +1,50 @@
+package com.goi.erp.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();
+ }
+}
diff --git a/src/main/java/com/goi/erp/config/InternalAuthFilter.java b/src/main/java/com/goi/erp/config/InternalAuthFilter.java
new file mode 100644
index 0000000..41e8c9d
--- /dev/null
+++ b/src/main/java/com/goi/erp/config/InternalAuthFilter.java
@@ -0,0 +1,37 @@
+package com.goi.erp.config;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+
+@Component
+public class InternalAuthFilter extends OncePerRequestFilter {
+
+ @Value("${internal.integration.token}")
+ private String expectedToken;
+
+ @Override
+ protected void doFilterInternal(
+ HttpServletRequest request,
+ HttpServletResponse response,
+ FilterChain filterChain
+ ) throws ServletException, IOException {
+
+ String token = request.getHeader("X-INTERNAL-TOKEN");
+
+ if (request.getRequestURI().startsWith("/ext/")) {
+ if (token == null || !token.equals(expectedToken)) {
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ return;
+ }
+ }
+
+ filterChain.doFilter(request, response);
+ }
+}
diff --git a/src/main/java/com/goi/erp/config/SecurityConfig.java b/src/main/java/com/goi/erp/config/SecurityConfig.java
index 098033f..8790862 100644
--- a/src/main/java/com/goi/erp/config/SecurityConfig.java
+++ b/src/main/java/com/goi/erp/config/SecurityConfig.java
@@ -32,6 +32,7 @@ public class SecurityConfig {
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 사용 안함
.authorizeHttpRequests(auth -> auth
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
+ .requestMatchers("/ext/**").permitAll()
.anyRequest().authenticated()
) // 요청 권한 설정
.addFilterBefore(new CorsFilter(corsConfigurationSource()), UsernamePasswordAuthenticationFilter.class) // JWT 필터 전에 CorsFilter 등록
diff --git a/src/main/java/com/goi/erp/controller/TestController.java b/src/main/java/com/goi/erp/controller/TestController.java
new file mode 100644
index 0000000..fd50c53
--- /dev/null
+++ b/src/main/java/com/goi/erp/controller/TestController.java
@@ -0,0 +1,23 @@
+package com.goi.erp.controller;
+
+import com.goi.erp.service.ExtSamsaraInspectionProcessor;
+
+import lombok.RequiredArgsConstructor;
+
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/test")
+@RequiredArgsConstructor
+public class TestController {
+
+ private final ExtSamsaraInspectionProcessor processor;
+
+ @PostMapping("/process-samsara-inspection")
+ public String process() {
+ processor.processUnprocessed();
+ return "OK";
+ }
+}
diff --git a/src/main/java/com/goi/erp/controller/VehicleDispatchController.java b/src/main/java/com/goi/erp/controller/VehicleDispatchController.java
new file mode 100644
index 0000000..e991a00
--- /dev/null
+++ b/src/main/java/com/goi/erp/controller/VehicleDispatchController.java
@@ -0,0 +1,260 @@
+package com.goi.erp.controller;
+
+import com.goi.erp.common.permission.PermissionSet;
+import com.goi.erp.dto.VehicleDispatchRequestDto;
+import com.goi.erp.entity.VehicleDispatch;
+import com.goi.erp.repository.VehicleDispatchRepository;
+import com.goi.erp.common.permission.PermissionChecker;
+import com.goi.erp.token.PermissionAuthenticationToken;
+import com.goi.erp.service.VehicleDispatchService;
+
+import lombok.RequiredArgsConstructor;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.web.bind.annotation.*;
+
+import java.math.BigDecimal;
+import java.time.Duration;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.UUID;
+
+@RestController
+@RequestMapping("/api/vehicle-dispatch")
+@RequiredArgsConstructor
+public class VehicleDispatchController {
+
+ private final VehicleDispatchService dispatchService;
+ private final VehicleDispatchRepository dispatchRepository;
+
+ // 기본값
+ private final int defaultPage = 0;
+ private final int defaultSize = 50;
+ private final int maxSize = 500;
+
+ /* ============================================================
+ CREATE (MANUAL)
+ ============================================================ */
+ @PostMapping("/manual")
+ public ResponseEntity createManualDispatch(
+ @RequestBody VehicleDispatchRequestDto requestDto
+ ) {
+ PermissionAuthenticationToken auth = (PermissionAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
+
+ if (auth == null || auth.getPermissionSet() == null) {
+ throw new AccessDeniedException("Permission information is missing");
+ }
+
+ PermissionSet permissionSet = auth.getPermissionSet();
+ if (!PermissionChecker.canCreateOPR(permissionSet)) {
+ throw new AccessDeniedException("You do not have permission to create dispatch");
+ }
+
+ VehicleDispatch created =
+ dispatchService.createManualDispatch(requestDto, auth.getName());
+
+ return new ResponseEntity<>(created, HttpStatus.CREATED);
+ }
+
+ /* ============================================================
+ PATCH (MANUAL)
+ ============================================================ */
+ @PatchMapping("/manual/{uuid}")
+ public ResponseEntity patchManualDispatch(
+ @PathVariable UUID uuid,
+ @RequestBody VehicleDispatchRequestDto requestDto
+ ) {
+ PermissionAuthenticationToken auth = (PermissionAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
+
+ if (auth == null || auth.getPermissionSet() == null) {
+ throw new AccessDeniedException("Permission information is missing");
+ }
+
+ PermissionSet permissionSet = auth.getPermissionSet();
+ if (!PermissionChecker.canUpdateOPR(permissionSet)) {
+ throw new AccessDeniedException("You do not have permission to update dispatch");
+ }
+
+ VehicleDispatch updated =
+ dispatchService.patchManualDispatch(uuid, requestDto, auth.getName());
+
+ return ResponseEntity.ok(updated);
+ }
+
+ /* ============================================================
+ STATE CHANGE (MANUAL)
+ ============================================================ */
+ @PostMapping("/{uuid}/pause")
+ public ResponseEntity pauseDispatch(
+ @PathVariable UUID uuid,
+ @RequestParam(required = false) LocalDateTime pausedAt
+ ) {
+ PermissionAuthenticationToken auth = (PermissionAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
+
+ if (auth == null || auth.getPermissionSet() == null) {
+ throw new AccessDeniedException("Permission information is missing");
+ }
+
+ PermissionSet permissionSet = auth.getPermissionSet();
+ if (!PermissionChecker.canUpdateOPR(permissionSet)) {
+ throw new AccessDeniedException("You do not have permission to update dispatch");
+ }
+
+ dispatchService.pauseDispatch(uuid, pausedAt);
+ return ResponseEntity.ok().build();
+ }
+
+ @PostMapping("/{uuid}/close")
+ public ResponseEntity closeDispatch(
+ @PathVariable UUID uuid,
+ @RequestParam(required = false) LocalDateTime endAt,
+ @RequestParam(required = false) BigDecimal endOdometerEnd, // DB: ved_odometer_end 로 저장
+ @RequestParam(required = false) String reason
+ ) {
+ PermissionAuthenticationToken auth =
+ (PermissionAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
+
+ if (auth == null || auth.getPermissionSet() == null) {
+ throw new AccessDeniedException("Permission information is missing");
+ }
+
+ PermissionSet permissionSet = auth.getPermissionSet();
+ if (!PermissionChecker.canUpdateOPR(permissionSet)) {
+ throw new AccessDeniedException("You do not have permission to update dispatch");
+ }
+
+ dispatchService.closeDispatch(
+ uuid,
+ endAt,
+ endOdometerEnd,
+ (reason == null || reason.isBlank()) ? "MANUAL_CLOSE" : reason
+ );
+
+ return ResponseEntity.ok().build();
+ }
+
+ /* ============================================================
+ READ ONE
+ ============================================================ */
+ @GetMapping("/{uuid}")
+ public ResponseEntity getOne(
+ @PathVariable UUID uuid
+ ) {
+ PermissionAuthenticationToken auth = (PermissionAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
+
+ if (auth == null || auth.getPermissionSet() == null) {
+ throw new AccessDeniedException("Permission information is missing");
+ }
+
+ PermissionSet permissionSet = auth.getPermissionSet();
+ if (!PermissionChecker.canReadOPRAll(permissionSet)) {
+ throw new AccessDeniedException("You do not have permission to read dispatch data");
+ }
+
+ return dispatchRepository.findByVedUuid(uuid)
+ .map(ResponseEntity::ok)
+ .orElse(ResponseEntity.notFound().build());
+ }
+
+ /* ============================================================
+ READ ALL (Paged)
+ ============================================================ */
+ @GetMapping
+ public ResponseEntity> getAll(
+ @RequestParam(required = false) Integer page,
+ @RequestParam(required = false) Integer size
+ ) {
+ PermissionAuthenticationToken auth = (PermissionAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
+
+ if (auth == null || auth.getPermissionSet() == null) {
+ throw new AccessDeniedException("Permission information is missing");
+ }
+
+ PermissionSet permissionSet = auth.getPermissionSet();
+ if (!PermissionChecker.canReadOPRAll(permissionSet)) {
+ throw new AccessDeniedException("You do not have permission to read dispatch data");
+ }
+
+ int p = (page == null) ? defaultPage : page;
+ int s = (size == null) ? defaultSize : size;
+ if (s > maxSize) s = maxSize;
+
+ Pageable pageable = PageRequest.of(p, s);
+ return ResponseEntity.ok(dispatchRepository.findAll(pageable));
+ }
+
+ /* ============================================================
+ READ BY DATE (가장 자주 쓰는 API)
+ - date만: 그날 전체
+ - date + vehId: 그 차량의 그날
+ - date + driverId: 그 기사 그날
+ ============================================================ */
+ @GetMapping("/by-date")
+ public ResponseEntity> getByDate(
+ @RequestParam LocalDate date,
+ @RequestParam(required = false) Long vehId,
+ @RequestParam(required = false) Long driverId
+ ) {
+ PermissionAuthenticationToken auth = (PermissionAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
+
+ if (auth == null || auth.getPermissionSet() == null) {
+ throw new AccessDeniedException("Permission information is missing");
+ }
+
+ PermissionSet permissionSet = auth.getPermissionSet();
+ if (!PermissionChecker.canReadOPRAll(permissionSet)) {
+ throw new AccessDeniedException("You do not have permission to read dispatch data");
+ }
+
+ if (vehId != null && driverId != null) {
+ // 둘 다 넣는 케이스가 있을까
+ throw new IllegalArgumentException("Use either vehId or driverId, not both");
+ }
+
+ if (vehId != null) {
+ return ResponseEntity.ok(
+ dispatchRepository.findByVedVehIdAndVedDispatchDate(vehId, date)
+ );
+ }
+
+ if (driverId != null) {
+ return ResponseEntity.ok(
+ dispatchRepository.findByVedDriverIdAndVedDispatchDate(driverId, date)
+ );
+ }
+
+ return ResponseEntity.ok(
+ dispatchRepository.findByVedDispatchDate(date)
+ );
+ }
+
+ /* ============================================================
+ JOB TRIGGER
+ - paused to closed
+ ============================================================ */
+ @PostMapping("/jobs/close-paused")
+ public ResponseEntity closePaused(
+ @RequestParam(defaultValue = "60") long minutes
+ ) {
+ PermissionAuthenticationToken auth = (PermissionAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
+
+ if (auth == null || auth.getPermissionSet() == null) {
+ throw new AccessDeniedException("Permission information is missing");
+ }
+
+ PermissionSet permissionSet = auth.getPermissionSet();
+ if (!PermissionChecker.canUpdateOPR(permissionSet)) {
+ throw new AccessDeniedException("You do not have permission to update dispatch");
+ }
+
+ dispatchService.closePausedDispatches(Duration.ofMinutes(minutes));
+ return ResponseEntity.ok().build();
+ }
+}
diff --git a/src/main/java/com/goi/erp/controller/VehicleExternalMapController.java b/src/main/java/com/goi/erp/controller/VehicleExternalMapController.java
new file mode 100644
index 0000000..8753bdb
--- /dev/null
+++ b/src/main/java/com/goi/erp/controller/VehicleExternalMapController.java
@@ -0,0 +1,33 @@
+package com.goi.erp.controller;
+
+import com.goi.erp.dto.VehicleExternalMapResponseDto;
+import com.goi.erp.service.VehicleExternalMapService;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/employee")
+@RequiredArgsConstructor
+public class VehicleExternalMapController {
+
+ private final VehicleExternalMapService externalMapService;
+
+ /**
+ * 외부 시스템의 employeeId → 내부 employee 매핑 조회
+ * 목적: customer_daily_order.driver_id 설정
+ *
+ * 예: GET /employee/external?solutionType=SAMSARA&externalId=28147...
+ */
+ @GetMapping("/external")
+ public ResponseEntity getVehicleMapping(
+ @RequestParam String solutionType,
+ @RequestParam String externalId
+ ) {
+ VehicleExternalMapResponseDto dto =
+ externalMapService.findMapping(solutionType, externalId);
+
+ return ResponseEntity.ok(dto);
+ }
+}
diff --git a/src/main/java/com/goi/erp/controller/ingest/ExtSamsaraInspectionIngestController.java b/src/main/java/com/goi/erp/controller/ingest/ExtSamsaraInspectionIngestController.java
new file mode 100644
index 0000000..cde05d1
--- /dev/null
+++ b/src/main/java/com/goi/erp/controller/ingest/ExtSamsaraInspectionIngestController.java
@@ -0,0 +1,58 @@
+package com.goi.erp.controller.ingest;
+
+import com.goi.erp.dto.ExtApiErrorResponse;
+import com.goi.erp.dto.ExtIngestResult;
+import com.goi.erp.dto.ExtSamsaraInspectionIngestCommand;
+import com.goi.erp.service.ExtSamsaraInspectionIngestService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import java.time.LocalDateTime;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+@Slf4j
+@RestController
+@RequestMapping("/ext/samsara/inspections")
+@RequiredArgsConstructor
+public class ExtSamsaraInspectionIngestController {
+
+ private final ExtSamsaraInspectionIngestService ingestService;
+
+ /**
+ * Samsara DVIR raw ingest endpoint
+ * Called by integration-service
+ */
+ @PostMapping("/ingest")
+ public ResponseEntity> ingest(
+ @RequestBody ExtSamsaraInspectionIngestCommand command
+ ) {
+ try {
+ ExtIngestResult result = ingestService.ingest(command);
+ return ResponseEntity.ok(result);
+
+ } catch (Exception e) {
+
+ log.error(
+ "[SAMSARA_DVIR][INGEST_ERROR] records={}, error={}",
+ command.getRecords() != null ? command.getRecords().size() : 0,
+ e.getMessage(),
+ e
+ );
+
+ return ResponseEntity
+ .status(HttpStatus.INTERNAL_SERVER_ERROR)
+ .body(
+ ExtApiErrorResponse.builder()
+ .service("opr-rest-api")
+ .errorCode("SAMSARA_DVIR_INGEST_FAILED")
+ .message("Failed to ingest Samsara DVIR data")
+ .detail(e.getMessage())
+ .timestamp(LocalDateTime.now())
+ .build()
+ );
+ }
+ }
+}
diff --git a/src/main/java/com/goi/erp/dto/ExtApiErrorResponse.java b/src/main/java/com/goi/erp/dto/ExtApiErrorResponse.java
new file mode 100644
index 0000000..7520f8f
--- /dev/null
+++ b/src/main/java/com/goi/erp/dto/ExtApiErrorResponse.java
@@ -0,0 +1,16 @@
+package com.goi.erp.dto;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+@Builder
+public class ExtApiErrorResponse {
+ private String service; // opr-rest-api
+ private String errorCode; // 예: INGEST_FAILED
+ private String message; // 사람이 읽는 메시지
+ private String detail; // stack / 원인
+ private LocalDateTime timestamp;
+}
diff --git a/src/main/java/com/goi/erp/dto/ExtIngestResult.java b/src/main/java/com/goi/erp/dto/ExtIngestResult.java
new file mode 100644
index 0000000..15568df
--- /dev/null
+++ b/src/main/java/com/goi/erp/dto/ExtIngestResult.java
@@ -0,0 +1,19 @@
+package com.goi.erp.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;
+}
diff --git a/src/main/java/com/goi/erp/dto/ExtSamsaraInspectionIngestCommand.java b/src/main/java/com/goi/erp/dto/ExtSamsaraInspectionIngestCommand.java
new file mode 100644
index 0000000..1c0fb90
--- /dev/null
+++ b/src/main/java/com/goi/erp/dto/ExtSamsaraInspectionIngestCommand.java
@@ -0,0 +1,20 @@
+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 ExtSamsaraInspectionIngestCommand {
+ private String source;
+ private String recordType;
+ private LocalDateTime fetchedAt;
+ private List records;
+}
diff --git a/src/main/java/com/goi/erp/dto/ExtSamsaraInspectionRecordDto.java b/src/main/java/com/goi/erp/dto/ExtSamsaraInspectionRecordDto.java
new file mode 100644
index 0000000..39423d7
--- /dev/null
+++ b/src/main/java/com/goi/erp/dto/ExtSamsaraInspectionRecordDto.java
@@ -0,0 +1,26 @@
+package com.goi.erp.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;
+ private String vehicleExternalId;
+ private String driverExternalId;
+ private String inspectionType;
+ private LocalDateTime startTime;
+ private LocalDateTime endTime;
+ private LocalDateTime signedAt;
+ private String payloadHash;
+ private JsonNode payloadJson;
+}
diff --git a/src/main/java/com/goi/erp/dto/VehicleDispatchRequestDto.java b/src/main/java/com/goi/erp/dto/VehicleDispatchRequestDto.java
new file mode 100644
index 0000000..748a15a
--- /dev/null
+++ b/src/main/java/com/goi/erp/dto/VehicleDispatchRequestDto.java
@@ -0,0 +1,35 @@
+package com.goi.erp.dto;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+@Data
+public class VehicleDispatchRequestDto {
+
+ private Long vedVehId;
+ private Long vedDriverId;
+ private Long vedSubDriverId;
+ private String vedExternalVehicleId; // SAMSARA vehicle id
+ private String vedExternalDriverId;
+ private LocalDate vedDispatchDate;
+ private Integer vedShift;
+ private String vedStatus;
+ private LocalDateTime vedStartAt;
+ private LocalDateTime vedPausedAt;
+ private LocalDateTime vedEndAt;
+ private String vedEndReason;
+ private BigDecimal vedOdometerStart;
+ private BigDecimal vedOdometerEnd;
+ private BigDecimal vedOdometerIncrement;
+ private String vedOdometerSource; // obd / gpsDistance / gpsOdometer
+ private String vedSource; // AUTO / MANUAL
+ private Integer vedEventCount;
+
+ // inspection 연계용 (AUTO 생성 시 사용)
+ private Long vedInspectionId;
+ private UUID vedInspectionUuid;
+}
diff --git a/src/main/java/com/goi/erp/dto/VehicleDispatchResponseDto.java b/src/main/java/com/goi/erp/dto/VehicleDispatchResponseDto.java
new file mode 100644
index 0000000..bb0d74e
--- /dev/null
+++ b/src/main/java/com/goi/erp/dto/VehicleDispatchResponseDto.java
@@ -0,0 +1,44 @@
+package com.goi.erp.dto;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+@Data
+@Builder
+public class VehicleDispatchResponseDto {
+
+ // 식별자
+ private UUID vedUuid;
+
+ // 차량 정보 (View용)
+ private String vehicleNo;
+ private String vehicleName;
+ private String licensePlate;
+
+ // 운전자 정보
+ private String driverName;
+ private String driverEmpNo;
+ private String subDriverName;
+
+ // 배차 정보
+ private String vedStatus;
+ private LocalDate vedDispatchDate;
+ private Integer vedShift;
+ private LocalDateTime vedStartAt;
+ private LocalDateTime vedPausedAt;
+ private LocalDateTime vedEndAt;
+ private String vedEndReason;
+
+ private BigDecimal vedOdometerStart;
+ private BigDecimal vedOdometerEnd;
+ private BigDecimal vedOdometerIncrement;
+ private String vedOdometerSource;
+
+ private Integer vedEventCount;
+ private String vedSource;
+}
diff --git a/src/main/java/com/goi/erp/dto/VehicleExternalMapRequestDto.java b/src/main/java/com/goi/erp/dto/VehicleExternalMapRequestDto.java
new file mode 100644
index 0000000..3705e94
--- /dev/null
+++ b/src/main/java/com/goi/erp/dto/VehicleExternalMapRequestDto.java
@@ -0,0 +1,11 @@
+package com.goi.erp.dto;
+
+import lombok.Data;
+
+@Data
+public class VehicleExternalMapRequestDto {
+ private Long vexVehicleId; // 매핑할 내부 vehicle ID
+ private String vexSolutionType; // 'SAMSARA', 'MIS', ...
+ private String vexExternalId; // 외부 ID
+ private String vexStatus; // A / I (옵션)
+}
diff --git a/src/main/java/com/goi/erp/dto/VehicleExternalMapResponseDto.java b/src/main/java/com/goi/erp/dto/VehicleExternalMapResponseDto.java
new file mode 100644
index 0000000..376a1ef
--- /dev/null
+++ b/src/main/java/com/goi/erp/dto/VehicleExternalMapResponseDto.java
@@ -0,0 +1,20 @@
+package com.goi.erp.dto;
+
+import lombok.Data;
+
+import java.util.UUID;
+
+@Data
+public class VehicleExternalMapResponseDto {
+
+ private UUID vexUuid;
+ private Long vexVehicleId;
+
+ private String vexSolutionType;
+ private String vexExternalId;
+
+ private String vexStatus;
+
+ private String vexCreatedBy;
+ private String vexUpdatedBy;
+}
diff --git a/src/main/java/com/goi/erp/dto/VehicleInspectionDefectDto.java b/src/main/java/com/goi/erp/dto/VehicleInspectionDefectDto.java
new file mode 100644
index 0000000..cb3177e
--- /dev/null
+++ b/src/main/java/com/goi/erp/dto/VehicleInspectionDefectDto.java
@@ -0,0 +1,24 @@
+package com.goi.erp.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class VehicleInspectionDefectDto {
+
+ private String defectType;
+ private String comment;
+ private Boolean isResolved;
+ private Long resolvedBy;
+ private String resolvedByExternalId;
+ private LocalDateTime resolvedAt;
+ private String vidExternalDefectId;
+ private LocalDateTime createdAt;
+}
diff --git a/src/main/java/com/goi/erp/dto/VehicleInspectionDto.java b/src/main/java/com/goi/erp/dto/VehicleInspectionDto.java
new file mode 100644
index 0000000..4a6b65e
--- /dev/null
+++ b/src/main/java/com/goi/erp/dto/VehicleInspectionDto.java
@@ -0,0 +1,42 @@
+package com.goi.erp.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.UUID;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class VehicleInspectionDto {
+
+ private Long vehId;
+ private String externalVehicleId;
+ private UUID vehicleUuid;
+ private Long driverId;
+ private UUID driverUuid;
+ private UUID subDriverUuid;
+ private Long subDriverId;
+ private String externalDriverId;
+ private String externalSubDriverId;
+
+ private LocalDate inspectionDate;
+ private String inspectionType; // preTrip / postTrip
+ private LocalDateTime startAt;
+ private LocalDateTime endAt;
+
+ private String result; // safe / unsafe
+ private Long odometer;
+ private Boolean hasDefect;
+
+ private String source; // SAMSARA / PAPER
+ private Long sourceId;
+
+ private List defects;
+}
diff --git a/src/main/java/com/goi/erp/entity/ExtSamsaraRawInspection.java b/src/main/java/com/goi/erp/entity/ExtSamsaraRawInspection.java
new file mode 100644
index 0000000..4d77c7c
--- /dev/null
+++ b/src/main/java/com/goi/erp/entity/ExtSamsaraRawInspection.java
@@ -0,0 +1,99 @@
+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 com.fasterxml.jackson.databind.JsonNode;
+
+import org.hibernate.annotations.JdbcTypeCode;
+import org.hibernate.type.SqlTypes;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+@Entity
+@Table(
+ name = "ext_samsara_raw_inspection",
+ uniqueConstraints = {
+ @UniqueConstraint(
+ name = "uk_ext_samsara_raw_inspection_src_extid",
+ columnNames = {"esri_source", "esri_external_id"}
+ )
+ }
+)
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@EntityListeners(AuditingEntityListener.class)
+public class ExtSamsaraRawInspection {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "esri_id")
+ private Long esriId;
+
+ @Column(name = "esri_source", nullable = false, length = 20)
+ private String esriSource; // SAMSARA
+
+ @Column(name = "esri_record_type", nullable = false, length = 20)
+ private String esriRecordType; // DVIR
+
+ @Column(name = "esri_external_id", nullable = false, length = 50)
+ private String esriExternalId; // inspection id
+
+ @Column(name = "esri_vehicle_ext_id", length = 50)
+ private String esriVehicleExtId;
+
+ @Column(name = "esri_driver_ext_id", length = 50)
+ private String esriDriverExtId;
+
+ @Column(name = "esri_start_time")
+ private LocalDateTime esriStartTime;
+
+ @Column(name = "esri_end_time")
+ private LocalDateTime esriEndTime;
+
+ @Column(name = "esri_signed_at")
+ private LocalDateTime esriSignedAt;
+
+ @Column(name = "esri_inspection_type", length = 20)
+ private String esriInspectionType; // preTrip / postTrip
+
+ @Column(name = "esri_hash", nullable = false, length = 64)
+ private String esriHash;
+
+ @JdbcTypeCode(SqlTypes.JSON)
+ @Column(name="esri_payload", columnDefinition = "jsonb", nullable = false)
+ private JsonNode esriPayload;
+
+ @Column(name = "esri_fetched_at", nullable = false)
+ private LocalDateTime esriFetchedAt;
+
+ @Column(
+ name = "esri_fetched_date",
+ insertable = false,
+ updatable = false
+ )
+ private LocalDate esriFetchedDate;
+
+ @Column(name = "esri_processed")
+ private Boolean esriProcessed;
+
+ @Column(name = "esri_processed_at")
+ private LocalDateTime esriProcessedAt;
+}
+
diff --git a/src/main/java/com/goi/erp/entity/VehicleDispatch.java b/src/main/java/com/goi/erp/entity/VehicleDispatch.java
new file mode 100644
index 0000000..cb2483e
--- /dev/null
+++ b/src/main/java/com/goi/erp/entity/VehicleDispatch.java
@@ -0,0 +1,107 @@
+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;
+import java.util.UUID;
+
+@Entity
+@Table(
+ name = "vehicle_dispatch",
+ uniqueConstraints = {
+ @UniqueConstraint(
+ name = "uk_vehicle_dispatch_shift",
+ columnNames = {"ved_veh_id", "ved_dispatch_date", "ved_shift"}
+ )
+ }
+)
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@EntityListeners(AuditingEntityListener.class)
+public class VehicleDispatch {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "ved_id")
+ private Long vedId;
+
+ @Column(name = "ved_uuid", unique = true)
+ private UUID vedUuid;
+
+ @Column(name = "ved_status", length = 10)
+ private String vedStatus; // O: Open | C: Closed | P: Paused
+
+ @Column(name = "ved_veh_id")
+ private Long vedVehId;
+
+ @Column(name = "ved_driver_id")
+ private Long vedDriverId;
+
+ @Column(name = "ved_sub_driver_id")
+ private Long vedSubDriverId;
+
+ @Column(name = "ved_dispatch_date", nullable = false)
+ private LocalDate vedDispatchDate;
+
+ @Column(name = "ved_shift")
+ private Integer vedShift;
+
+ @Column(name = "ved_start_at")
+ private LocalDateTime vedStartAt;
+
+ @Column(name = "ved_paused_at")
+ private LocalDateTime vedPausedAt;
+
+ @Column(name = "ved_end_at")
+ private LocalDateTime vedEndAt;
+
+ @Column(name = "ved_end_reason")
+ private String vedEndReason;
+
+ @Column(name = "ved_odometer_start", precision = 12, scale = 2)
+ private BigDecimal vedOdometerStart;
+
+ @Column(name = "ved_odometer_end", precision = 12, scale = 2)
+ private BigDecimal vedOdometerEnd;
+
+ @Column(name = "ved_odometer_increment", precision = 12, scale = 2)
+ private BigDecimal vedOdometerIncrement;
+
+ @Column(name = "ved_odometer_source", length = 20)
+ private String vedOdometerSource; // obd / gpsDistance / gpsOdometer
+
+ @Column(name = "ved_event_count")
+ private Integer vedEventCount;
+
+ @Column(name = "ved_source", length = 20)
+ private String vedSource; // AUTO / MANUAL
+
+ @Column(name = "ved_created_at")
+ private LocalDateTime vedCreatedAt;
+
+ @Column(name = "ved_created_by", length = 50)
+ private String vedCreatedBy;
+
+ @Column(name = "ved_updated_at")
+ private LocalDateTime vedUpdatedAt;
+
+ @Column(name = "ved_updated_by", length = 50)
+ private String vedUpdatedBy;
+}
diff --git a/src/main/java/com/goi/erp/entity/VehicleExternalMap.java b/src/main/java/com/goi/erp/entity/VehicleExternalMap.java
new file mode 100644
index 0000000..3638249
--- /dev/null
+++ b/src/main/java/com/goi/erp/entity/VehicleExternalMap.java
@@ -0,0 +1,66 @@
+package com.goi.erp.entity;
+
+import org.springframework.data.annotation.CreatedBy;
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.annotation.LastModifiedBy;
+import org.springframework.data.annotation.LastModifiedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+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 lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+@Entity
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@EntityListeners(AuditingEntityListener.class)
+public class VehicleExternalMap {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "vex_id")
+ private Long vexId;
+
+ @Column(name = "vex_uuid", nullable = false)
+ private UUID vexUuid;
+
+ @Column(name = "vex_vehicle_id", nullable = false)
+ private Long vexVehicleId;
+
+ @Column(name = "vex_solution_type", nullable = false, length = 50)
+ private String vexSolutionType;
+
+ @Column(name = "vex_external_id", nullable = false, length = 100)
+ private String vexExternalId;
+
+ @Column(name = "vex_status", length = 1)
+ private String vexStatus; // A / I
+
+ @CreatedDate
+ @Column(name = "vex_created_at", updatable = false)
+ private LocalDateTime vexCreatedAt;
+
+ @CreatedBy
+ @Column(name = "vex_created_by")
+ private String vexCreatedBy;
+
+ @LastModifiedDate
+ @Column(name = "vex_updated_at")
+ private LocalDateTime vexUpdatedAt;
+
+ @LastModifiedBy
+ @Column(name = "vex_updated_by")
+ private String vexUpdatedBy;
+}
diff --git a/src/main/java/com/goi/erp/entity/VehicleInspection.java b/src/main/java/com/goi/erp/entity/VehicleInspection.java
new file mode 100644
index 0000000..f5a329f
--- /dev/null
+++ b/src/main/java/com/goi/erp/entity/VehicleInspection.java
@@ -0,0 +1,88 @@
+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.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+@Entity
+@Table(
+ name = "vehicle_inspection",
+ uniqueConstraints = {
+ @UniqueConstraint(
+ name = "uk_vehicle_inspection_source",
+ columnNames = {"vei_source", "vei_source_id"}
+ )
+ }
+)
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@EntityListeners(AuditingEntityListener.class)
+public class VehicleInspection {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "vei_id")
+ private Long veiId;
+
+ @Column(name = "vei_uuid", unique = true)
+ private UUID veiUuid;
+
+ @Column(name = "vei_veh_id")
+ private Long veiVehId;
+
+ @Column(name = "vei_driver_id")
+ private Long veiDriverId;
+
+ @Column(name = "vei_sub_driver_id")
+ private Long veiSubDriverId;
+
+ @Column(name = "vei_inspection_date", nullable = false)
+ private LocalDate veiInspectionDate;
+
+ @Column(name = "vei_inspection_type", length = 20)
+ private String veiInspectionType; // preTrip / postTrip
+
+ @Column(name = "vei_start_at")
+ private LocalDateTime veiStartAt;
+
+ @Column(name = "vei_end_at")
+ private LocalDateTime veiEndAt;
+
+ @Column(name = "vei_result", length = 20)
+ private String veiResult; // safe / unsafe
+
+ @Column(name = "vei_odometer")
+ private Long veiOdometer;
+
+ @Column(name = "vei_has_defect")
+ private Boolean veiHasDefect;
+
+ @Column(name = "vei_source", length = 20)
+ private String veiSource; // SAMSARA / PAPER
+
+ @Column(name = "vei_source_id")
+ private Long veiSourceId; // external id or self id
+
+ @Column(name = "vei_created_at")
+ private LocalDateTime veiCreatedAt;
+
+ @Column(name = "vei_dispatch_id")
+ private Long veiDispatchId;
+}
\ No newline at end of file
diff --git a/src/main/java/com/goi/erp/entity/VehicleInspectionDefect.java b/src/main/java/com/goi/erp/entity/VehicleInspectionDefect.java
new file mode 100644
index 0000000..445dc85
--- /dev/null
+++ b/src/main/java/com/goi/erp/entity/VehicleInspectionDefect.java
@@ -0,0 +1,56 @@
+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 lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import java.time.LocalDateTime;
+
+@Entity
+@Table(name = "vehicle_inspection_defect")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@EntityListeners(AuditingEntityListener.class)
+public class VehicleInspectionDefect {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "vid_id")
+ private Long vidId;
+
+ @Column(name = "vid_vei_id", nullable = false)
+ private Long vidVeiId; // vehicle_inspection.vei_id
+
+ @Column(name = "vid_defect_type", length = 50)
+ private String vidDefectType;
+
+ @Column(name = "vid_comment")
+ private String vidComment;
+
+ @Column(name = "vid_external_defect_id")
+ private String vidExternalDefectId;
+
+ @Column(name = "vid_is_resolved")
+ private Boolean vidIsResolved;
+
+ @Column(name = "vid_resolved_by")
+ private Long vidResolvedBy;
+
+ @Column(name = "vid_resolved_at")
+ private LocalDateTime vidResolvedAt;
+
+ @Column(name = "vid_created_at")
+ private LocalDateTime vidCreatedAt;
+}
\ No newline at end of file
diff --git a/src/main/java/com/goi/erp/repository/ExtSamsaraRawInspectionRepository.java b/src/main/java/com/goi/erp/repository/ExtSamsaraRawInspectionRepository.java
new file mode 100644
index 0000000..208239a
--- /dev/null
+++ b/src/main/java/com/goi/erp/repository/ExtSamsaraRawInspectionRepository.java
@@ -0,0 +1,44 @@
+package com.goi.erp.repository;
+
+import com.goi.erp.entity.ExtSamsaraRawInspection;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Optional;
+
+@Repository
+public interface ExtSamsaraRawInspectionRepository
+ extends JpaRepository {
+
+ /**
+ * idempotent ingest 용
+ * (source + externalId 는 UNIQUE)
+ */
+ Optional findByEsriSourceAndEsriExternalId(
+ String esriSource,
+ String esriExternalId
+ );
+
+ /**
+ * 아직 처리되지 않은 raw inspection 조회
+ */
+ List findByEsriProcessedFalse();
+
+ /**
+ * 특정 날짜에 수집된 raw inspection 중 미처리 데이터
+ * (scheduler / batch parsing 용)
+ */
+ List findByEsriProcessedFalseAndEsriFetchedDate(
+ LocalDate esriFetchedDate
+ );
+
+ /**
+ * 특정 차량의 inspection 이력 조회 (디버깅/분석용)
+ */
+ List findByEsriVehicleExtId(
+ String esriVehicleExtId
+ );
+}
diff --git a/src/main/java/com/goi/erp/repository/VehicleDispatchRepository.java b/src/main/java/com/goi/erp/repository/VehicleDispatchRepository.java
new file mode 100644
index 0000000..0e7c3af
--- /dev/null
+++ b/src/main/java/com/goi/erp/repository/VehicleDispatchRepository.java
@@ -0,0 +1,57 @@
+package com.goi.erp.repository;
+
+import com.goi.erp.entity.VehicleDispatch;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+public interface VehicleDispatchRepository extends JpaRepository {
+
+ List findByVedVehIdAndVedDispatchDate(Long vedVehId, LocalDate vedDispatchDate);
+ List findByVedDriverIdAndVedDispatchDate(Long vedDriverId, LocalDate vedDispatchDate);
+ List findByVedDispatchDate(LocalDate vedDispatchDate);
+ Page findAll(Pageable pageable);
+
+ Optional findByVedUuid(UUID vedUuid);
+
+ //
+ @Query("""
+ SELECT d
+ FROM VehicleDispatch d
+ WHERE d.vedVehId = :vehId
+ AND d.vedStatus IN ('O','P')
+ """)
+ Optional findOpenDispatchByVehId(Long vehId);
+
+ //
+ @Query("""
+ SELECT d
+ FROM VehicleDispatch d
+ WHERE d.vedStatus = 'P'
+ AND d.vedPausedAt <= :threshold
+ """)
+ List findPausedBefore(LocalDateTime threshold);
+
+ //
+ @Query("""
+ SELECT COALESCE(MAX(d.vedShift), -1)
+ FROM VehicleDispatch d
+ WHERE d.vedVehId = :vehId
+ AND d.vedDispatchDate = :date
+ """)
+ Integer findMaxShift(Long vehId, LocalDate date);
+
+ //
+ default Integer findNextShift(Long vehId, LocalDate date) {
+ Integer max = findMaxShift(vehId, date);
+ return max == null ? 0 : max + 1;
+ }
+}
diff --git a/src/main/java/com/goi/erp/repository/VehicleExternalMapRepository.java b/src/main/java/com/goi/erp/repository/VehicleExternalMapRepository.java
new file mode 100644
index 0000000..2d50fb8
--- /dev/null
+++ b/src/main/java/com/goi/erp/repository/VehicleExternalMapRepository.java
@@ -0,0 +1,17 @@
+package com.goi.erp.repository;
+
+import com.goi.erp.entity.VehicleExternalMap;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+@Repository
+public interface VehicleExternalMapRepository extends JpaRepository {
+
+ Optional findByVexSolutionTypeAndVexExternalIdAndVexStatus(
+ String vexSolutionType,
+ String vexExternalId,
+ String vexStatus
+ );
+}
diff --git a/src/main/java/com/goi/erp/repository/VehicleInspectionDefectRepository.java b/src/main/java/com/goi/erp/repository/VehicleInspectionDefectRepository.java
new file mode 100644
index 0000000..8d9841e
--- /dev/null
+++ b/src/main/java/com/goi/erp/repository/VehicleInspectionDefectRepository.java
@@ -0,0 +1,18 @@
+package com.goi.erp.repository;
+
+import com.goi.erp.entity.VehicleInspectionDefect;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+@Repository
+public interface VehicleInspectionDefectRepository extends JpaRepository {
+
+ List findByVidVeiId(Long vidVeiId);
+ List findByVidVeiIdAndVidIsResolvedFalse(Long vidVeiId);
+
+ Optional findByVidVeiIdAndVidExternalDefectId(Long veiId, String vidExternalDefectId);
+}
diff --git a/src/main/java/com/goi/erp/repository/VehicleInspectionRepository.java b/src/main/java/com/goi/erp/repository/VehicleInspectionRepository.java
new file mode 100644
index 0000000..a98bc30
--- /dev/null
+++ b/src/main/java/com/goi/erp/repository/VehicleInspectionRepository.java
@@ -0,0 +1,16 @@
+package com.goi.erp.repository;
+
+import com.goi.erp.entity.VehicleInspection;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.time.LocalDate;
+import java.util.Optional;
+
+@Repository
+public interface VehicleInspectionRepository extends JpaRepository {
+
+ Optional findByVeiSourceAndVeiSourceId(String veiSource, Long veiSourceId);
+ Optional findByVeiVehIdAndVeiInspectionDate(Long veiVehId, LocalDate veiInspectionDate);
+}
diff --git a/src/main/java/com/goi/erp/service/ExtSamsaraInspectionIngestService.java b/src/main/java/com/goi/erp/service/ExtSamsaraInspectionIngestService.java
new file mode 100644
index 0000000..4d13745
--- /dev/null
+++ b/src/main/java/com/goi/erp/service/ExtSamsaraInspectionIngestService.java
@@ -0,0 +1,139 @@
+package com.goi.erp.service;
+
+import com.goi.erp.dto.ExtIngestResult;
+import com.goi.erp.dto.ExtSamsaraInspectionIngestCommand;
+import com.goi.erp.dto.ExtSamsaraInspectionRecordDto;
+import com.goi.erp.entity.ExtSamsaraRawInspection;
+import com.goi.erp.repository.ExtSamsaraRawInspectionRepository;
+
+import jakarta.transaction.Transactional;
+import lombok.RequiredArgsConstructor;
+
+import org.springframework.stereotype.Service;
+
+@Service
+@RequiredArgsConstructor
+public class ExtSamsaraInspectionIngestService {
+
+ private final ExtSamsaraRawInspectionRepository repository;
+
+ /**
+ * Ingest entry point
+ */
+ @Transactional
+ public ExtIngestResult ingest(ExtSamsaraInspectionIngestCommand command) {
+
+ int inserted = 0;
+ int updated = 0;
+ int skipped = 0;
+ @SuppressWarnings("unused")
+ int failed = 0;
+
+ for (ExtSamsaraInspectionRecordDto record : command.getRecords()) {
+
+ IngestAction action;
+
+ try {
+ action = ingestSingle(command, record);
+ } catch (Exception e) {
+ failed++;
+ continue;
+ }
+
+ switch (action) {
+ case INSERTED -> inserted++;
+ case UPDATED -> updated++;
+ case SKIPPED -> skipped++;
+ case FAILED -> failed++;
+ }
+ }
+
+ return ExtIngestResult.builder()
+ .source(command.getSource())
+ .recordType(command.getRecordType())
+ .received(command.getRecords().size())
+ .inserted(inserted)
+ .updated(updated)
+ .skipped(skipped)
+ // .failed(failed) // 필요하면 추후 추가
+ .build();
+ }
+
+ /**
+ * Single record ingest (idempotent)
+ */
+ private IngestAction ingestSingle(
+ ExtSamsaraInspectionIngestCommand command,
+ ExtSamsaraInspectionRecordDto record
+ ) {
+
+ return repository
+ .findByEsriSourceAndEsriExternalId(
+ command.getSource(),
+ record.getExternalId()
+ )
+ .map(existing -> updateIfChanged(existing, command, record))
+ .orElseGet(() -> {
+ insertNew(command, record);
+ return IngestAction.INSERTED;
+ });
+ }
+
+
+ /**
+ * Insert new raw inspection
+ */
+ private void insertNew(
+ ExtSamsaraInspectionIngestCommand command,
+ ExtSamsaraInspectionRecordDto record
+ ) {
+
+ ExtSamsaraRawInspection entity = ExtSamsaraRawInspection.builder()
+ .esriSource(command.getSource())
+ .esriRecordType(command.getRecordType())
+ .esriExternalId(record.getExternalId())
+ .esriVehicleExtId(record.getVehicleExternalId())
+ .esriDriverExtId(record.getDriverExternalId())
+ .esriInspectionType(record.getInspectionType())
+ .esriStartTime(record.getStartTime())
+ .esriEndTime(record.getEndTime())
+ .esriSignedAt(record.getSignedAt())
+ .esriHash(record.getPayloadHash())
+ .esriFetchedAt(command.getFetchedAt())
+ .esriProcessed(false)
+ .esriPayload(record.getPayloadJson())
+ .build();
+
+ repository.save(entity);
+ }
+
+ /**
+ * Update only if hash changed
+ */
+ private IngestAction updateIfChanged(
+ ExtSamsaraRawInspection existing,
+ ExtSamsaraInspectionIngestCommand command,
+ ExtSamsaraInspectionRecordDto record
+ ) {
+
+ if (existing.getEsriHash().equals(record.getPayloadHash())) {
+ return IngestAction.SKIPPED;
+ }
+
+ existing.setEsriVehicleExtId(record.getVehicleExternalId());
+ existing.setEsriDriverExtId(record.getDriverExternalId());
+ existing.setEsriInspectionType(record.getInspectionType());
+ existing.setEsriStartTime(record.getStartTime());
+ existing.setEsriEndTime(record.getEndTime());
+ existing.setEsriSignedAt(record.getSignedAt());
+ existing.setEsriHash(record.getPayloadHash());
+ existing.setEsriFetchedAt(command.getFetchedAt());
+ existing.setEsriPayload(record.getPayloadJson());
+ existing.setEsriProcessed(false);
+ existing.setEsriProcessedAt(null);
+
+ repository.save(existing);
+ return IngestAction.UPDATED;
+ }
+
+}
diff --git a/src/main/java/com/goi/erp/service/ExtSamsaraInspectionProcessor.java b/src/main/java/com/goi/erp/service/ExtSamsaraInspectionProcessor.java
new file mode 100644
index 0000000..85f7aa8
--- /dev/null
+++ b/src/main/java/com/goi/erp/service/ExtSamsaraInspectionProcessor.java
@@ -0,0 +1,130 @@
+package com.goi.erp.service;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.goi.erp.dto.VehicleInspectionDefectDto;
+import com.goi.erp.dto.VehicleInspectionDto;
+import com.goi.erp.entity.ExtSamsaraRawInspection;
+import com.goi.erp.repository.ExtSamsaraRawInspectionRepository;
+import com.goi.erp.common.util.DateTimeUtil;
+
+import jakarta.transaction.Transactional;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.springframework.stereotype.Service;
+
+@Service
+@Slf4j
+@RequiredArgsConstructor
+public class ExtSamsaraInspectionProcessor {
+
+ private final ExtSamsaraRawInspectionRepository rawRepo;
+ private final VehicleInspectionService vehicleInspectionService;
+
+ @Transactional
+ public void processUnprocessed() {
+
+ List raws = rawRepo.findByEsriProcessedFalse();
+
+ if (raws.isEmpty()) {
+ log.info("[DVIR_PROCESSOR] no unprocessed raw inspections");
+ return;
+ }
+ // log 용
+ int processed = 0;
+ int failed = 0;
+
+ for (ExtSamsaraRawInspection raw : raws) {
+ try {
+ processSingle(raw);
+ processed++;
+ } catch (Exception e) {
+ failed++;
+ log.error(
+ "[DVIR_PROCESSOR] failed rawId={} externalId={} msg={}",
+ raw.getEsriId(),
+ raw.getEsriExternalId(),
+ e.getMessage(),
+ e
+ );
+ }
+ }
+
+ log.info("[DVIR_PROCESSOR] done processed={} failed={}", processed, failed);
+ }
+
+ private void processSingle(ExtSamsaraRawInspection raw) {
+
+ JsonNode payload = raw.getEsriPayload();
+
+ // Inspection
+ VehicleInspectionDto dto = new VehicleInspectionDto();
+ dto.setSource("SAMSARA");
+ dto.setSourceId(Long.valueOf(raw.getEsriExternalId()));
+ dto.setExternalVehicleId(raw.getEsriVehicleExtId());
+ dto.setExternalDriverId(raw.getEsriDriverExtId());
+ dto.setInspectionType(raw.getEsriInspectionType());
+
+ dto.setInspectionDate(
+ raw.getEsriStartTime() != null
+ ? raw.getEsriStartTime().toLocalDate()
+ : raw.getEsriFetchedAt().toLocalDate()
+ );
+
+ dto.setStartAt(raw.getEsriStartTime());
+ dto.setEndAt(raw.getEsriEndTime());
+ dto.setResult(payload.path("safetyStatus").asText(null));
+ dto.setOdometer(payload.path("odometerMeters").asLong(0L));
+
+ log.info(
+ "[DVIR] rawId={} odometerMeters node={} value={}",
+ raw.getEsriExternalId(),
+ payload.get("odometerMeters"),
+ payload.path("odometerMeters").asLong(-1)
+ );
+
+ // Defects
+ JsonNode defectsNode = payload.path("vehicleDefects");
+ List defectDtos = new ArrayList<>();
+
+ if (defectsNode.isArray()) {
+ for (JsonNode d : defectsNode) {
+ VehicleInspectionDefectDto defectDto = new VehicleInspectionDefectDto();
+ defectDto.setVidExternalDefectId(d.path("id").asText(null));
+ defectDto.setDefectType(d.path("defectType").asText(null));
+ defectDto.setComment(d.path("comment").asText(null));
+ defectDto.setIsResolved(d.path("isResolved").asBoolean(false));
+ JsonNode resolvedBy = d.path("resolvedBy");
+ if (!resolvedBy.isMissingNode()) {
+ defectDto.setResolvedByExternalId(resolvedBy.path("id").asText(null));
+ }
+ defectDto.setResolvedAt(DateTimeUtil.parse(d.path("resolvedAtTime")));
+ defectDto.setCreatedAt(DateTimeUtil.parse(d.path("createdAtTime")));
+ defectDtos.add(defectDto);
+ }
+ }
+ dto.setDefects(defectDtos);
+ dto.setHasDefect(!defectDtos.isEmpty());
+
+ log.info(
+ "[DVIR DTO] sourceId={} result={} odometer={} hasDefect={} defects={}",
+ dto.getSourceId(),
+ dto.getResult(),
+ dto.getOdometer(),
+ dto.getHasDefect(),
+ dto.getDefects() != null ? dto.getDefects().size() : null
+ );
+
+ // inspection + defect 저장은 Service 에서
+ vehicleInspectionService.saveInspection(dto);
+
+ // raw 처리 완료
+ raw.setEsriProcessed(true);
+ raw.setEsriProcessedAt(LocalDateTime.now());
+ rawRepo.save(raw);
+ }
+}
diff --git a/src/main/java/com/goi/erp/service/HcmEmployeeClient.java b/src/main/java/com/goi/erp/service/HcmEmployeeClient.java
index d07adb7..2a1aead 100644
--- a/src/main/java/com/goi/erp/service/HcmEmployeeClient.java
+++ b/src/main/java/com/goi/erp/service/HcmEmployeeClient.java
@@ -25,9 +25,9 @@ public class HcmEmployeeClient {
@Value("${hcm.api.base-url}")
private String hcmBaseUrl;
- public Long getEmpIdFromExternalId(String externalId) {
+ public Long getEmpIdFromExternalId(String externalSource, String externalId) {
- String url = hcmBaseUrl + "/employee/external" + "?solutionType=MIS&externalId=" + externalId;
+ String url = hcmBaseUrl + "/employee/external" + "?solutionType=" + externalSource + "&externalId=" + externalId;
try {
// set token in header
diff --git a/src/main/java/com/goi/erp/service/IngestAction.java b/src/main/java/com/goi/erp/service/IngestAction.java
new file mode 100644
index 0000000..4bf2e27
--- /dev/null
+++ b/src/main/java/com/goi/erp/service/IngestAction.java
@@ -0,0 +1,8 @@
+package com.goi.erp.service;
+
+public enum IngestAction {
+ INSERTED,
+ UPDATED,
+ SKIPPED,
+ FAILED
+}
\ No newline at end of file
diff --git a/src/main/java/com/goi/erp/service/VehicleDispatchService.java b/src/main/java/com/goi/erp/service/VehicleDispatchService.java
new file mode 100644
index 0000000..18ebc2f
--- /dev/null
+++ b/src/main/java/com/goi/erp/service/VehicleDispatchService.java
@@ -0,0 +1,275 @@
+package com.goi.erp.service;
+
+import com.goi.erp.dto.VehicleDispatchRequestDto;
+import com.goi.erp.entity.VehicleDispatch;
+import com.goi.erp.entity.VehicleInspection;
+import com.goi.erp.repository.VehicleDispatchRepository;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.time.Duration;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.UUID;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class VehicleDispatchService {
+
+ private final VehicleDispatchRepository dispatchRepository;
+
+ /* ===============================
+ * CREATE
+ * =============================== */
+ public VehicleDispatch createFromPreTripInspection(
+ Long vehId,
+ Long driverId,
+ Long subDriverId,
+ LocalDate dispatchDate,
+ LocalDateTime startAt,
+ BigDecimal startOdometer,
+ String source
+ ) {
+ Integer nextShift = dispatchRepository.findNextShift(vehId, dispatchDate);
+
+ VehicleDispatch dispatch = VehicleDispatch.builder()
+ .vedUuid(UUID.randomUUID())
+ .vedVehId(vehId)
+ .vedDriverId(driverId)
+ .vedSubDriverId(subDriverId)
+ .vedDispatchDate(dispatchDate)
+ .vedShift(nextShift)
+ .vedStartAt(startAt)
+ .vedOdometerStart(startOdometer)
+ .vedStatus("O")
+ .vedSource(source != null ? source : "AUTO")
+ .build();
+
+ return dispatchRepository.save(dispatch);
+ }
+
+ public VehicleDispatch createManualDispatch(
+ VehicleDispatchRequestDto dto,
+ String createdBy
+ ) {
+ VehicleDispatch opened =
+ dispatchRepository.findOpenDispatchByVehId(dto.getVedVehId())
+ .orElse(null);
+
+ if (opened != null) return opened;
+
+ Integer nextShift =
+ dispatchRepository.findNextShift(dto.getVedVehId(), dto.getVedDispatchDate());
+
+ VehicleDispatch dispatch = VehicleDispatch.builder()
+ .vedUuid(UUID.randomUUID())
+ .vedVehId(dto.getVedVehId())
+ .vedDriverId(dto.getVedDriverId())
+ .vedSubDriverId(dto.getVedSubDriverId())
+ .vedDispatchDate(dto.getVedDispatchDate())
+ .vedShift(nextShift)
+ .vedStartAt(dto.getVedStartAt() != null ? dto.getVedStartAt() : LocalDateTime.now())
+ .vedOdometerStart(dto.getVedOdometerStart())
+ .vedOdometerSource(dto.getVedOdometerSource())
+ .vedStatus("O")
+ .vedSource("MANUAL")
+ .vedCreatedBy(createdBy)
+ .build();
+
+ return dispatchRepository.save(dispatch);
+ }
+
+ /* ===============================
+ * STATE TRANSITIONS
+ * =============================== */
+ public void pauseDispatch(UUID dispatchUuid, LocalDateTime pausedAt) {
+ VehicleDispatch d = getOpen(dispatchUuid);
+
+ d.setVedStatus("P");
+ d.setVedPausedAt(pausedAt != null ? pausedAt : LocalDateTime.now());
+
+ dispatchRepository.save(d);
+ }
+
+ public void closeDispatch(
+ UUID dispatchUuid,
+ LocalDateTime endAt,
+ BigDecimal endOdometer,
+ String reason
+ ) {
+ VehicleDispatch d = dispatchRepository.findByVedUuid(dispatchUuid)
+ .orElseThrow(() -> new IllegalArgumentException("dispatch not found"));
+
+ if ("C".equals(d.getVedStatus())) {
+ log.debug(
+ "[DISPATCH][CLOSE] already closed. dispatchUuid={}",
+ dispatchUuid
+ );
+ return;
+ }
+
+ d.setVedStatus("C");
+ d.setVedEndAt(endAt != null ? endAt : LocalDateTime.now());
+ d.setVedEndReason(reason);
+
+ if (endOdometer != null) {
+ d.setVedOdometerEnd(endOdometer);
+
+ if (d.getVedOdometerStart() != null) {
+ d.setVedOdometerIncrement(
+ endOdometer.subtract(d.getVedOdometerStart())
+ );
+ }
+ }
+
+ dispatchRepository.save(d);
+ }
+
+
+ /* ===============================
+ * INSPECTION 에 의한 배차 종료
+ * =============================== */
+ public void evaluateDispatchCloseByInspection(VehicleInspection inspection) {
+
+ VehicleDispatch open =
+ dispatchRepository.findOpenDispatchByVehId(inspection.getVeiVehId())
+ .orElse(null);
+
+ if (open == null) {
+ log.info(
+ "[DISPATCH][EVAL] no open dispatch. inspectionType={} vehId={} driverId={}",
+ inspection.getVeiInspectionType(),
+ inspection.getVeiVehId(),
+ inspection.getVeiDriverId()
+ );
+ return;
+ }
+
+ // Rule 1: PostTrip
+ if ("postTrip".equalsIgnoreCase(inspection.getVeiInspectionType())) {
+ closeDispatch(
+ open.getVedUuid(),
+ inspection.getVeiEndAt(),
+ inspection.getVeiOdometer() != null ? BigDecimal.valueOf(inspection.getVeiOdometer()): null,
+ "POST_TRIP_INSPECTION"
+ );
+ log.info(
+ "[DISPATCH][CLOSE] by POST_TRIP inspection. dispatchUuid={} endAt={}",
+ open.getVedUuid(),
+ inspection.getVeiEndAt()
+ );
+ return;
+ }
+
+ // Rule 2: same vehicle, different driver, preTrip
+ if ("preTrip".equalsIgnoreCase(inspection.getVeiInspectionType())
+ && !inspection.getVeiDriverId().equals(open.getVedDriverId())) {
+
+ closeDispatch(
+ open.getVedUuid(),
+ inspection.getVeiStartAt(),
+ null,
+ "NEW_PRETRIP_SAME_VEHICLE"
+ );
+ log.info(
+ "[DISPATCH][CLOSE] reason={} dispatchUuid={} inspectionType={}",
+ "NEW_PRETRIP_SAME_VEHICLE",
+ open.getVedUuid(),
+ inspection.getVeiInspectionType()
+ );
+ return;
+ }
+
+ // Rule 3: same driver, different vehicle, preTrip
+ if ("preTrip".equalsIgnoreCase(inspection.getVeiInspectionType())
+ && inspection.getVeiDriverId().equals(open.getVedDriverId())
+ && !inspection.getVeiVehId().equals(open.getVedVehId())) {
+
+ closeDispatch(
+ open.getVedUuid(),
+ inspection.getVeiStartAt(),
+ null,
+ "NEW_PRETRIP_OTHER_VEHICLE"
+ );
+ }
+ }
+
+ /* ===============================
+ * JOB / 60분 RULE로 배차 종료
+ * =============================== */
+ public void closePausedDispatches(Duration threshold) {
+ LocalDateTime now = LocalDateTime.now();
+
+ List paused =
+ dispatchRepository.findPausedBefore(now.minus(threshold));
+
+ for (VehicleDispatch d : paused) {
+ closeDispatch(
+ d.getVedUuid(),
+ now,
+ null,
+ "ENGINE_OFF_60MIN"
+ );
+ }
+ }
+
+ /* ===============================
+ * MANUAL 배차 업데이트
+ * =============================== */
+ public VehicleDispatch patchManualDispatch(
+ UUID dispatchUuid,
+ VehicleDispatchRequestDto dto,
+ String updatedBy
+ ) {
+ VehicleDispatch d = dispatchRepository.findByVedUuid(dispatchUuid)
+ .orElseThrow(() -> new IllegalArgumentException("dispatch not found"));
+
+ if ("C".equals(d.getVedStatus())) {
+ throw new IllegalStateException("closed dispatch cannot be modified");
+ }
+
+ if (dto.getVedDriverId() != null) d.setVedDriverId(dto.getVedDriverId());
+ if (dto.getVedSubDriverId() != null) d.setVedSubDriverId(dto.getVedSubDriverId());
+ if (dto.getVedStartAt() != null) d.setVedStartAt(dto.getVedStartAt());
+ if (dto.getVedOdometerSource() != null) d.setVedOdometerSource(dto.getVedOdometerSource());
+ if (dto.getVedEventCount() != null) d.setVedEventCount(dto.getVedEventCount());
+
+ // odometer
+ if (dto.getVedOdometerStart() != null) {
+ d.setVedOdometerStart(dto.getVedOdometerStart());
+ }
+ if (dto.getVedOdometerEnd() != null) {
+ d.setVedOdometerEnd(dto.getVedOdometerEnd());
+
+ if (d.getVedOdometerStart() != null) {
+ d.setVedOdometerIncrement(
+ dto.getVedOdometerEnd().subtract(d.getVedOdometerStart())
+ );
+ }
+ }
+
+ d.setVedUpdatedAt(LocalDateTime.now());
+ d.setVedUpdatedBy(updatedBy);
+
+ return dispatchRepository.save(d);
+ }
+
+
+ //
+ private VehicleDispatch getOpen(UUID uuid) {
+ VehicleDispatch d = dispatchRepository.findByVedUuid(uuid)
+ .orElseThrow(() -> new IllegalArgumentException("dispatch not found"));
+
+ if (!"O".equals(d.getVedStatus())) {
+ throw new IllegalStateException("dispatch not open");
+ }
+ return d;
+ }
+}
+
diff --git a/src/main/java/com/goi/erp/service/VehicleExternalMapService.java b/src/main/java/com/goi/erp/service/VehicleExternalMapService.java
new file mode 100644
index 0000000..88ef9ca
--- /dev/null
+++ b/src/main/java/com/goi/erp/service/VehicleExternalMapService.java
@@ -0,0 +1,46 @@
+package com.goi.erp.service;
+
+import com.goi.erp.dto.VehicleExternalMapResponseDto;
+import com.goi.erp.entity.VehicleExternalMap;
+import com.goi.erp.repository.VehicleExternalMapRepository;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+public class VehicleExternalMapService {
+
+ private final VehicleExternalMapRepository externalMapRepository;
+
+ /**
+ * 외부 솔루션의 ID로 내부 employee 매핑 정보 조회
+ * 목적: customer_daily_order.driver_id 에 들어갈 내부 emp_id 조회
+ */
+ @Transactional(readOnly = true)
+ public VehicleExternalMapResponseDto findMapping(String solutionType, String externalId) {
+
+ VehicleExternalMap map = externalMapRepository
+ .findByVexSolutionTypeAndVexExternalIdAndVexStatus(
+ solutionType,
+ externalId,
+ "A" // 활성 매핑만 사용
+ )
+ .orElseThrow(() ->
+ new RuntimeException("Vehicle external mapping not found for externalId: " + externalId)
+ );
+
+ // Entity → ResponseDto 변환
+ VehicleExternalMapResponseDto dto = new VehicleExternalMapResponseDto();
+ dto.setVexUuid(map.getVexUuid());
+ dto.setVexVehicleId(map.getVexVehicleId());
+ dto.setVexSolutionType(map.getVexSolutionType());
+ dto.setVexExternalId(map.getVexExternalId());
+ dto.setVexStatus(map.getVexStatus());
+ dto.setVexCreatedBy(map.getVexCreatedBy());
+ dto.setVexUpdatedBy(map.getVexUpdatedBy());
+
+ return dto;
+ }
+}
diff --git a/src/main/java/com/goi/erp/service/VehicleInspectionService.java b/src/main/java/com/goi/erp/service/VehicleInspectionService.java
new file mode 100644
index 0000000..65e02ce
--- /dev/null
+++ b/src/main/java/com/goi/erp/service/VehicleInspectionService.java
@@ -0,0 +1,238 @@
+package com.goi.erp.service;
+
+import com.goi.erp.dto.VehicleInspectionDefectDto;
+import com.goi.erp.dto.VehicleInspectionDto;
+import com.goi.erp.entity.VehicleDispatch;
+import com.goi.erp.entity.VehicleInspection;
+import com.goi.erp.entity.VehicleInspectionDefect;
+import com.goi.erp.repository.VehicleDispatchRepository;
+import com.goi.erp.repository.VehicleInspectionDefectRepository;
+import com.goi.erp.repository.VehicleInspectionRepository;
+
+import jakarta.transaction.Transactional;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.UUID;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class VehicleInspectionService {
+
+ private final VehicleInspectionRepository inspectionRepository;
+ private final VehicleInspectionDefectRepository defectRepository;
+ private final VehicleDispatchRepository dispatchRepository;
+ private final HcmEmployeeClient hcmEmployeeClient;
+ private final VehicleExternalMapService vehicleExternalMapService;
+ private final VehicleDispatchService vehicleDispatchService;
+ // private final VehicleService vehicleService;
+
+ /**
+ * Create or Update Vehicle Inspection (idempotent)
+ * -> defects upsert
+ * -> evaluate dispatch close by inspection
+ * -> create dispatch (preTrip) if needed
+ */
+ @Transactional
+ public VehicleInspection saveInspection(VehicleInspectionDto dto) {
+ VehicleInspection inspection = inspectionRepository
+ .findByVeiSourceAndVeiSourceId(dto.getSource(), dto.getSourceId())
+ .orElseGet(VehicleInspection::new);
+
+ // ===== ID resolve =====
+ Long driverId = resolveDriverId(dto);
+ Long subDriverId = resolveSubDriverId(dto);
+ Long vehicleId = resolveVehicleId(dto);
+
+ log.info(
+ "[INSPECTION] start source={} sourceId={} type={} vehId={} driverId={}",
+ dto.getSource(),
+ dto.getSourceId(),
+ dto.getInspectionType(),
+ vehicleId,
+ driverId
+ );
+
+ // ===== Inspection =====
+ inspection.setVeiUuid(UUID.randomUUID());
+ inspection.setVeiVehId(vehicleId);
+ inspection.setVeiDriverId(driverId);
+ inspection.setVeiSubDriverId(subDriverId);
+ inspection.setVeiInspectionDate(dto.getInspectionDate());
+ inspection.setVeiInspectionType(dto.getInspectionType());
+ inspection.setVeiStartAt(dto.getStartAt());
+ inspection.setVeiEndAt(dto.getEndAt());
+ inspection.setVeiResult(dto.getResult());
+ inspection.setVeiOdometer(dto.getOdometer());
+ inspection.setVeiHasDefect(dto.getHasDefect());
+ inspection.setVeiSource(dto.getSource());
+ inspection.setVeiSourceId(dto.getSourceId());
+
+ inspection = inspectionRepository.save(inspection);
+
+ // ===== Defects =====
+ if (dto.getDefects() != null) {
+ saveDefects(inspection.getVeiId(), dto.getDefects());
+ }
+
+ // ===== Dispatch close evaluation (Rule 1~3) =====
+ // - postTrip이면 여기서 closeDispatch 호출됨
+ // - preTrip new driver/vehicle 조건도 여기서 기존 open close됨
+ vehicleDispatchService.evaluateDispatchCloseByInspection(inspection);
+
+ // ===== Dispatch create (preTrip) =====
+ // - 이미 open dispatch가 있으면 생성하지 않음
+ // - odometerStart = inspection.odometer (가능하면)
+ if (shouldCreateDispatch(inspection)) {
+ // open 여부
+ boolean hasOpenDispatch = dispatchRepository.findOpenDispatchByVehId(inspection.getVeiVehId()).isPresent();
+
+ if (!hasOpenDispatch) {
+
+ BigDecimal odometerStart =
+ (inspection.getVeiOdometer() != null)
+ ? BigDecimal.valueOf(inspection.getVeiOdometer())
+ : null;
+
+ VehicleDispatch dispatch =
+ vehicleDispatchService.createFromPreTripInspection(
+ inspection.getVeiVehId(),
+ inspection.getVeiDriverId(),
+ inspection.getVeiSubDriverId(),
+ inspection.getVeiInspectionDate(),
+ inspection.getVeiStartAt(),
+ odometerStart,
+ "AUTO"
+ );
+
+ // inspection에 dispatch 연결
+ inspection.setVeiDispatchId(dispatch.getVedId());
+ inspection = inspectionRepository.save(inspection);
+ } else {
+ log.info(
+ "[DISPATCH][CREATE] skipped. open dispatch already exists. vehId={}",
+ inspection.getVeiVehId()
+ );
+ }
+ } else {
+ log.debug(
+ "[DISPATCH][CREATE] skip. reason=shouldCreateDispatch=false inspectionId={}",
+ inspection.getVeiId()
+ );
+ }
+
+ return inspection;
+ }
+
+ /**
+ * defects 저장 (externalDefectId 기준 upsert)
+ * - externalDefectId가 null이면 "수기"로 간주 → 그냥 매번 INSERT 하면 중복 가능성이 커짐
+ * => 수기는 (veiId + defectType + comment + createdAt) 조합으로 찾아
+ */
+ private void saveDefects(Long veiId, List defects) {
+ // 없으면
+ if (defects == null || defects.isEmpty()) return;
+
+ // defect 별
+ for (VehicleInspectionDefectDto d : defects) {
+
+ VehicleInspectionDefect defect;
+
+ // 외부 defect id가 있으면 그걸로 idempotent
+ if (d.getVidExternalDefectId() != null && !d.getVidExternalDefectId().isBlank()) {
+ defect = defectRepository
+ .findByVidVeiIdAndVidExternalDefectId(veiId, d.getVidExternalDefectId())
+ .orElseGet(VehicleInspectionDefect::new);
+ } else {
+ // 수기 defect: 최소한의 중복 방지 (원하면 repository 메서드로 더 정교하게)
+ defect = new VehicleInspectionDefect();
+ }
+
+ defect.setVidVeiId(veiId);
+ defect.setVidExternalDefectId(d.getVidExternalDefectId());
+ defect.setVidDefectType(d.getDefectType());
+ defect.setVidComment(d.getComment());
+ defect.setVidIsResolved(d.getIsResolved());
+ defect.setVidCreatedAt(d.getCreatedAt());
+ defect.setVidResolvedAt(d.getResolvedAt());
+ defect.setVidResolvedBy(resolveEmpId(d));
+
+ defectRepository.save(defect);
+ }
+ }
+
+ private Long resolveDriverId(VehicleInspectionDto dto) {
+
+ if (dto.getDriverId() != null) return dto.getDriverId();
+
+ if (dto.getExternalDriverId() != null) {
+ return hcmEmployeeClient.getEmpIdFromExternalId(
+ "SAMSARA",
+ dto.getExternalDriverId()
+ );
+ }
+
+ if (dto.getDriverUuid() != null) {
+ return hcmEmployeeClient.getEmpIdFromUuid(dto.getDriverUuid());
+ }
+
+ return null;
+ }
+
+ private Long resolveSubDriverId(VehicleInspectionDto dto) {
+
+ if (dto.getSubDriverId() != null) return dto.getSubDriverId();
+
+ if (dto.getExternalSubDriverId() != null) {
+ return hcmEmployeeClient.getEmpIdFromExternalId(
+ "SAMSARA",
+ dto.getExternalSubDriverId()
+ );
+ }
+
+ if (dto.getSubDriverUuid() != null) {
+ return hcmEmployeeClient.getEmpIdFromUuid(dto.getSubDriverUuid());
+ }
+
+ return null;
+ }
+
+ private Long resolveVehicleId(VehicleInspectionDto dto) {
+
+ if (dto.getVehId() != null) return dto.getVehId();
+
+ if (dto.getExternalVehicleId() != null) {
+ return vehicleExternalMapService
+ .findMapping("SAMSARA", dto.getExternalVehicleId())
+ .getVexVehicleId();
+ }
+
+ return null;
+ }
+
+ private Long resolveEmpId(VehicleInspectionDefectDto dto) {
+
+ if (dto.getResolvedBy() != null) return dto.getResolvedBy();
+
+ if (dto.getResolvedByExternalId() != null) {
+ return hcmEmployeeClient.getEmpIdFromExternalId(
+ "SAMSARA",
+ dto.getResolvedByExternalId()
+ );
+ }
+
+ return null;
+ }
+
+ private boolean shouldCreateDispatch(VehicleInspection inspection) {
+ return "preTrip".equalsIgnoreCase(inspection.getVeiInspectionType())
+ && inspection.getVeiDispatchId() == null
+ && inspection.getVeiVehId() != null
+ && inspection.getVeiDriverId() != null;
+ }
+}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 85c3fa1..1a444ff 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -35,4 +35,7 @@ server:
# ================================
hcm:
api:
- base-url: http://localhost:8081/hcm-rest-api
\ No newline at end of file
+ base-url: http://localhost:8081/hcm-rest-api
+internal:
+ integration:
+ token: ${INTEGRATION_INTERNAL_TOKEN}
\ No newline at end of file