Skip to content
Merged
2 changes: 2 additions & 0 deletions src/main/java/com/mobility/api/MobilityApiApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@SpringBootApplication
@EnableJpaAuditing
public class MobilityApiApplication {

public static void main(String[] args) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

import com.mobility.api.domain.dispatch.enums.*;
import com.mobility.api.domain.transporter.entity.Transporter;
import com.mobility.api.global.entity.BaseEntity;
import com.mobility.api.global.exception.GlobalException;
import com.mobility.api.global.response.ResultCode;
import jakarta.persistence.*;
import lombok.*;
import lombok.experimental.SuperBuilder;
import lombok.extern.slf4j.Slf4j;

import java.time.LocalDateTime;
Expand All @@ -15,14 +17,16 @@
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@SuperBuilder
@Slf4j
public class Dispatch {
public class Dispatch extends BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

private String dispatchNumber; // 배차 번호 (예: 2024-0001)

private String startLocation; // 출발지

private double startLatitude; // 출발지 위도
Expand Down Expand Up @@ -67,18 +71,28 @@ public class Dispatch {
@JoinColumn(name = "transporter_id")
private Transporter transporter;

private LocalDateTime createdAt; // 생성일자
private String memo; // 메모

private LocalDateTime assignedAt; // 배차 할당 시간

private LocalDateTime completedAt; // 완료 시간

private LocalDateTime canceledAt; // 취소 시간

@Column(length = 500)
private String cancelReason; // 취소 사유 (최대 200자)

// 기사 배차 시
public void assignDispatch(Transporter transporter) {

// 1. 유효성 검증 : 이미 배차되어있는지 확인
if (this.status != StatusType.OPEN) {
// 1. 유효성 검증 : HOLD 또는 OPEN 상태에서만 배차 가능
if (this.status != StatusType.OPEN && this.status != StatusType.HOLD) {
throw new GlobalException(ResultCode.DISPATCH_NOT_OPEN);
}

this.transporter = transporter;
this.status = StatusType.ASSIGNED;
this.assignedAt = LocalDateTime.now();
}

public void cancelDispatch(Transporter transporter) {
Expand All @@ -100,6 +114,7 @@ public void completeDispatch(Transporter transporter) {
}

this.status = StatusType.COMPLETED;
this.completedAt = LocalDateTime.now();
}

private void validateOwner(Transporter transporter) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
package com.mobility.api.domain.dispatch.enums;

public enum StatusType {
OPEN, // 등록
ASSIGNED, // 진행중
COMPLETED, // 완료
HOLD, // 자동배차 진행중 (주변 기사에게 순차 권유 중)
OPEN, // 모든 기사 확인 가능 (자동배차 대상 없거나 모두 거절)
ASSIGNED, // 배차 완료 (기사 배정됨)
COMPLETED, // 운송 완료
CANCELED // 취소
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;

import com.mobility.api.domain.dispatch.enums.StatusType;

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

Expand Down Expand Up @@ -50,4 +52,16 @@ List<DispatchDistanceProjection> findDispatchesByDistance(
@Param("lon") double lon,
@Param("statuses") List<String> statuses
);

/**
* 상태별 배차 카운트 조회
*/
@Query("SELECT d.status, COUNT(d) FROM Dispatch d GROUP BY d.status")
List<Object[]> countByStatus();

/**
* 특정 사무실의 상태별 배차 카운트 조회
*/
@Query("SELECT d.status, COUNT(d) FROM Dispatch d WHERE d.officeId = :officeId GROUP BY d.status")
List<Object[]> countByStatusAndOfficeId(@Param("officeId") Long officeId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,8 @@ public void startSequentialNotification(Long dispatchId) {
dispatch = dispatchRepository.findById(dispatchId)
.orElseThrow(() -> new GlobalException(ResultCode.NOT_FOUND_DISPATCH));

if (dispatch.getStatus() != StatusType.OPEN) {
log.info("[AutoDispatch] 배차가 더 이상 OPEN 상태가 아님. 종료 - dispatchId: {}, status: {}",
if (dispatch.getStatus() != StatusType.HOLD) {
log.info("[AutoDispatch] 배차가 더 이상 HOLD 상태가 아님. 종료 - dispatchId: {}, status: {}",
dispatchId, dispatch.getStatus());
break;
}
Expand Down Expand Up @@ -124,8 +124,9 @@ public void startSequentialNotification(Long dispatchId) {
}
}

// 4. 10명 모두 거절/미응답
log.info("[AutoDispatch] 모든 기사가 거절/미응답. 배차는 OPEN 상태 유지 - dispatchId: {}", dispatchId);
// 4. 모든 기사가 거절/미응답 → HOLD에서 OPEN으로 변경
updateDispatchStatusToOpen(dispatchId);
log.info("[AutoDispatch] 모든 기사가 거절/미응답. 배차를 OPEN 상태로 변경 - dispatchId: {}", dispatchId);

} catch (Exception e) {
log.error("[AutoDispatch] 순차 알림 처리 중 오류 발생 - dispatchId: {}", dispatchId, e);
Expand Down Expand Up @@ -160,8 +161,8 @@ public void handleAccept(Long offerId, Long transporterId) {
Dispatch dispatch = dispatchRepository.findByIdWithPessimisticLock(offer.getDispatch().getId())
.orElseThrow(() -> new GlobalException(ResultCode.NOT_FOUND_DISPATCH));

// 5. 배차 상태 확인 (이미 할당되었을 수 있음)
if (dispatch.getStatus() != StatusType.OPEN) {
// 5. 배차 상태 확인 (HOLD 상태에서만 수락 가능)
if (dispatch.getStatus() != StatusType.HOLD) {
// Offer는 거절로 처리
offer.reject();
offerRepository.save(offer);
Expand Down Expand Up @@ -352,4 +353,18 @@ private Double calculateDistance(double startLat, double startLon, double endLat

return earthRadiusKm * c;
}

/**
* 배차 상태를 OPEN으로 변경 (모든 자동배차 대상 기사가 거절/타임아웃한 경우)
*/
@Transactional
protected void updateDispatchStatusToOpen(Long dispatchId) {
dispatchRepository.findById(dispatchId).ifPresent(dispatch -> {
if (dispatch.getStatus() == StatusType.HOLD) {
dispatch.setStatus(StatusType.OPEN);
dispatchRepository.save(dispatch);
log.info("[AutoDispatch] 배차 상태 HOLD → OPEN 변경 완료 - dispatchId: {}", dispatchId);
}
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
import com.mobility.api.domain.office.dto.request.CreateDispatchReq;
import com.mobility.api.domain.office.dto.request.DispatchSearchDto;
import com.mobility.api.domain.office.dto.request.UpdateDispatchReq;
import com.mobility.api.domain.office.dto.response.DispatchSummaryRes;
import com.mobility.api.domain.office.dto.response.GetAllDispatchRes;
import com.mobility.api.domain.office.dto.response.GetDispatchDetailRes;
import com.mobility.api.domain.office.service.OfficeService;
import com.mobility.api.domain.transporter.dto.request.TransporterCreateReq;
import com.mobility.api.domain.transporter.dto.response.TransporterRes;
Expand Down Expand Up @@ -50,6 +52,35 @@ public CommonResponse<Page<GetAllDispatchRes>> getAllDispatch(
return CommonResponse.success(officeService.findAllDispatch(searchDto, pageable));
}

/**
* <pre>
* 사무실 - 배차 상세 조회
* </pre>
*
* @param dispatchId
* @return
*/
@Operation(summary = "배차 상세 조회", description = "")
@RequestMapping(path = "/dispatch/{dispatch_id}", method = RequestMethod.GET)
public CommonResponse<GetDispatchDetailRes> getDispatchDetail(
@PathVariable("dispatch_id") Long dispatchId
) {
return CommonResponse.success(officeService.getDispatchDetail(dispatchId));
}

/**
* <pre>
* 사무실 - 배차 상태별 카운트 조회
* </pre>
*
* @return 상태별 배차 개수
*/
@Operation(summary = "배차 상태별 카운트 조회", description = "OPEN, ASSIGNED, COMPLETED, CANCELED 상태별 배차 개수를 조회합니다.")
@RequestMapping(path = "/dispatch/summary", method = RequestMethod.GET)
public CommonResponse<DispatchSummaryRes> getDispatchSummary() {
return CommonResponse.success(officeService.getDispatchSummary());
}

/**
* <pre>
* 사무실 - 배차 등록
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.mobility.api.domain.office.dto.response;

import com.mobility.api.domain.dispatch.enums.StatusType;

import java.util.Map;

public record DispatchSummaryRes(
Map<StatusType, Long> statusCounts
) {
public static DispatchSummaryRes from(Map<StatusType, Long> counts) {
return new DispatchSummaryRes(counts);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package com.mobility.api.domain.office.dto.response;

import com.mobility.api.domain.dispatch.entity.Dispatch;
import com.mobility.api.domain.dispatch.enums.*;
import com.mobility.api.domain.transporter.entity.Transporter;
import io.swagger.v3.oas.annotations.media.Schema;

import java.time.LocalDateTime;

@Schema(description = "배차 상세 응답")
public record GetDispatchDetailRes(
@Schema(description = "배차 ID", example = "1")
Long id,

@Schema(description = "배차 번호", example = "2024-0001")
String dispatchNumber,

@Schema(description = "배차 상태", example = "OPEN")
StatusType status,

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

@Schema(description = "출발지 주소", example = "서울특별시 강남구 테헤란로 123")
String startLocation,

@Schema(description = "출발지 위도", example = "37.5065")
double startLatitude,

@Schema(description = "출발지 경도", example = "127.0536")
double startLongitude,

@Schema(description = "도착지 주소", example = "부산광역시 해운대구 우동 456")
String destinationLocation,

@Schema(description = "도착지 위도", example = "35.1595")
double destinationLatitude,

@Schema(description = "도착지 경도", example = "129.1603")
double destinationLongitude,

@Schema(description = "고객 전화번호 (마스킹 처리)", example = "010-****-5678")
String clientPhoneNumber,

@Schema(description = "메모", example = "현관 비밀번호 1234")
String memo,

@Schema(description = "콜 타입 (INTERNAL: 자사콜, INTEGRATED: 통합콜)", example = "INTERNAL")
CallType call,

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

@Schema(description = "결제 방식 (CASH: 현금, POSTPAID: 후불, COMPLETE_POSTPAID: 완후)", example = "CASH")
PaymentType paymentMethod,

@Schema(description = "톨비 방식 (TOLLGATE_INCLUDED: 톨포, TOLLGATE_SEPARATE: 톨별, HIPASS: 하이패스)", example = "HIPASS")
TollType tollType,

@Schema(description = "배차된 기사 ID (미배차 시 null)", example = "1")
Long transporterId,

@Schema(description = "배차된 기사 이름 (미배차 시 null)", example = "홍길동")
String transporterName,

@Schema(description = "배차된 기사 전화번호 (미배차 시 null)", example = "010-1234-5678")
String transporterPhone,

@Schema(description = "사무실 ID", example = "1")
Long officeId,

@Schema(description = "생성 일시", example = "2024-01-15T10:00:00")
LocalDateTime createdAt,

@Schema(description = "수정 일시", example = "2024-01-15T10:00:00")
LocalDateTime updatedAt,

@Schema(description = "배차 할당 일시 (미배차 시 null)", example = "2024-01-15T10:30:00")
LocalDateTime assignedAt,

@Schema(description = "완료 일시 (미완료 시 null)", example = "2024-01-15T12:00:00")
LocalDateTime completedAt,

@Schema(description = "취소 일시 (미취소 시 null)", example = "2024-01-15T11:00:00")
LocalDateTime canceledAt,

@Schema(description = "취소 사유 (최대 200자, 한글/영어 동일)", example = "고객 요청으로 취소", maxLength = 200)
String cancelReason
) {
public static GetDispatchDetailRes from(Dispatch dispatch) {
Transporter transporter = dispatch.getTransporter();

return new GetDispatchDetailRes(
dispatch.getId(),
dispatch.getDispatchNumber(),
dispatch.getStatus(),
dispatch.getCharge(),
dispatch.getStartLocation(),
dispatch.getStartLatitude(),
dispatch.getStartLongitude(),
dispatch.getDestinationLocation(),
dispatch.getDestinationLatitude(),
dispatch.getDestinationLongitude(),
maskPhoneNumber(dispatch.getClientPhoneNumber()),
dispatch.getMemo(),
dispatch.getCall(),
dispatch.getService(),
dispatch.getPaymentType(),
dispatch.getTollType(),
transporter != null ? transporter.getId() : null,
transporter != null ? transporter.getName() : null,
transporter != null ? transporter.getPhone() : null,
dispatch.getOfficeId(),
dispatch.getCreatedAt(),
dispatch.getUpdatedAt(),
dispatch.getAssignedAt(),
dispatch.getCompletedAt(),
dispatch.getCanceledAt(),
dispatch.getCancelReason()
);
}

private static String maskPhoneNumber(String phoneNumber) {
if (phoneNumber == null || phoneNumber.length() < 8) {
return phoneNumber;
}
// 010-1234-5678 -> 010-****-5678
// 01012345678 -> 010****5678
if (phoneNumber.contains("-")) {
String[] parts = phoneNumber.split("-");
if (parts.length == 3) {
return parts[0] + "-****-" + parts[2];
}
}
// 하이픈 없는 경우 (01012345678)
if (phoneNumber.length() == 11) {
return phoneNumber.substring(0, 3) + "****" + phoneNumber.substring(7);
}
return phoneNumber;
}
}
Loading