[VehicleDispatch]

- Added dispatch data from inspection
- Added AutoClose to auto close idle + home vehicles

[Config]
- Added get configurations from sys-rest-api
This commit is contained in:
Hyojin Ahn 2026-01-12 12:28:16 -05:00
parent 3347de524f
commit 923b6d5aca
19 changed files with 891 additions and 21 deletions

View File

@ -1,6 +1,7 @@
package com.goi.erp.service; package com.goi.erp.client;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.ParameterizedTypeReference;
@ -17,6 +18,7 @@ import com.goi.erp.token.PermissionAuthenticationToken;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
@Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class HcmEmployeeClient { public class HcmEmployeeClient {
@ -62,8 +64,10 @@ public class HcmEmployeeClient {
return null; return null;
} catch (Exception e) { } catch (Exception e) {
// 필요하면 logging log.error(
System.out.println("externalId lookup error: " + e.getMessage()); "[EXTERNAL][GET] externalId lookup error: {}",
e.getMessage()
);
return null; return null;
} }
} }
@ -105,8 +109,10 @@ public class HcmEmployeeClient {
return null; return null;
} catch (Exception e) { } catch (Exception e) {
// 필요하면 로깅 log.error(
System.out.println("UUID lookup error: " + e.getMessage()); "[EXTERNAL][GET] UUID lookup error: {}",
e.getMessage()
);
return null; return null;
} }
} }

View File

@ -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<VehicleStatGpsResponseDto> fetchVehicleStats(List<String> 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
}
}
}

View File

@ -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<Void> entity = new HttpEntity<>(buildHeaders());
ResponseEntity<SysConfigResponseDto> 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;
}
}

View File

@ -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;
}
}

View File

@ -1,9 +1,12 @@
package com.goi.erp.controller; package com.goi.erp.controller;
import com.goi.erp.service.ExtSamsaraInspectionProcessor; import com.goi.erp.service.ExtSamsaraInspectionProcessor;
import com.goi.erp.service.VehicleDispatchAutoCloseService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import java.time.LocalDate;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@ -14,10 +17,22 @@ import org.springframework.web.bind.annotation.RestController;
public class TestController { public class TestController {
private final ExtSamsaraInspectionProcessor processor; private final ExtSamsaraInspectionProcessor processor;
private final VehicleDispatchAutoCloseService autoCloseService;
@PostMapping("/process-samsara-inspection") @PostMapping("/process-samsara-inspection")
public String process() { public String process() {
processor.processUnprocessed(); processor.processUnprocessed();
return "OK"; return "OK";
} }
/**
* 차량 배차 자동 종료 테스트 (오늘 날짜 기준)
*/
@PostMapping("/auto-close-dispatch")
public String autoCloseDispatch() {
autoCloseService.processAutoClose(LocalDate.now());
return "AUTO CLOSE OK";
}
} }

View File

