[INSPECTION]

- Insert inspection raw data by integration-service ingest
- Parse inspection data from raw data
- Insert defects by parsing inspection raw

[DISPATCH]
- Open vehicle dispatch from preTrip inspection
- Close vehicle dispatch from postTrip inspection
This commit is contained in:
Hyojin Ahn 2025-12-23 14:53:06 -05:00
parent 5a22c38ee7
commit 3347de524f
36 changed files with 2134 additions and 3 deletions

View File

@ -75,6 +75,11 @@
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.vladmihalcea</groupId>
<artifactId>hibernate-types-60</artifactId>
<version>2.21.1</version>
</dependency>
<dependency>
<groupId>template</groupId>
<artifactId>layered-architecture-template</artifactId>

View File

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

View File

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

View File

@ -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 등록

View File

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

View File

@ -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<VehicleDispatch> 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<VehicleDispatch> 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<Void> 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<Void> 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<VehicleDispatch> 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<Page<VehicleDispatch>> 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<List<VehicleDispatch>> 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<Void> 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();
}
}

View File

@ -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<VehicleExternalMapResponseDto> getVehicleMapping(
@RequestParam String solutionType,
@RequestParam String externalId
) {
VehicleExternalMapResponseDto dto =
externalMapService.findMapping(solutionType, externalId);
return ResponseEntity.ok(dto);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (옵션)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<ExtSamsaraRawInspection, Long> {
/**
* idempotent ingest
* (source + externalId UNIQUE)
*/
Optional<ExtSamsaraRawInspection> findByEsriSourceAndEsriExternalId(
String esriSource,
String esriExternalId
);
/**
* 아직 처리되지 않은 raw inspection 조회
*/
List<ExtSamsaraRawInspection> findByEsriProcessedFalse();
/**
* 특정 날짜에 수집된 raw inspection 미처리 데이터
* (scheduler / batch parsing )
*/
List<ExtSamsaraRawInspection> findByEsriProcessedFalseAndEsriFetchedDate(
LocalDate esriFetchedDate
);
/**
* 특정 차량의 inspection 이력 조회 (디버깅/분석용)
*/
List<ExtSamsaraRawInspection> findByEsriVehicleExtId(
String esriVehicleExtId
);
}

View File

@ -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<VehicleDispatch, Long> {
List<VehicleDispatch> findByVedVehIdAndVedDispatchDate(Long vedVehId, LocalDate vedDispatchDate);
List<VehicleDispatch> findByVedDriverIdAndVedDispatchDate(Long vedDriverId, LocalDate vedDispatchDate);
List<VehicleDispatch> findByVedDispatchDate(LocalDate vedDispatchDate);
Page<VehicleDispatch> findAll(Pageable pageable);
Optional<VehicleDispatch> findByVedUuid(UUID vedUuid);
//
@Query("""
SELECT d
FROM VehicleDispatch d
WHERE d.vedVehId = :vehId
AND d.vedStatus IN ('O','P')
""")
Optional<VehicleDispatch> findOpenDispatchByVehId(Long vehId);
//
@Query("""
SELECT d
FROM VehicleDispatch d
WHERE d.vedStatus = 'P'
AND d.vedPausedAt <= :threshold
""")
List<VehicleDispatch> 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;
}
}

View File

@ -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<VehicleExternalMap, Long> {
Optional<VehicleExternalMap> findByVexSolutionTypeAndVexExternalIdAndVexStatus(
String vexSolutionType,
String vexExternalId,
String vexStatus
);
}

View File

@ -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<VehicleInspectionDefect, Long> {
List<VehicleInspectionDefect> findByVidVeiId(Long vidVeiId);
List<VehicleInspectionDefect> findByVidVeiIdAndVidIsResolvedFalse(Long vidVeiId);
Optional<VehicleInspectionDefect> findByVidVeiIdAndVidExternalDefectId(Long veiId, String vidExternalDefectId);
}

View File

@ -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<VehicleInspection, Long> {
Optional<VehicleInspection> findByVeiSourceAndVeiSourceId(String veiSource, Long veiSourceId);
Optional<VehicleInspection> findByVeiVehIdAndVeiInspectionDate(Long veiVehId, LocalDate veiInspectionDate);
}

View File

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

View File

@ -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<ExtSamsaraRawInspection> 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<VehicleInspectionDefectDto> 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);
}
}

View File

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

View File

@ -0,0 +1,8 @@
package com.goi.erp.service;
public enum IngestAction {
INSERTED,
UPDATED,
SKIPPED,
FAILED
}

View File

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

View File

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

View File

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

View File

@ -35,4 +35,7 @@ server:
# ================================
hcm:
api:
base-url: http://localhost:8081/hcm-rest-api
base-url: http://localhost:8081/hcm-rest-api
internal:
integration:
token: ${INTEGRATION_INTERNAL_TOKEN}