diff --git a/src/main/java/com/goi/erp/service/HcmEmployeeClient.java b/src/main/java/com/goi/erp/client/HcmEmployeeClient.java similarity index 91% rename from src/main/java/com/goi/erp/service/HcmEmployeeClient.java rename to src/main/java/com/goi/erp/client/HcmEmployeeClient.java index 2a1aead..ac36c98 100644 --- a/src/main/java/com/goi/erp/service/HcmEmployeeClient.java +++ b/src/main/java/com/goi/erp/client/HcmEmployeeClient.java @@ -1,6 +1,7 @@ -package com.goi.erp.service; +package com.goi.erp.client; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.ParameterizedTypeReference; @@ -17,6 +18,7 @@ import com.goi.erp.token.PermissionAuthenticationToken; import java.util.Map; import java.util.UUID; +@Slf4j @Service @RequiredArgsConstructor public class HcmEmployeeClient { @@ -62,8 +64,10 @@ public class HcmEmployeeClient { return null; } catch (Exception e) { - // 필요하면 logging - System.out.println("externalId lookup error: " + e.getMessage()); + log.error( + "[EXTERNAL][GET] externalId lookup error: {}", + e.getMessage() + ); return null; } } @@ -105,8 +109,10 @@ public class HcmEmployeeClient { return null; } catch (Exception e) { - // 필요하면 로깅 - System.out.println("UUID lookup error: " + e.getMessage()); + log.error( + "[EXTERNAL][GET] UUID lookup error: {}", + e.getMessage() + ); return null; } } diff --git a/src/main/java/com/goi/erp/client/SamsaraVehicleStatGpsClient.java b/src/main/java/com/goi/erp/client/SamsaraVehicleStatGpsClient.java new file mode 100644 index 0000000..56e5101 --- /dev/null +++ b/src/main/java/com/goi/erp/client/SamsaraVehicleStatGpsClient.java @@ -0,0 +1,66 @@ +package com.goi.erp.client; + +import com.goi.erp.dto.VehicleStatGpsResponseDto; + +import lombok.extern.slf4j.Slf4j; + +import java.time.Duration; +import java.util.List; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +@Slf4j +@Service +public class SamsaraVehicleStatGpsClient { + + private final WebClient webClient; + + public SamsaraVehicleStatGpsClient( + WebClient.Builder webClientBuilder, + @Value("${integration.api.base-url}") String integrationBaseUrl + ) { + this.webClient = webClientBuilder + .baseUrl(integrationBaseUrl) + .build(); + } + + /** + * integration-service 를 통해 Samsara vehicle engine + GPS 상태 조회 + */ + public List fetchVehicleStats(List vehicleExternalIds) { + + if (vehicleExternalIds == null || vehicleExternalIds.isEmpty()) { + return List.of(); + } + + try { +// log.info( +// "vehicleIds={}", +// vehicleExternalIds +// ); + return webClient.get() + .uri(uriBuilder -> { + uriBuilder + .path("/vehicle/stat/gps"); + vehicleExternalIds.forEach( + id -> uriBuilder.queryParam("vehicleIds", id) + ); + return uriBuilder.build(); + }) + .retrieve() + .bodyToFlux(VehicleStatGpsResponseDto.class) + .collectList() + .block(Duration.ofSeconds(10)); // 10초 타임아웃 + + } catch (Exception e) { + log.error( + "Failed to fetch vehicle stats from integration-service. vehicleIds={}", + vehicleExternalIds, + e + ); + return List.of(); // 장애 시 전체 skip + } + } +} diff --git a/src/main/java/com/goi/erp/client/SysConfigClient.java b/src/main/java/com/goi/erp/client/SysConfigClient.java new file mode 100644 index 0000000..8e33ca0 --- /dev/null +++ b/src/main/java/com/goi/erp/client/SysConfigClient.java @@ -0,0 +1,162 @@ +package com.goi.erp.client; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import com.goi.erp.dto.SysConfigResponseDto; +import com.goi.erp.token.PermissionAuthenticationToken; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SysConfigClient { + + private final RestTemplate restTemplate; + @Value("${sys.api.base-url}") + private String sysBaseUrl; + + public SysConfigResponseDto getRaw(String module, String key) { + + String url = sysBaseUrl + "/config/" + module + "/" + key; + + try { + HttpEntity entity = new HttpEntity<>(buildHeaders()); + + ResponseEntity response = + restTemplate.exchange( + url, + HttpMethod.GET, + entity, + SysConfigResponseDto.class + ); + + return response.getBody(); + + } catch (Exception e) { + log.error( + "[SYS-CONFIG][GET] {}.{} failed: {}", + module, key, e.getMessage() + ); + return null; + } + } + + public String getString(String module, String key) { + SysConfigResponseDto cfg = getRequired(module, key); + assertType(cfg, "STRING"); + return cfg.getCfgValue(); + } + + public Integer getInt(String module, String key) { + SysConfigResponseDto cfg = getRequired(module, key); + assertType(cfg, "INT"); + return Integer.valueOf(cfg.getCfgValue()); + } + + public Long getLong(String module, String key) { + SysConfigResponseDto cfg = getRequired(module, key); + assertType(cfg, "INT"); // 정책: INT = Long 허용 + return Long.valueOf(cfg.getCfgValue()); + } + + public BigDecimal getDecimal(String module, String key) { + SysConfigResponseDto cfg = getRequired(module, key); + assertType(cfg, "DECIMAL"); + return new BigDecimal(cfg.getCfgValue()); + } + + public Boolean getBoolean(String module, String key) { + SysConfigResponseDto cfg = getRequired(module, key); + assertType(cfg, "BOOL"); + return Boolean.valueOf(cfg.getCfgValue()); + } + + public LocalDate getDate(String module, String key) { + SysConfigResponseDto cfg = getRequired(module, key); + assertType(cfg, "DATE"); + return LocalDate.parse(cfg.getCfgValue()); + } + + public LocalDateTime getDateTime(String module, String key) { + SysConfigResponseDto cfg = getRequired(module, key); + assertType(cfg, "DATETIME"); + return LocalDateTime.parse(cfg.getCfgValue()); + } + + // + private SysConfigResponseDto getRequired(String module, String key) { + SysConfigResponseDto cfg = getRaw(module, key); + if (cfg == null) { + log.error( + "[SYS-CONFIG][GET] {}.{} failed: {}", + module, key, "Config not found: " + module + "." + key + ); + throw new IllegalStateException( + "Config not found: " + module + "." + key + ); + } + if (cfg.getCfgValue() == null) { + log.error( + "[SYS-CONFIG][GET] {}.{} failed: {}", + module, key, "Config value is null: " + module + "." + key + ); + throw new IllegalStateException( + "Config value is null: " + module + "." + key + ); + } + return cfg; + } + + private void assertType(SysConfigResponseDto cfg, String expected) { + String actual = cfg.getCfgDataType(); + + if (actual == null) { + throw new IllegalStateException( + "Config data type is null: " + + cfg.getCfgModule() + "." + cfg.getCfgKey() + ); + } + + if (!expected.equalsIgnoreCase(actual)) { + throw new IllegalStateException( + "Config type mismatch: " + + cfg.getCfgModule() + "." + cfg.getCfgKey() + + " expected=" + expected + + " actual=" + actual + ); + } + } + + private HttpHeaders buildHeaders() { + HttpHeaders headers = new HttpHeaders(); + String jwt = getCurrentJwt(); + if (jwt != null) { + headers.set("Authorization", "Bearer " + jwt); + } + return headers; + } + + private String getCurrentJwt() { + var auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth instanceof PermissionAuthenticationToken token) { + return token.getJwt(); + } + return null; + } + + +} + diff --git a/src/main/java/com/goi/erp/common/util/GeoUtil.java b/src/main/java/com/goi/erp/common/util/GeoUtil.java new file mode 100644 index 0000000..69b4eeb --- /dev/null +++ b/src/main/java/com/goi/erp/common/util/GeoUtil.java @@ -0,0 +1,35 @@ +package com.goi.erp.common.util; + +public final class GeoUtil { + + private static final double EARTH_RADIUS_METERS = 6_371_000; + + private GeoUtil() { + // utility class + } + + /** + * 두 좌표 간 거리 (meters) + */ + public static double distanceMeters( + double lat1, + double lon1, + double lat2, + double lon2 + ) { + + double dLat = Math.toRadians(lat2 - lat1); + double dLon = Math.toRadians(lon2 - lon1); + + double a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(Math.toRadians(lat1)) + * Math.cos(Math.toRadians(lat2)) + * Math.sin(dLon / 2) * Math.sin(dLon / 2); + + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return EARTH_RADIUS_METERS * c; + } +} + diff --git a/src/main/java/com/goi/erp/controller/TestController.java b/src/main/java/com/goi/erp/controller/TestController.java index fd50c53..93c109b 100644 --- a/src/main/java/com/goi/erp/controller/TestController.java +++ b/src/main/java/com/goi/erp/controller/TestController.java @@ -1,9 +1,12 @@ package com.goi.erp.controller; import com.goi.erp.service.ExtSamsaraInspectionProcessor; +import com.goi.erp.service.VehicleDispatchAutoCloseService; import lombok.RequiredArgsConstructor; +import java.time.LocalDate; + import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -14,10 +17,22 @@ import org.springframework.web.bind.annotation.RestController; public class TestController { private final ExtSamsaraInspectionProcessor processor; + private final VehicleDispatchAutoCloseService autoCloseService; @PostMapping("/process-samsara-inspection") public String process() { processor.processUnprocessed(); return "OK"; } + + /** + * 차량 배차 자동 종료 테스트 (오늘 날짜 기준) + */ + @PostMapping("/auto-close-dispatch") + public String autoCloseDispatch() { + + autoCloseService.processAutoClose(LocalDate.now()); + + return "AUTO CLOSE OK"; + } } diff --git a/src/main/java/com/goi/erp/controller/VehicleDispatchController.java b/src/main/java/com/goi/erp/controller/VehicleDispatchController.java index e991a00..cd65547 100644 --- a/src/main/java/com/goi/erp/controller/VehicleDispatchController.java +++ b/src/main/java/com/goi/erp/controller/VehicleDispatchController.java @@ -24,10 +24,11 @@ import java.time.Duration; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; import java.util.UUID; @RestController -@RequestMapping("/api/vehicle-dispatch") +@RequestMapping("/vehicle-dispatch") @RequiredArgsConstructor public class VehicleDispatchController { @@ -234,6 +235,31 @@ public class VehicleDispatchController { dispatchRepository.findByVedDispatchDate(date) ); } + + /* ============================================================ + 누락된 dispatch 찾아서 재 배차 /redispatch?date=2025-12-24 + ============================================================ */ + @PostMapping("/redispatch") + public ResponseEntity> redispatch( + @RequestParam LocalDate date + ) { + 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 redispatch"); + } + + Map result = dispatchService.redispatchByDate(date); + + return ResponseEntity.ok(result); + } + /* ============================================================ JOB TRIGGER diff --git a/src/main/java/com/goi/erp/dto/SysConfigResponseDto.java b/src/main/java/com/goi/erp/dto/SysConfigResponseDto.java new file mode 100644 index 0000000..e8b1f69 --- /dev/null +++ b/src/main/java/com/goi/erp/dto/SysConfigResponseDto.java @@ -0,0 +1,23 @@ +package com.goi.erp.dto; + +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +public class SysConfigResponseDto { + + private Long cfgId; + private UUID cfgUuid; + + private String cfgModule; + private String cfgKey; + private String cfgValue; + private String cfgDataType; + + private LocalDateTime cfgCreatedAt; + private LocalDateTime cfgUpdatedAt; + private String cfgCreatedBy; + private String cfgUpdatedBy; +} diff --git a/src/main/java/com/goi/erp/dto/VehicleDispatchRequestDto.java b/src/main/java/com/goi/erp/dto/VehicleDispatchRequestDto.java index 748a15a..6cec0bc 100644 --- a/src/main/java/com/goi/erp/dto/VehicleDispatchRequestDto.java +++ b/src/main/java/com/goi/erp/dto/VehicleDispatchRequestDto.java @@ -19,6 +19,7 @@ public class VehicleDispatchRequestDto { private Integer vedShift; private String vedStatus; private LocalDateTime vedStartAt; + private String vedStartReason; private LocalDateTime vedPausedAt; private LocalDateTime vedEndAt; private String vedEndReason; diff --git a/src/main/java/com/goi/erp/dto/VehicleDispatchResponseDto.java b/src/main/java/com/goi/erp/dto/VehicleDispatchResponseDto.java index bb0d74e..db44afa 100644 --- a/src/main/java/com/goi/erp/dto/VehicleDispatchResponseDto.java +++ b/src/main/java/com/goi/erp/dto/VehicleDispatchResponseDto.java @@ -30,6 +30,7 @@ public class VehicleDispatchResponseDto { private LocalDate vedDispatchDate; private Integer vedShift; private LocalDateTime vedStartAt; + private String vedStartReason; private LocalDateTime vedPausedAt; private LocalDateTime vedEndAt; private String vedEndReason; diff --git a/src/main/java/com/goi/erp/dto/VehicleStatGpsResponseDto.java b/src/main/java/com/goi/erp/dto/VehicleStatGpsResponseDto.java new file mode 100644 index 0000000..01d3122 --- /dev/null +++ b/src/main/java/com/goi/erp/dto/VehicleStatGpsResponseDto.java @@ -0,0 +1,22 @@ +package com.goi.erp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.Instant; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class VehicleStatGpsResponseDto { + + private String vesVehicleExternalId; // Samsara vehicle external ID + private Boolean vesEngineOn; // Engine state: true = On, false = Off, null = unknown + private BigDecimal vesLatitude; // Latest GPS latitude + private BigDecimal vesLongitude; // Latest GPS longitude + private Instant vesGpsTime; // GPS timestamp (UTC, from Samsara) +} diff --git a/src/main/java/com/goi/erp/entity/VehicleDispatch.java b/src/main/java/com/goi/erp/entity/VehicleDispatch.java index cb2483e..2ea5153 100644 --- a/src/main/java/com/goi/erp/entity/VehicleDispatch.java +++ b/src/main/java/com/goi/erp/entity/VehicleDispatch.java @@ -66,6 +66,9 @@ public class VehicleDispatch { @Column(name = "ved_start_at") private LocalDateTime vedStartAt; + @Column(name = "ved_start_reason") + private String vedStartReason; + @Column(name = "ved_paused_at") private LocalDateTime vedPausedAt; diff --git a/src/main/java/com/goi/erp/repository/VehicleDispatchRepository.java b/src/main/java/com/goi/erp/repository/VehicleDispatchRepository.java index 0e7c3af..e3fdc62 100644 --- a/src/main/java/com/goi/erp/repository/VehicleDispatchRepository.java +++ b/src/main/java/com/goi/erp/repository/VehicleDispatchRepository.java @@ -6,6 +6,7 @@ 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 org.springframework.data.repository.query.Param; import java.time.LocalDate; import java.time.LocalDateTime; @@ -27,9 +28,19 @@ public interface VehicleDispatchRepository extends JpaRepository 'C' """) - Optional findOpenDispatchByVehId(Long vehId); + Optional findOpenDispatchByVehId(Long vehId, LocalDate dispatchDate); + + // + @Query(""" + SELECT d + FROM VehicleDispatch d + WHERE d.vedDispatchDate = :dispatchDate + AND d.vedStatus <> 'C' + """) + List findAllNotClosed(LocalDate dispatchDate); // @Query(""" @@ -54,4 +65,26 @@ public interface VehicleDispatchRepository extends JpaRepository= :closedAfter + AND NOT EXISTS ( + SELECT 1 + FROM VehicleDispatch x + WHERE x.vedVehId = d.vedVehId + AND x.vedDispatchDate = :dispatchDate + AND x.vedStatus <> 'C' + ) + + """) + List findAutoClosedCandidatesForReopen( + @Param("dispatchDate") LocalDate dispatchDate, + @Param("closeReason") String closeReason, + @Param("closedAfter") LocalDateTime closedAfter + ); } diff --git a/src/main/java/com/goi/erp/repository/VehicleExternalMapRepository.java b/src/main/java/com/goi/erp/repository/VehicleExternalMapRepository.java index 2d50fb8..a34c64a 100644 --- a/src/main/java/com/goi/erp/repository/VehicleExternalMapRepository.java +++ b/src/main/java/com/goi/erp/repository/VehicleExternalMapRepository.java @@ -4,6 +4,7 @@ import com.goi.erp.entity.VehicleExternalMap; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository @@ -14,4 +15,11 @@ public interface VehicleExternalMapRepository extends JpaRepository findAllByVexSolutionTypeAndVexVehicleIdInAndVexStatus( + String solutionType, + List vexVehicleId, + String vexStatus + ); + } diff --git a/src/main/java/com/goi/erp/repository/VehicleInspectionRepository.java b/src/main/java/com/goi/erp/repository/VehicleInspectionRepository.java index a98bc30..8e323d0 100644 --- a/src/main/java/com/goi/erp/repository/VehicleInspectionRepository.java +++ b/src/main/java/com/goi/erp/repository/VehicleInspectionRepository.java @@ -3,9 +3,11 @@ package com.goi.erp.repository; import com.goi.erp.entity.VehicleInspection; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import java.time.LocalDate; +import java.util.List; import java.util.Optional; @Repository @@ -13,4 +15,13 @@ public interface VehicleInspectionRepository extends JpaRepository findByVeiSourceAndVeiSourceId(String veiSource, Long veiSourceId); Optional findByVeiVehIdAndVeiInspectionDate(Long veiVehId, LocalDate veiInspectionDate); + + @Query(""" + select i + from VehicleInspection i + where i.veiInspectionType = 'preTrip' + and i.veiInspectionDate = :date + and i.veiDispatchId is null + """) + List findUnlinkedPreTripByDate(LocalDate date); } diff --git a/src/main/java/com/goi/erp/service/VehicleDispatchAutoCloseService.java b/src/main/java/com/goi/erp/service/VehicleDispatchAutoCloseService.java new file mode 100644 index 0000000..0f9f593 --- /dev/null +++ b/src/main/java/com/goi/erp/service/VehicleDispatchAutoCloseService.java @@ -0,0 +1,285 @@ +package com.goi.erp.service; + +import com.goi.erp.client.SamsaraVehicleStatGpsClient; +import com.goi.erp.client.SysConfigClient; +import com.goi.erp.common.util.GeoUtil; +import com.goi.erp.dto.VehicleStatGpsResponseDto; +import com.goi.erp.entity.VehicleDispatch; +import com.goi.erp.repository.VehicleDispatchRepository; + +import jakarta.transaction.Transactional; +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.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class VehicleDispatchAutoCloseService { + /* + * O → P : HOME 근접 + engine OFF + * P → C : HOME 근접 + engine OFF + paused_at ≥ N분 + */ + + private final VehicleDispatchRepository vehicleDispatchRepository; + private final VehicleExternalMapService vehicleExternalMapService; + private final SamsaraVehicleStatGpsClient vehicleStatClient; + private final SysConfigClient sysConfigClient; + + @Transactional + public void processAutoClose(LocalDate dispatchDate) { + // config + BigDecimal homeLat = sysConfigClient.getDecimal("SYS", "HOME_LATITUDE"); + BigDecimal homeLon = sysConfigClient.getDecimal("SYS", "HOME_LONGITUDE"); + Integer radiusMeters = sysConfigClient.getInt("OPR", "HOME_RADIUS_METERS"); + Integer pausedMinutes = sysConfigClient.getInt("OPR", "PAUSED_CLOSE_MINUTES"); + + // 0. auto open check 먼저 + processAutoOpen(dispatchDate, homeLat, homeLon, radiusMeters); + + // 1. 대상 vehicle_dispatch 조회 (C 제외) + List targets = vehicleDispatchRepository.findAllNotClosed(dispatchDate); + if (targets.isEmpty()) return; + + // 2. external vehicleIds 수집 + List vehicleIds = targets.stream() + .map(VehicleDispatch::getVedVehId) + .filter(Objects::nonNull) + .distinct() + .toList(); + if (vehicleIds.isEmpty()) return; + + + // 3. internal vehicleId -> samsara externalId 매핑 bulk 조회 + Map vehicleIdToExternalId = vehicleExternalMapService.findExternalIdsByVehicleIds("SAMSARA", vehicleIds); + + List externalIds = vehicleIdToExternalId.values().stream() + .filter(Objects::nonNull) + .distinct() + .toList(); + if (externalIds.isEmpty()) return; + + // 4. call + List stats = vehicleStatClient.fetchVehicleStats(externalIds); + if (stats.isEmpty()) return; + + // 5. response + Map statMap = stats.stream() + .collect(Collectors.toMap( + VehicleStatGpsResponseDto::getVesVehicleExternalId, + s -> s, + (a, b) -> a + )); + + // 6. dispatch별 처리 + for (VehicleDispatch dispatch : targets) { + + Long internalVehicleId = dispatch.getVedVehId(); + if (internalVehicleId == null) continue; + + String externalId = vehicleIdToExternalId.get(internalVehicleId); + if (externalId == null) continue; + + VehicleStatGpsResponseDto stat = statMap.get(externalId); + if (stat == null) continue; + + // 관측값 계산 + boolean engineOn = Boolean.TRUE.equals(stat.getVesEngineOn()); + + double distanceMeters = GeoUtil.distanceMeters( + homeLat.doubleValue(), + homeLon.doubleValue(), + stat.getVesLatitude().doubleValue(), + stat.getVesLongitude().doubleValue() + ); + + boolean isAtHome = distanceMeters <= radiusMeters; + boolean isStoppedAtHome = !engineOn && isAtHome; + + // 상태 변경 + String status = dispatch.getVedStatus(); + + // P → O (다시 출발) + if ("P".equals(status) && !isStoppedAtHome) { + transitionToOnRoute(dispatch); + continue; + } + + // O -> P (도착 후 정차) + if ("O".equals(status) && isStoppedAtHome) { + transitionToPaused(dispatch); + continue; + } + + // P -> C (장시간 정차) + if ("P".equals(status) && isStoppedAtHome) { + + if (dispatch.getVedPausedAt() == null) { + continue; + } + + long pausedMin = Duration.between( + dispatch.getVedPausedAt(), + LocalDateTime.now() + ).toMinutes(); + + if (pausedMin >= pausedMinutes) { + transitionToClosed(dispatch, "AUTO_CLOSE_HOME_IDLE"); + } + } + } + } + + private void processAutoOpen( + LocalDate dispatchDate, + BigDecimal homeLat, + BigDecimal homeLon, + Integer radiusMeters + ) { + + // AUTO_OPEN 시간 윈도우 (예: 최근 2시간) + LocalDateTime closedAfter = LocalDateTime.now().minusHours(2); + + // 1. AUTO_CLOSE된 후보 조회 + List closedCandidates = + vehicleDispatchRepository.findAutoClosedCandidatesForReopen( + dispatchDate, + "AUTO_CLOSE_HOME_IDLE", + closedAfter + ); + + if (closedCandidates.isEmpty()) return; + + // 2. vehicleId 수집 + List vehicleIds = closedCandidates.stream() + .map(VehicleDispatch::getVedVehId) + .filter(Objects::nonNull) + .distinct() + .toList(); + if (vehicleIds.isEmpty()) return; + + // 3. externalId 매핑 + Map vehicleIdToExternalId = + vehicleExternalMapService.findExternalIdsByVehicleIds( + "SAMSARA", + vehicleIds + ); + + List externalIds = vehicleIdToExternalId.values().stream() + .filter(Objects::nonNull) + .distinct() + .toList(); + if (externalIds.isEmpty()) return; + + // 4. stats 조회 + List stats = vehicleStatClient.fetchVehicleStats(externalIds); + if (stats.isEmpty()) return; + + Map statMap = stats.stream() + .collect(Collectors.toMap( + VehicleStatGpsResponseDto::getVesVehicleExternalId, + s -> s, + (a, b) -> a + )); + + // 5. AUTO-OPEN 판단 + for (VehicleDispatch closed : closedCandidates) { + + Long vehicleId = closed.getVedVehId(); + String externalId = vehicleIdToExternalId.get(vehicleId); + if (externalId == null) continue; + + VehicleStatGpsResponseDto stat = statMap.get(externalId); + if (stat == null) continue; + + boolean engineOn = Boolean.TRUE.equals(stat.getVesEngineOn()); + + double distanceMeters = GeoUtil.distanceMeters( + homeLat.doubleValue(), + homeLon.doubleValue(), + stat.getVesLatitude().doubleValue(), + stat.getVesLongitude().doubleValue() + ); + + boolean leftHome = distanceMeters > radiusMeters; + + // ⭐ AUTO-OPEN 조건 + if (engineOn && leftHome) { + + createNewDispatchFromAutoClose(closed); + + log.info( + "[AUTO-OPEN] New dispatch created from AUTO_CLOSE. prevDispatchId={}", + closed.getVedId() + ); + } + } + } + + + private void transitionToOnRoute(VehicleDispatch dispatch) { + dispatch.setVedStatus("O"); + dispatch.setVedPausedAt(null); + vehicleDispatchRepository.save(dispatch); + + log.info( + "[AUTO-CLOSE] Dispatch {} → O (re-open)", + dispatch.getVedId() + ); + } + + private void transitionToPaused(VehicleDispatch dispatch) { + dispatch.setVedStatus("P"); + dispatch.setVedPausedAt(LocalDateTime.now()); + vehicleDispatchRepository.save(dispatch); + + log.info( + "[AUTO-CLOSE] Dispatch {} → P (paused)", + dispatch.getVedId() + ); + } + + private void transitionToClosed(VehicleDispatch dispatch, String reason) { + dispatch.setVedStatus("C"); + dispatch.setVedEndAt(LocalDateTime.now()); + dispatch.setVedEndReason(reason); + vehicleDispatchRepository.save(dispatch); + + log.info( + "[AUTO-CLOSE] Dispatch {} → C (closed)", + dispatch.getVedId() + ); + } + + private void createNewDispatchFromAutoClose(VehicleDispatch closed) { + + VehicleDispatch newDispatch = VehicleDispatch.builder() + .vedUuid(UUID.randomUUID()) + .vedDriverId(closed.getVedDriverId()) + .vedSubDriverId(closed.getVedSubDriverId()) + .vedVehId(closed.getVedVehId()) + .vedDispatchDate(closed.getVedDispatchDate()) + .vedShift(closed.getVedShift() + 1) + .vedStartAt(LocalDateTime.now()) + .vedOdometerStart(closed.getVedOdometerEnd()) + .vedStatus("O") + .vedSource("AUTO") + .vedStartReason("AUTO_OPEN_LEAVE_HOME") + .build(); + + vehicleDispatchRepository.save(newDispatch); + } + +} + diff --git a/src/main/java/com/goi/erp/service/VehicleDispatchService.java b/src/main/java/com/goi/erp/service/VehicleDispatchService.java index 18ebc2f..032db82 100644 --- a/src/main/java/com/goi/erp/service/VehicleDispatchService.java +++ b/src/main/java/com/goi/erp/service/VehicleDispatchService.java @@ -4,7 +4,9 @@ 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 com.goi.erp.repository.VehicleInspectionRepository; +import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -15,6 +17,8 @@ import java.time.Duration; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.UUID; @Slf4j @@ -23,6 +27,14 @@ import java.util.UUID; public class VehicleDispatchService { private final VehicleDispatchRepository dispatchRepository; + private final VehicleInspectionRepository inspectionRepository; + // 누락된 재배차 로직 + public enum RedispatchAction { + CREATED, + LINKED, + SKIPPED + } + /* =============================== * CREATE @@ -34,7 +46,8 @@ public class VehicleDispatchService { LocalDate dispatchDate, LocalDateTime startAt, BigDecimal startOdometer, - String source + String source, + String startReason ) { Integer nextShift = dispatchRepository.findNextShift(vehId, dispatchDate); @@ -48,6 +61,7 @@ public class VehicleDispatchService { .vedStartAt(startAt) .vedOdometerStart(startOdometer) .vedStatus("O") + .vedStartReason(source != null ? source : "PRE_INSPECTION") .vedSource(source != null ? source : "AUTO") .build(); @@ -58,9 +72,7 @@ public class VehicleDispatchService { VehicleDispatchRequestDto dto, String createdBy ) { - VehicleDispatch opened = - dispatchRepository.findOpenDispatchByVehId(dto.getVedVehId()) - .orElse(null); + VehicleDispatch opened = dispatchRepository.findOpenDispatchByVehId(dto.getVedVehId(), dto.getVedDispatchDate()).orElse(null); if (opened != null) return opened; @@ -137,9 +149,7 @@ public class VehicleDispatchService { * =============================== */ public void evaluateDispatchCloseByInspection(VehicleInspection inspection) { - VehicleDispatch open = - dispatchRepository.findOpenDispatchByVehId(inspection.getVeiVehId()) - .orElse(null); + VehicleDispatch open = dispatchRepository.findOpenDispatchByVehId(inspection.getVeiVehId(), inspection.getVeiInspectionDate()).orElse(null); if (open == null) { log.info( @@ -259,6 +269,112 @@ public class VehicleDispatchService { return dispatchRepository.save(d); } + + @Transactional + public Map redispatchByDate(LocalDate date) { + + int total = 0; + int created = 0; + int linked = 0; + int skipped = 0; + + // 1. 해당 날짜 + 배차 안 된 preTrip inspection + List inspections = + inspectionRepository.findUnlinkedPreTripByDate(date); + + for (VehicleInspection inspection : inspections) { + + total++; + + try { + RedispatchAction action = redispatchSingle(inspection); + + switch (action) { + case LINKED -> linked++; + case CREATED -> created++; + case SKIPPED -> skipped++; + } + + } catch (Exception e) { + skipped++; + log.error( + "[REDISPATCH][FAIL] inspectionId={} vehId={} driverId={}", + inspection.getVeiId(), + inspection.getVeiVehId(), + inspection.getVeiDriverId(), + e + ); + } + } + + return Map.of( + "dispatchDate", date, + "totalInspections", total, + "created", created, + "linked", linked, + "skipped", skipped + ); + } + + private RedispatchAction redispatchSingle(VehicleInspection inspection) { + + // 필수값 체크 + if (inspection.getVeiVehId() == null || inspection.getVeiDriverId() == null) { + log.warn( + "[REDISPATCH][SKIP] missing veh/driver inspectionId={}", + inspection.getVeiId() + ); + return RedispatchAction.SKIPPED; + } + + // 같은 날짜 + 차량 open dispatch 있는지 + Optional open = dispatchRepository.findOpenDispatchByVehId(inspection.getVeiVehId(), inspection.getVeiInspectionDate()); + + if (open.isPresent()) { + inspection.setVeiDispatchId(open.get().getVedId()); + inspectionRepository.save(inspection); + + log.info( + "[REDISPATCH][LINK] inspectionId={} → dispatchId={}", + inspection.getVeiId(), + open.get().getVedId() + ); + + return RedispatchAction.LINKED; + } + + // 없으면 새 dispatch 생성 + BigDecimal odometerStart = + inspection.getVeiOdometer() != null + ? BigDecimal.valueOf(inspection.getVeiOdometer()) + : null; + + VehicleDispatch dispatch = + createFromPreTripInspection( + inspection.getVeiVehId(), + inspection.getVeiDriverId(), + inspection.getVeiSubDriverId(), + inspection.getVeiInspectionDate(), + inspection.getVeiStartAt(), + odometerStart, + "REDISPATCH", + "PRE_INSPECTION" + ); + + inspection.setVeiDispatchId(dispatch.getVedId()); + inspectionRepository.save(inspection); + + log.info( + "[REDISPATCH][CREATE] inspectionId={} → dispatchId={}", + inspection.getVeiId(), + dispatch.getVedId() + ); + + return RedispatchAction.CREATED; + } + + + // diff --git a/src/main/java/com/goi/erp/service/VehicleExternalMapService.java b/src/main/java/com/goi/erp/service/VehicleExternalMapService.java index 88ef9ca..7ca4d6b 100644 --- a/src/main/java/com/goi/erp/service/VehicleExternalMapService.java +++ b/src/main/java/com/goi/erp/service/VehicleExternalMapService.java @@ -5,9 +5,16 @@ import com.goi.erp.entity.VehicleExternalMap; import com.goi.erp.repository.VehicleExternalMapRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +@Slf4j @Service @RequiredArgsConstructor public class VehicleExternalMapService { @@ -15,8 +22,7 @@ public class VehicleExternalMapService { private final VehicleExternalMapRepository externalMapRepository; /** - * 외부 솔루션의 ID로 내부 employee 매핑 정보 조회 - * 목적: customer_daily_order.driver_id 에 들어갈 내부 emp_id 조회 + * 외부 솔루션의 ID로 내부 vehicle 매핑 정보 조회 (단건) */ @Transactional(readOnly = true) public VehicleExternalMapResponseDto findMapping(String solutionType, String externalId) { @@ -28,10 +34,11 @@ public class VehicleExternalMapService { "A" // 활성 매핑만 사용 ) .orElseThrow(() -> - new RuntimeException("Vehicle external mapping not found for externalId: " + externalId) + new RuntimeException( + "Vehicle external mapping not found for externalId: " + externalId + ) ); - // Entity → ResponseDto 변환 VehicleExternalMapResponseDto dto = new VehicleExternalMapResponseDto(); dto.setVexUuid(map.getVexUuid()); dto.setVexVehicleId(map.getVexVehicleId()); @@ -43,4 +50,46 @@ public class VehicleExternalMapService { return dto; } + + /** + * 내부 vehicleId 목록으로 외부 솔루션 ID bulk 조회 + * + * @return Map + */ + @Transactional(readOnly = true) + public Map findExternalIdsByVehicleIds( + String solutionType, + List vehicleIds + ) { + + if (vehicleIds == null || vehicleIds.isEmpty()) { + return Map.of(); + } + + List maps = + externalMapRepository + .findAllByVexSolutionTypeAndVexVehicleIdInAndVexStatus( + solutionType, + vehicleIds, + "A" // 활성 매핑만 + ); + + if (maps.isEmpty()) { + log.warn( + "[VEHICLE-EXT-MAP] No active mappings found. solutionType={}, vehicleIds={}", + solutionType, + vehicleIds + ); + return Map.of(); + } + + // internal vehicleId -> externalId + return maps.stream() + .filter(m -> m.getVexVehicleId() != null && m.getVexExternalId() != null) + .collect(Collectors.toMap( + VehicleExternalMap::getVexVehicleId, + VehicleExternalMap::getVexExternalId, + (a, b) -> a // 중복 시 첫 번째 유지 + )); + } } diff --git a/src/main/java/com/goi/erp/service/VehicleInspectionService.java b/src/main/java/com/goi/erp/service/VehicleInspectionService.java index 65e02ce..2bd8c7d 100644 --- a/src/main/java/com/goi/erp/service/VehicleInspectionService.java +++ b/src/main/java/com/goi/erp/service/VehicleInspectionService.java @@ -1,5 +1,6 @@ package com.goi.erp.service; +import com.goi.erp.client.HcmEmployeeClient; import com.goi.erp.dto.VehicleInspectionDefectDto; import com.goi.erp.dto.VehicleInspectionDto; import com.goi.erp.entity.VehicleDispatch; @@ -90,7 +91,7 @@ public class VehicleInspectionService { // - odometerStart = inspection.odometer (가능하면) if (shouldCreateDispatch(inspection)) { // open 여부 - boolean hasOpenDispatch = dispatchRepository.findOpenDispatchByVehId(inspection.getVeiVehId()).isPresent(); + boolean hasOpenDispatch = dispatchRepository.findOpenDispatchByVehId(inspection.getVeiVehId(), inspection.getVeiInspectionDate()).isPresent(); if (!hasOpenDispatch) { @@ -107,7 +108,8 @@ public class VehicleInspectionService { inspection.getVeiInspectionDate(), inspection.getVeiStartAt(), odometerStart, - "AUTO" + "AUTO", + "PRE_INSPECTION" ); // inspection에 dispatch 연결 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1a444ff..09d4e36 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -36,6 +36,12 @@ server: hcm: api: base-url: http://localhost:8081/hcm-rest-api +sys: + api: + base-url: http://localhost:8090/sys-rest-api +integration: + api: + base-url: http://localhost:8091/integration-service internal: integration: token: ${INTEGRATION_INTERNAL_TOKEN} \ No newline at end of file