@ -24,10 +24,11 @@ import java.time.Duration;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
@RestController @RestController
@RequestMapping("/api/vehicle-dispatch") @RequestMapping("/vehicle-dispatch")
@RequiredArgsConstructor @RequiredArgsConstructor
public class VehicleDispatchController { public class VehicleDispatchController {
@ -234,6 +235,31 @@ public class VehicleDispatchController {
dispatchRepository.findByVedDispatchDate(date) dispatchRepository.findByVedDispatchDate(date)
); );
} }
/* ============================================================
누락된 dispatch 찾아서 배차 /redispatch?date=2025-12-24
============================================================ */
@PostMapping("/redispatch")
public ResponseEntity<Map<String, Object>> 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<String, Object> result = dispatchService.redispatchByDate(date);
return ResponseEntity.ok(result);
}
/* ============================================================ /* ============================================================
JOB TRIGGER JOB TRIGGER

View File

@ -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;
}

View File

@ -19,6 +19,7 @@ public class VehicleDispatchRequestDto {
private Integer vedShift; private Integer vedShift;
private String vedStatus; private String vedStatus;
private LocalDateTime vedStartAt; private LocalDateTime vedStartAt;
private String vedStartReason;
private LocalDateTime vedPausedAt; private LocalDateTime vedPausedAt;
private LocalDateTime vedEndAt; private LocalDateTime vedEndAt;
private String vedEndReason; private String vedEndReason;

View File

@ -30,6 +30,7 @@ public class VehicleDispatchResponseDto {
private LocalDate vedDispatchDate; private LocalDate vedDispatchDate;
private Integer vedShift; private Integer vedShift;
private LocalDateTime vedStartAt; private LocalDateTime vedStartAt;
private String vedStartReason;
private LocalDateTime vedPausedAt; private LocalDateTime vedPausedAt;
private LocalDateTime vedEndAt; private LocalDateTime vedEndAt;
private String vedEndReason; private String vedEndReason;

View File

@ -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)
}

View File

@ -66,6 +66,9 @@ public class VehicleDispatch {
@Column(name = "ved_start_at") @Column(name = "ved_start_at")
private LocalDateTime vedStartAt; private LocalDateTime vedStartAt;
@Column(name = "ved_start_reason")
private String vedStartReason;
@Column(name = "ved_paused_at") @Column(name = "ved_paused_at")
private LocalDateTime vedPausedAt; private LocalDateTime vedPausedAt;

View File

@ -6,6 +6,7 @@ import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@ -27,9 +28,19 @@ public interface VehicleDispatchRepository extends JpaRepository<VehicleDispatch
SELECT d SELECT d
FROM VehicleDispatch d FROM VehicleDispatch d
WHERE d.vedVehId = :vehId WHERE d.vedVehId = :vehId
AND d.vedStatus IN ('O','P') AND d.vedDispatchDate = :dispatchDate
AND d.vedStatus <> 'C'
""") """)
Optional<VehicleDispatch> findOpenDispatchByVehId(Long vehId); Optional<VehicleDispatch> findOpenDispatchByVehId(Long vehId, LocalDate dispatchDate);
//
@Query("""
SELECT d
FROM VehicleDispatch d
WHERE d.vedDispatchDate = :dispatchDate
AND d.vedStatus <> 'C'
""")
List<VehicleDispatch> findAllNotClosed(LocalDate dispatchDate);
// //
@Query(""" @Query("""
@ -54,4 +65,26 @@ public interface VehicleDispatchRepository extends JpaRepository<VehicleDispatch
Integer max = findMaxShift(vehId, date); Integer max = findMaxShift(vehId, date);
return max == null ? 0 : max + 1; return max == null ? 0 : max + 1;
} }
@Query("""
SELECT d
FROM VehicleDispatch d
WHERE d.vedDispatchDate = :dispatchDate
AND d.vedStatus = 'C'
AND d.vedEndReason = :closeReason
AND d.vedEndAt >= :closedAfter
AND NOT EXISTS (
SELECT 1
FROM VehicleDispatch x
WHERE x.vedVehId = d.vedVehId
AND x.vedDispatchDate = :dispatchDate
AND x.vedStatus <> 'C'
)
""")
List<VehicleDispatch> findAutoClosedCandidatesForReopen(
@Param("dispatchDate") LocalDate dispatchDate,
@Param("closeReason") String closeReason,
@Param("closedAfter") LocalDateTime closedAfter
);
} }

View File

@ -4,6 +4,7 @@ import com.goi.erp.entity.VehicleExternalMap;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional; import java.util.Optional;
@Repository @Repository
@ -14,4 +15,11 @@ public interface VehicleExternalMapRepository extends JpaRepository<VehicleExter
String vexExternalId, String vexExternalId,
String vexStatus String vexStatus
); );
List<VehicleExternalMap> findAllByVexSolutionTypeAndVexVehicleIdInAndVexStatus(
String solutionType,
List<Long> vexVehicleId,
String vexStatus
);
} }

View File

@ -3,9 +3,11 @@ package com.goi.erp.repository;
import com.goi.erp.entity.VehicleInspection; import com.goi.erp.entity.VehicleInspection;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List;
import java.util.Optional; import java.util.Optional;
@Repository @Repository
@ -13,4 +15,13 @@ public interface VehicleInspectionRepository extends JpaRepository<VehicleInspec
Optional<VehicleInspection> findByVeiSourceAndVeiSourceId(String veiSource, Long veiSourceId); Optional<VehicleInspection> findByVeiSourceAndVeiSourceId(String veiSource, Long veiSourceId);
Optional<VehicleInspection> findByVeiVehIdAndVeiInspectionDate(Long veiVehId, LocalDate veiInspectionDate); Optional<VehicleInspection> 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<VehicleInspection> findUnlinkedPreTripByDate(LocalDate date);
} }

View File

@ -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<VehicleDispatch> targets = vehicleDispatchRepository.findAllNotClosed(dispatchDate);
if (targets.isEmpty()) return;
// 2. external vehicleIds 수집
List<Long> vehicleIds = targets.stream()
.map(VehicleDispatch::getVedVehId)
.filter(Objects::nonNull)
.distinct()
.toList();
if (vehicleIds.isEmpty()) return;
// 3. internal vehicleId -> samsara externalId 매핑 bulk 조회
Map<Long, String> vehicleIdToExternalId = vehicleExternalMapService.findExternalIdsByVehicleIds("SAMSARA", vehicleIds);
List<String> externalIds = vehicleIdToExternalId.values().stream()
.filter(Objects::nonNull)
.distinct()
.toList();
if (externalIds.isEmpty()) return;
// 4. call
List<VehicleStatGpsResponseDto> stats = vehicleStatClient.fetchVehicleStats(externalIds);
if (stats.isEmpty()) return;
// 5. response
Map<String, VehicleStatGpsResponseDto> 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<VehicleDispatch> closedCandidates =
vehicleDispatchRepository.findAutoClosedCandidatesForReopen(
dispatchDate,
"AUTO_CLOSE_HOME_IDLE",
closedAfter
);
if (closedCandidates.isEmpty()) return;
// 2. vehicleId 수집
List<Long> vehicleIds = closedCandidates.stream()
.map(VehicleDispatch::getVedVehId)
.filter(Objects::nonNull)
.distinct()
.toList();
if (vehicleIds.isEmpty()) return;
// 3. externalId 매핑
Map<Long, String> vehicleIdToExternalId =
vehicleExternalMapService.findExternalIdsByVehicleIds(
"SAMSARA",
vehicleIds
);
List<String> externalIds = vehicleIdToExternalId.values().stream()
.filter(Objects::nonNull)
.distinct()
.toList();
if (externalIds.isEmpty()) return;
// 4. stats 조회
List<VehicleStatGpsResponseDto> stats = vehicleStatClient.fetchVehicleStats(externalIds);
if (stats.isEmpty()) return;
Map<String, VehicleStatGpsResponseDto> 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);
}
}

View File

@ -4,7 +4,9 @@ import com.goi.erp.dto.VehicleDispatchRequestDto;
import com.goi.erp.entity.VehicleDispatch; import com.goi.erp.entity.VehicleDispatch;
import com.goi.erp.entity.VehicleInspection; import com.goi.erp.entity.VehicleInspection;
import com.goi.erp.repository.VehicleDispatchRepository; import com.goi.erp.repository.VehicleDispatchRepository;
import com.goi.erp.repository.VehicleInspectionRepository;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -15,6 +17,8 @@ import java.time.Duration;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@Slf4j @Slf4j
@ -23,6 +27,14 @@ import java.util.UUID;
public class VehicleDispatchService { public class VehicleDispatchService {
private final VehicleDispatchRepository dispatchRepository; private final VehicleDispatchRepository dispatchRepository;
private final VehicleInspectionRepository inspectionRepository;
// 누락된 재배차 로직
public enum RedispatchAction {
CREATED,
LINKED,
SKIPPED
}
/* =============================== /* ===============================
* CREATE * CREATE
@ -34,7 +46,8 @@ public class VehicleDispatchService {
LocalDate dispatchDate, LocalDate dispatchDate,
LocalDateTime startAt, LocalDateTime startAt,
BigDecimal startOdometer, BigDecimal startOdometer,
String source String source,
String startReason
) { ) {
Integer nextShift = dispatchRepository.findNextShift(vehId, dispatchDate); Integer nextShift = dispatchRepository.findNextShift(vehId, dispatchDate);
@ -48,6 +61,7 @@ public class VehicleDispatchService {
.vedStartAt(startAt) .vedStartAt(startAt)
.vedOdometerStart(startOdometer) .vedOdometerStart(startOdometer)
.vedStatus("O") .vedStatus("O")
.vedStartReason(source != null ? source : "PRE_INSPECTION")
.vedSource(source != null ? source : "AUTO") .vedSource(source != null ? source : "AUTO")
.build(); .build();
@ -58,9 +72,7 @@ public class VehicleDispatchService {
VehicleDispatchRequestDto dto, VehicleDispatchRequestDto dto,
String createdBy String createdBy
) { ) {
VehicleDispatch opened = VehicleDispatch opened = dispatchRepository.findOpenDispatchByVehId(dto.getVedVehId(), dto.getVedDispatchDate()).orElse(null);
dispatchRepository.findOpenDispatchByVehId(dto.getVedVehId())
.orElse(null);
if (opened != null) return opened; if (opened != null) return opened;
@ -137,9 +149,7 @@ public class VehicleDispatchService {
* =============================== */ * =============================== */
public void evaluateDispatchCloseByInspection(VehicleInspection inspection) { public void evaluateDispatchCloseByInspection(VehicleInspection inspection) {
VehicleDispatch open = VehicleDispatch open = dispatchRepository.findOpenDispatchByVehId(inspection.getVeiVehId(), inspection.getVeiInspectionDate()).orElse(null);
dispatchRepository.findOpenDispatchByVehId(inspection.getVeiVehId())
.orElse(null);
if (open == null) { if (open == null) {
log.info( log.info(
@ -259,6 +269,112 @@ public class VehicleDispatchService {
return dispatchRepository.save(d); return dispatchRepository.save(d);
} }
@Transactional
public Map<String, Object> redispatchByDate(LocalDate date) {
int total = 0;
int created = 0;
int linked = 0;
int skipped = 0;
// 1. 해당 날짜 + 배차 preTrip inspection
List<VehicleInspection> 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<VehicleDispatch> 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;
}
// //

View File

@ -5,9 +5,16 @@ import com.goi.erp.entity.VehicleExternalMap;
import com.goi.erp.repository.VehicleExternalMapRepository; import com.goi.erp.repository.VehicleExternalMapRepository;
import lombok.RequiredArgsConstructor; 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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class VehicleExternalMapService { public class VehicleExternalMapService {
@ -15,8 +22,7 @@ public class VehicleExternalMapService {
private final VehicleExternalMapRepository externalMapRepository; private final VehicleExternalMapRepository externalMapRepository;
/** /**
* 외부 솔루션의 ID로 내부 employee 매핑 정보 조회 * 외부 솔루션의 ID로 내부 vehicle 매핑 정보 조회 (단건)
* 목적: customer_daily_order.driver_id 들어갈 내부 emp_id 조회
*/ */
@Transactional(readOnly = true) @Transactional(readOnly = true)
public VehicleExternalMapResponseDto findMapping(String solutionType, String externalId) { public VehicleExternalMapResponseDto findMapping(String solutionType, String externalId) {
@ -28,10 +34,11 @@ public class VehicleExternalMapService {
"A" // 활성 매핑만 사용 "A" // 활성 매핑만 사용
) )
.orElseThrow(() -> .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(); VehicleExternalMapResponseDto dto = new VehicleExternalMapResponseDto();
dto.setVexUuid(map.getVexUuid()); dto.setVexUuid(map.getVexUuid());
dto.setVexVehicleId(map.getVexVehicleId()); dto.setVexVehicleId(map.getVexVehicleId());
@ -43,4 +50,46 @@ public class VehicleExternalMapService {
return dto; return dto;
} }
/**
* 내부 vehicleId 목록으로 외부 솔루션 ID bulk 조회
*
* @return Map<internalVehicleId, externalId>
*/
@Transactional(readOnly = true)
public Map<Long, String> findExternalIdsByVehicleIds(
String solutionType,
List<Long> vehicleIds
) {
if (vehicleIds == null || vehicleIds.isEmpty()) {
return Map.of();
}
List<VehicleExternalMap> 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 // 중복 번째 유지
));
}
} }

View File

@ -1,5 +1,6 @@
package com.goi.erp.service; package com.goi.erp.service;
import com.goi.erp.client.HcmEmployeeClient;
import com.goi.erp.dto.VehicleInspectionDefectDto; import com.goi.erp.dto.VehicleInspectionDefectDto;
import com.goi.erp.dto.VehicleInspectionDto; import com.goi.erp.dto.VehicleInspectionDto;
import com.goi.erp.entity.VehicleDispatch; import com.goi.erp.entity.VehicleDispatch;
@ -90,7 +91,7 @@ public class VehicleInspectionService {
// - odometerStart = inspection.odometer (가능하면) // - odometerStart = inspection.odometer (가능하면)
if (shouldCreateDispatch(inspection)) { if (shouldCreateDispatch(inspection)) {
// open 여부 // open 여부
boolean hasOpenDispatch = dispatchRepository.findOpenDispatchByVehId(inspection.getVeiVehId()).isPresent(); boolean hasOpenDispatch = dispatchRepository.findOpenDispatchByVehId(inspection.getVeiVehId(), inspection.getVeiInspectionDate()).isPresent();
if (!hasOpenDispatch) { if (!hasOpenDispatch) {
@ -107,7 +108,8 @@ public class VehicleInspectionService {
inspection.getVeiInspectionDate(), inspection.getVeiInspectionDate(),
inspection.getVeiStartAt(), inspection.getVeiStartAt(),
odometerStart, odometerStart,
"AUTO" "AUTO",
"PRE_INSPECTION"
); );
// inspection에 dispatch 연결 // inspection에 dispatch 연결

View File

@ -36,6 +36,12 @@ server:
hcm: hcm:
api: api:
base-url: http://localhost:8081/hcm-rest-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: internal:
integration: integration:
token: ${INTEGRATION_INTERNAL_TOKEN} token: ${INTEGRATION_INTERNAL_TOKEN}