Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.mobility.api.domain.dispatch.dto;

/**
* Native Query 결과를 매핑할 인터페이스
* 기사의 현재 위치 기준 배차 리스트 조회 시 사용
*/
public interface DispatchDistanceProjection {
Long getId();
String getServiceType();
Integer getCharge();
String getStartLocation();
String getDestinationLocation();
String getStatus();
Double getDistanceInMeters(); // DB에서 계산된 미터 단위 거리
String getViaType();
String getPaymentType();
String getTollType();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package com.mobility.api.domain.dispatch.dto.response;

import com.mobility.api.domain.dispatch.dto.DispatchDistanceProjection;
import com.mobility.api.domain.dispatch.enums.PaymentType;
import com.mobility.api.domain.dispatch.enums.ServiceType;
import com.mobility.api.domain.dispatch.enums.StatusType;
import com.mobility.api.domain.dispatch.enums.TollType;
import com.mobility.api.domain.dispatch.enums.ViaType;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;

import java.util.ArrayList;
import java.util.List;

/**
* 기사용 배차 리스트 조회 응답 DTO
* Projection의 미터 값을 받아 km로 변환하여 전달
*/
@Builder
@Schema(description = "배차 리스트 아이템 (거리 포함)")
public record DispatchListItemRes(
@Schema(description = "배차 ID", example = "1")
Long id,

@Schema(description = "서비스 타입 (DELIVERY: 탁송, DRIVER: 대리)", example = "DELIVERY")
ServiceType serviceType,

@Schema(description = "요금 (원)", example = "50000")
Integer charge,

@Schema(description = "출발지", example = "강남역")
String startLocation,

@Schema(description = "도착지", example = "판교역")
String destinationLocation,

@Schema(description = "배차 상태 (OPEN: 대기, ASSIGNED: 배정, COMPLETED: 완료, CANCELED: 취소)", example = "OPEN")
StatusType status,

@Schema(description = "현재 기사와 출발지 간 직선거리 (km)", example = "11.5")
Double distanceKm,

@Schema(description = "배차 태그 (경유 여부, 결제 방식, 톨비 방식)", example = "[\"경유\", \"현금\", \"톨게이트 포함\"]")
List<String> tags
) {
/**
* Projection -> DTO 변환 메서드
* @param proj Native Query 결과 Projection
* @return DispatchListItemRes
*/
public static DispatchListItemRes from(DispatchDistanceProjection proj) {
// 미터(m) -> 킬로미터(km) 변환
double meters = proj.getDistanceInMeters() != null ? proj.getDistanceInMeters() : 0.0;
double km = Math.round((meters / 1000.0) * 100.0) / 100.0;

// ServiceType enum 변환
ServiceType serviceType = proj.getServiceType() != null
? ServiceType.valueOf(proj.getServiceType())
: null;

// StatusType enum 변환
StatusType status = proj.getStatus() != null
? StatusType.valueOf(proj.getStatus())
: null;

// ViaType enum 변환
ViaType viaType = proj.getViaType() != null
? ViaType.valueOf(proj.getViaType())
: null;

// PaymentType enum 변환
PaymentType paymentType = proj.getPaymentType() != null
? PaymentType.valueOf(proj.getPaymentType())
: null;

// TollType enum 변환
TollType tollType = proj.getTollType() != null
? TollType.valueOf(proj.getTollType())
: null;

// 태그 리스트 생성
List<String> tags = buildTags(viaType, paymentType, tollType);

return DispatchListItemRes.builder()
.id(proj.getId())
.serviceType(serviceType)
.charge(proj.getCharge())
.startLocation(proj.getStartLocation())
.destinationLocation(proj.getDestinationLocation())
.status(status)
.distanceKm(km)
.tags(tags)
.build();
}

/**
* Tag 리스트 생성 메서드
* 경유 여부, 결제 방식, 톨비 방식을 String 리스트로 변환
*/
private static List<String> buildTags(ViaType viaType, PaymentType paymentType, TollType tollType) {
List<String> tags = new ArrayList<>();

// 경유 여부 (VIA가 있으면 "경유" 추가)
if (viaType == ViaType.VIA) {
tags.add("경유");
}

// 결제 방식
if (paymentType != null) {
switch (paymentType) {
case CASH -> tags.add("현금");
case POSTPAID -> tags.add("후불");
case COMPLETE_POSTPAID -> tags.add("완후");
}
}

// 톨비 방식
if (tollType != null) {
switch (tollType) {
case TOLLGATE_INCLUDED -> tags.add("톨게이트 포함");
case TOLLGATE_SEPARATE -> tags.add("톨게이트 별도");
case HIPASS -> tags.add("하이패스");
}
}

return tags;
}
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,53 @@
package com.mobility.api.domain.dispatch.repository;

import com.mobility.api.domain.dispatch.dto.DispatchDistanceProjection;
import com.mobility.api.domain.dispatch.entity.Dispatch;
import jakarta.persistence.LockModeType;
import jakarta.persistence.QueryHint;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface DispatchRepository extends JpaRepository<Dispatch, Long>,
JpaSpecificationExecutor<Dispatch> { // 정렬, 필터 등을 위해 추가

@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")}) // 3初
@QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")}) // 3초
@Query("SELECT d FROM Dispatch d WHERE d.id = :dispatchId")
Optional<Dispatch> findByIdWithPessimisticLock(@Param("dispatchId") Long dispatchId);

/**
* PostGIS의 ST_DistanceSphere를 사용해 미터 단위 거리를 계산
* 기사의 현재 위치(lat, lon) 기준, 전체 배차를 거리순으로 조회
* @param lat 기사의 현재 위도
* @param lon 기사의 현재 경도
* @param statuses 필터링할 배차 상태 목록 (빈 리스트면 전체 조회)
* @return 거리순으로 정렬된 배차 리스트
*/
@Query(value = """
SELECT d.id as id,
d.service as serviceType,
d.charge as charge,
d.start_location as startLocation,
d.destination_location as destinationLocation,
d.status as status,
ST_DistanceSphere(
ST_SetSRID(ST_MakePoint(d.start_longitude, d.start_latitude), 4326),
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)
) as distanceInMeters,
d.via_type as viaType,
d.payment_type as paymentType,
d.toll_type as tollType
FROM dispatch d
WHERE d.active = true
AND (:statuses IS NULL OR d.status IN (:statuses))
ORDER BY distanceInMeters ASC
""", nativeQuery = true)
List<DispatchDistanceProjection> findDispatchesByDistance(
@Param("lat") double lat,
@Param("lon") double lon,
@Param("statuses") List<String> statuses
);
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package com.mobility.api.domain.dispatch.service;

import com.mobility.api.domain.dispatch.dto.DispatchDistanceProjection;
import com.mobility.api.domain.dispatch.dto.response.DispatchCancelRes;
import com.mobility.api.domain.dispatch.dto.response.DispatchDetailRes;
import com.mobility.api.domain.dispatch.dto.response.DispatchListItemRes;
import com.mobility.api.domain.dispatch.entity.Dispatch;
import com.mobility.api.domain.dispatch.enums.StatusType;
import com.mobility.api.domain.dispatch.repository.DispatchRepository;
import com.mobility.api.domain.dispatch.dto.response.DispatchAssignCompleteRes;
import com.mobility.api.domain.transporter.entity.LocationHistory;
Expand All @@ -14,6 +17,9 @@
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import java.util.List;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;

@Slf4j
Expand Down Expand Up @@ -105,6 +111,38 @@ public DispatchDetailRes getDispatchDetail(Long dispatchId, Long currentUserId)
return DispatchDetailRes.from(dispatch, distanceKm);
}

/**
* 기사용 배차 리스트 조회 (거리순 정렬 + 상태 필터링)
* @param transporterId 현재 로그인한 기사 ID
* @param statuses 필터링할 배차 상태 목록 (null이면 전체 조회)
* @return 거리순으로 정렬된 배차 리스트
*/
public List<DispatchListItemRes> getDispatchListByDistance(Long transporterId, List<StatusType> statuses) {
// 1. 기사의 최신 위치 조회
LocationHistory latestLocation = locationRepository.findFirstByTransporter_IdOrderByIdDesc(transporterId)
.orElseThrow(() -> new GlobalException(ResultCode.NOT_FOUND_USER));

// 2. 기사 위치 기준으로 배차를 거리순으로 조회 (상태 필터링 적용)
double lat = latestLocation.getLocation().getY();
double lon = latestLocation.getLocation().getX();

// StatusType enum을 String으로 변환
// 빈 리스트면 null로 전달하여 PostgreSQL IN 절 에러 방지
List<String> statusStrings = null;
if (statuses != null && !statuses.isEmpty()) {
statusStrings = statuses.stream()
.map(StatusType::name)
.collect(Collectors.toList());
}

List<DispatchDistanceProjection> projections = dispatchRepository.findDispatchesByDistance(lat, lon, statusStrings);

// 3. Projection -> DTO 변환
return projections.stream()
.map(DispatchListItemRes::from)
.collect(Collectors.toList());
}

/**
* PostGIS를 사용하여 두 지점 간 거리 계산 (km 단위)
* @param startLat 출발지 위도
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import com.mobility.api.domain.dispatch.dto.response.DispatchCancelRes;
import com.mobility.api.domain.dispatch.dto.response.DispatchAssignCompleteRes;
import com.mobility.api.domain.dispatch.dto.response.DispatchListItemRes;
import com.mobility.api.domain.dispatch.enums.StatusType;
import com.mobility.api.domain.dispatch.service.DispatcherService;
import com.mobility.api.domain.transporter.dto.request.LocationUpdateReq;
import com.mobility.api.domain.transporter.dto.response.LocationUpdateRes;
Expand All @@ -18,6 +20,8 @@
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@Slf4j
@Tag(name = "기사 관련 요청(/api/v1/transporter/...)")
@RestController
Expand Down Expand Up @@ -84,6 +88,62 @@ public CommonResponse<LocationUpdateRes> updateTransporterLocation(
}

/**
* 기사의 배차 리스트 정렬 등을 위해, 현재 기사 위치 기준으로 배차
* 기사용 배차 리스트 조회 (거리순 정렬 + 상태 필터링)
*
* NOTE: 원래 @ModelAttribute + DispatchListSearchReq(Record) 방식을 사용하려 했으나,
* Java Record는 불변 객체(모든 필드가 final)라서 setter가 없고,
* Spring의 @ModelAttribute는 전통적으로 기본 생성자 + setter를 통해 바인딩하기 때문에
* Record와의 호환성 문제로 파라미터 바인딩이 실패했습니다.
* 따라서 @RequestParam으로 직접 받는 방식으로 구현했습니다.
*/
@Operation(
summary = "기사용 배차 리스트 조회 (거리순 정렬 + 상태 필터링)",
description = """
현재 로그인한 기사의 위치를 기준으로 배차를 거리순으로 조회합니다.

- 기사의 최신 위치 정보를 기준으로 각 배차의 출발지까지의 직선거리를 계산합니다.
- status 파라미터로 특정 상태의 배차만 필터링할 수 있습니다.
- 예: ?status=OPEN (OPEN 상태만 조회)
- 예: ?status=OPEN&status=ASSIGNED (OPEN, ASSIGNED 상태 조회)
- 미입력 시 전체 배차 조회
- 거리가 가까운 순서대로 정렬되어 반환됩니다.
- 거리는 km 단위로 반환됩니다.

배차 상태 종류: OPEN(대기), ASSIGNED(배정), COMPLETED(완료), CANCELED(취소)
"""
)
@io.swagger.v3.oas.annotations.responses.ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "배차 리스트 조회 성공"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "404",
description = "기사의 위치 정보를 찾을 수 없음"
)
})
@GetMapping("/dispatch-list")
public CommonResponse<List<DispatchListItemRes>> getDispatchList(
@io.swagger.v3.oas.annotations.Parameter(hidden = true)
@CurrentUser Transporter transporter,
@io.swagger.v3.oas.annotations.Parameter(
description = "필터링할 배차 상태 목록 (복수 선택 가능, 미입력 시 전체 조회)",
example = "OPEN"
)
@RequestParam(required = false) List<String> status
) {
Long transporterId = getValidatedTransporterId(transporter);

// String을 StatusType enum으로 수동 변환
List<StatusType> statusTypes = null;
if (status != null && !status.isEmpty()) {
statusTypes = status.stream()
.map(String::toUpperCase)
.map(StatusType::valueOf)
.toList();
}

List<DispatchListItemRes> dispatchList = dispatcherService.getDispatchListByDistance(transporterId, statusTypes);
return CommonResponse.success(dispatchList);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.mobility.api.domain.transporter.dto.request;

import com.mobility.api.domain.dispatch.enums.StatusType;
import io.swagger.v3.oas.annotations.media.Schema;

import java.util.List;

/**
* 기사용 배차 리스트 조회 Request DTO
*/
@Schema(description = "배차 리스트 조회 필터")
public record DispatchListSearchReq(
@Schema(
description = "필터링할 배차 상태 목록 (복수 선택 가능, 미입력 시 전체 조회)",
example = "[\"OPEN\", \"ASSIGNED\"]"
)
List<StatusType> status
) {
/**
* 정적 팩토리 메서드
*/
public static DispatchListSearchReq of(List<StatusType> statuses) {
return new DispatchListSearchReq(statuses);
}
}