From 74a4b6540d4fcf6e00fd7c71e8b23c75b88d3223 Mon Sep 17 00:00:00 2001 From: SOWON LEE <66356241+Leesowon@users.noreply.github.com> Date: Fri, 23 Jan 2026 09:08:32 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[#57]=20feat:=20=EC=8B=A4=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=20=ED=94=BC=EB=93=9C=20api=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../office/controller/OfficeV1Controller.java | 21 ++++++-- .../office/dto/response/DispatchFeedRes.java | 32 +++++++++++ .../domain/office/service/OfficeService.java | 53 +++++++++++++++++++ 3 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/mobility/api/domain/office/dto/response/DispatchFeedRes.java diff --git a/src/main/java/com/mobility/api/domain/office/controller/OfficeV1Controller.java b/src/main/java/com/mobility/api/domain/office/controller/OfficeV1Controller.java index e163bb9..f9eb740 100644 --- a/src/main/java/com/mobility/api/domain/office/controller/OfficeV1Controller.java +++ b/src/main/java/com/mobility/api/domain/office/controller/OfficeV1Controller.java @@ -4,6 +4,7 @@ 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.DispatchFeedRes; 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; @@ -82,6 +83,23 @@ public CommonResponse getDispatchSummary() { return CommonResponse.success(officeService.getDispatchSummary()); } + /** + *
+     *     사무실 - 대시보드 실시간 피드 조회
+     * 
+ * + * @param limit 조회 개수 (기본: 20) + * @return 최근 배차 이벤트 피드 목록 + */ + @Operation(summary = "대시보드 실시간 피드 조회", description = "최근 배차 이벤트(open, assigned, completed, canceled) 목록을 조회합니다. HOLD 상태는 제외됩니다.") + @RequestMapping(path = "/dispatch/feed", method = RequestMethod.GET) + public CommonResponse> getDispatchFeed( + @Parameter(description = "조회 개수", example = "20") + @RequestParam(required = false, defaultValue = "20") Integer limit + ) { + return CommonResponse.success(officeService.getDispatchFeed(limit)); + } + /** *
      *     사무실 - 배차 등록
@@ -206,10 +224,7 @@ public CommonResponse changeTransporterStatus(
                 user.getManager()
         );
 
-
         return CommonResponse.success(0);
     }
 
-
-
 }
diff --git a/src/main/java/com/mobility/api/domain/office/dto/response/DispatchFeedRes.java b/src/main/java/com/mobility/api/domain/office/dto/response/DispatchFeedRes.java
new file mode 100644
index 0000000..bb03b0a
--- /dev/null
+++ b/src/main/java/com/mobility/api/domain/office/dto/response/DispatchFeedRes.java
@@ -0,0 +1,32 @@
+package com.mobility.api.domain.office.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+
+import java.time.LocalDateTime;
+
+@Schema(description = "배차 피드 응답 DTO")
+@Builder
+public record DispatchFeedRes(
+        @Schema(description = "피드 ID", example = "open-123")
+        String id,
+
+        @Schema(description = "피드 타입", example = "assigned", allowableValues = {"open", "assigned", "completed", "canceled"})
+        String type,
+
+        @Schema(description = "배차 ID", example = "123")
+        Long dispatchId,
+
+        @Schema(description = "배차 번호", example = "2024-0001")
+        String dispatchNumber,
+
+        @Schema(description = "기사 이름 (assigned, completed 시에만 존재)", example = "김철수")
+        String transporterName,
+
+        @Schema(description = "피드 메시지", example = "김철수 기사가 콜 #2024-0001을 배차 받았습니다")
+        String message,
+
+        @Schema(description = "이벤트 발생 시간 (BaseEntity.createdAt 또는 이벤트별 타임스탬프)", example = "2024-01-15T10:32:00")
+        LocalDateTime timestamp
+) {
+}
\ No newline at end of file
diff --git a/src/main/java/com/mobility/api/domain/office/service/OfficeService.java b/src/main/java/com/mobility/api/domain/office/service/OfficeService.java
index 3c0722e..b6c8fde 100644
--- a/src/main/java/com/mobility/api/domain/office/service/OfficeService.java
+++ b/src/main/java/com/mobility/api/domain/office/service/OfficeService.java
@@ -7,6 +7,7 @@
 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.DispatchFeedRes;
 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;
@@ -254,4 +255,56 @@ public void changeTransporterStatus(Long transporterId, TransporterStatus status
 
     }
 
+    /**
+     * 대시보드 실시간 피드 조회
+     * @param limit 조회 개수 (기본: 20)
+     * @return 최근 배차 이벤트 피드 목록
+     */
+    @Transactional(readOnly = true)
+    public List getDispatchFeed(Integer limit) {
+        // 최근 배차 조회 (HOLD 상태 제외, createdAt 기준 내림차순)
+        List recentDispatches = dispatchRepository.findAll(
+                org.springframework.data.domain.PageRequest.of(0, limit,
+                        org.springframework.data.domain.Sort.by(org.springframework.data.domain.Sort.Direction.DESC, "createdAt"))
+        ).getContent();
+
+        // 각 배차를 피드 DTO로 변환
+        return recentDispatches.stream()
+                .filter(dispatch -> dispatch.getStatus() != StatusType.HOLD) // HOLD 상태 제외
+                .map(dispatch -> {
+                    String type = dispatch.getStatus().name().toLowerCase();
+                    String transporterName = (dispatch.getTransporter() != null) ? dispatch.getTransporter().getName() : null;
+                    String dispatchNumberDisplay = (dispatch.getDispatchNumber() != null) ? "#" + dispatch.getDispatchNumber() : "#" + dispatch.getId();
+
+                    // 상태별 타임스탬프 선택
+                    java.time.LocalDateTime timestamp = switch (dispatch.getStatus()) {
+                        case OPEN -> dispatch.getCreatedAt();
+                        case ASSIGNED -> dispatch.getAssignedAt();
+                        case COMPLETED -> dispatch.getCompletedAt();
+                        case CANCELED -> dispatch.getCanceledAt();
+                        default -> dispatch.getCreatedAt();
+                    };
+
+                    // 상태별 메시지 생성
+                    String message = switch (dispatch.getStatus()) {
+                        case OPEN -> "배차 " + dispatchNumberDisplay + "가 등록되었습니다";
+                        case ASSIGNED -> transporterName + " 기사가 콜 " + dispatchNumberDisplay + "을 배차 받았습니다";
+                        case COMPLETED -> transporterName + " 기사가 콜 " + dispatchNumberDisplay + "을 완료했습니다";
+                        case CANCELED -> "배차 " + dispatchNumberDisplay + "이 취소되었습니다";
+                        default -> "배차 " + dispatchNumberDisplay;
+                    };
+
+                    return DispatchFeedRes.builder()
+                            .id(type + "-" + dispatch.getId())
+                            .type(type)
+                            .dispatchId(dispatch.getId())
+                            .dispatchNumber(dispatch.getDispatchNumber())
+                            .transporterName(transporterName)
+                            .message(message)
+                            .timestamp(timestamp)
+                            .build();
+                })
+                .toList();
+    }
+
 }

From 37958e983d1b0d2c18412d6c694ccaa846bc7212 Mon Sep 17 00:00:00 2001
From: SOWON LEE <66356241+Leesowon@users.noreply.github.com>
Date: Fri, 23 Jan 2026 09:17:07 +0900
Subject: [PATCH 2/3] =?UTF-8?q?[#57]=20perf:=20=EB=B0=B0=EC=B0=A8=20?=
 =?UTF-8?q?=ED=94=BC=EB=93=9C=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20N+1=20?=
 =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../domain/dispatch/repository/DispatchRepository.java | 10 ++++++++++
 .../api/domain/office/service/OfficeService.java       |  4 ++--
 2 files changed, 12 insertions(+), 2 deletions(-)

diff --git a/src/main/java/com/mobility/api/domain/dispatch/repository/DispatchRepository.java b/src/main/java/com/mobility/api/domain/dispatch/repository/DispatchRepository.java
index ce28781..91d4f80 100644
--- a/src/main/java/com/mobility/api/domain/dispatch/repository/DispatchRepository.java
+++ b/src/main/java/com/mobility/api/domain/dispatch/repository/DispatchRepository.java
@@ -4,6 +4,8 @@
 import com.mobility.api.domain.dispatch.entity.Dispatch;
 import jakarta.persistence.LockModeType;
 import jakarta.persistence.QueryHint;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
 import org.springframework.data.jpa.repository.*;
 import org.springframework.data.repository.query.Param;
 
@@ -64,4 +66,12 @@ List findDispatchesByDistance(
      */
     @Query("SELECT d.status, COUNT(d) FROM Dispatch d WHERE d.officeId = :officeId GROUP BY d.status")
     List countByStatusAndOfficeId(@Param("officeId") Long officeId);
+
+    /**
+     * 배차 목록 조회 (Transporter와 Fetch Join으로 N+1 문제 해결)
+     * @param pageable 페이징 및 정렬 정보
+     * @return 배차 목록 (Transporter 포함)
+     */
+    @Query("SELECT d FROM Dispatch d LEFT JOIN FETCH d.transporter")
+    Page findAllWithTransporter(Pageable pageable);
 }
diff --git a/src/main/java/com/mobility/api/domain/office/service/OfficeService.java b/src/main/java/com/mobility/api/domain/office/service/OfficeService.java
index b6c8fde..00cdaa8 100644
--- a/src/main/java/com/mobility/api/domain/office/service/OfficeService.java
+++ b/src/main/java/com/mobility/api/domain/office/service/OfficeService.java
@@ -262,8 +262,8 @@ public void changeTransporterStatus(Long transporterId, TransporterStatus status
      */
     @Transactional(readOnly = true)
     public List getDispatchFeed(Integer limit) {
-        // 최근 배차 조회 (HOLD 상태 제외, createdAt 기준 내림차순)
-        List recentDispatches = dispatchRepository.findAll(
+        // 최근 배차 조회 (Transporter와 Fetch Join으로 N+1 문제 해결, createdAt 기준 내림차순)
+        List recentDispatches = dispatchRepository.findAllWithTransporter(
                 org.springframework.data.domain.PageRequest.of(0, limit,
                         org.springframework.data.domain.Sort.by(org.springframework.data.domain.Sort.Direction.DESC, "createdAt"))
         ).getContent();

From f73c970cd9a4e720d16a918b1af52550c8532ee9 Mon Sep 17 00:00:00 2001
From: SOWON LEE <66356241+Leesowon@users.noreply.github.com>
Date: Fri, 23 Jan 2026 09:34:21 +0900
Subject: [PATCH 3/3] =?UTF-8?q?[#57]=20docs:=20=EC=8A=A4=EC=9B=A8=EA=B1=B0?=
 =?UTF-8?q?=20=EB=AC=B8=EC=84=9C=ED=99=94?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../office/controller/OfficeV1Controller.java | 48 +++++++++++++-
 .../office/dto/response/DispatchFeedRes.java  | 63 ++++++++++++++++---
 .../domain/office/service/OfficeService.java  | 16 +++--
 3 files changed, 112 insertions(+), 15 deletions(-)

diff --git a/src/main/java/com/mobility/api/domain/office/controller/OfficeV1Controller.java b/src/main/java/com/mobility/api/domain/office/controller/OfficeV1Controller.java
index f9eb740..7097767 100644
--- a/src/main/java/com/mobility/api/domain/office/controller/OfficeV1Controller.java
+++ b/src/main/java/com/mobility/api/domain/office/controller/OfficeV1Controller.java
@@ -91,10 +91,54 @@ public CommonResponse getDispatchSummary() {
      * @param limit 조회 개수 (기본: 20)
      * @return 최근 배차 이벤트 피드 목록
      */
-    @Operation(summary = "대시보드 실시간 피드 조회", description = "최근 배차 이벤트(open, assigned, completed, canceled) 목록을 조회합니다. HOLD 상태는 제외됩니다.")
+    @Operation(
+            summary = "대시보드 실시간 피드 조회",
+            description = """
+                    최근 배차 이벤트를 시간순으로 조회합니다.
+
+                    **피드 타입:**
+                    - `open`: 배차 등록
+                    - `assigned`: 배차 할당 (기사 배정)
+                    - `completed`: 운송 완료
+                    - `canceled`: 배차 취소
+
+                    **특징:**
+                    - HOLD 상태(자동배차 진행 중)는 제외됩니다.
+                    - 최신순 정렬 (createdAt DESC)
+                    - Transporter 정보 포함 (N+1 최적화 적용)
+
+                    **응답 예시:**
+                    ```json
+                    {
+                      "code": "SUCCESS",
+                      "data": [
+                        {
+                          "id": "feed-01",
+                          "type": "assigned",
+                          "dispatchId": 123,
+                          "dispatchNumber": "2024-0001",
+                          "transporterName": "김철수",
+                          "message": "김철수 기사가 콜 #2024-0001을 배차 받았습니다",
+                          "timestamp": "2024-01-15T10:32:00"
+                        }
+                      ]
+                    }
+                    ```
+                    """
+    )
+    @io.swagger.v3.oas.annotations.responses.ApiResponses({
+            @io.swagger.v3.oas.annotations.responses.ApiResponse(
+                    responseCode = "200",
+                    description = "조회 성공"
+            )
+    })
     @RequestMapping(path = "/dispatch/feed", method = RequestMethod.GET)
     public CommonResponse> getDispatchFeed(
-            @Parameter(description = "조회 개수", example = "20")
+            @Parameter(
+                    description = "조회할 피드 개수 (기본값: 20, 최대 권장: 100)",
+                    example = "20",
+                    required = false
+            )
             @RequestParam(required = false, defaultValue = "20") Integer limit
     ) {
         return CommonResponse.success(officeService.getDispatchFeed(limit));
diff --git a/src/main/java/com/mobility/api/domain/office/dto/response/DispatchFeedRes.java b/src/main/java/com/mobility/api/domain/office/dto/response/DispatchFeedRes.java
index bb03b0a..c2e83ac 100644
--- a/src/main/java/com/mobility/api/domain/office/dto/response/DispatchFeedRes.java
+++ b/src/main/java/com/mobility/api/domain/office/dto/response/DispatchFeedRes.java
@@ -5,28 +5,75 @@
 
 import java.time.LocalDateTime;
 
-@Schema(description = "배차 피드 응답 DTO")
+@Schema(description = "대시보드 배차 피드 응답 DTO - 최근 배차 이벤트 정보")
 @Builder
 public record DispatchFeedRes(
-        @Schema(description = "피드 ID", example = "open-123")
+        @Schema(
+                description = "피드 고유 ID (연번 형식: feed-01, feed-02, ...)",
+                example = "feed-01",
+                requiredMode = Schema.RequiredMode.REQUIRED
+        )
         String id,
 
-        @Schema(description = "피드 타입", example = "assigned", allowableValues = {"open", "assigned", "completed", "canceled"})
+        @Schema(
+                description = """
+                        피드 타입
+                        - open: 배차 등록
+                        - assigned: 배차 할당 (기사 배정)
+                        - completed: 운송 완료
+                        - canceled: 배차 취소
+                        """,
+                example = "assigned",
+                allowableValues = {"open", "assigned", "completed", "canceled"},
+                requiredMode = Schema.RequiredMode.REQUIRED
+        )
         String type,
 
-        @Schema(description = "배차 ID", example = "123")
+        @Schema(
+                description = "배차 ID (Dispatch 테이블의 PK)",
+                example = "123",
+                requiredMode = Schema.RequiredMode.REQUIRED
+        )
         Long dispatchId,
 
-        @Schema(description = "배차 번호", example = "2024-0001")
+        @Schema(
+                description = "배차 번호 (예: 2024-0001) - null일 수 있음",
+                example = "2024-0001",
+                nullable = true
+        )
         String dispatchNumber,
 
-        @Schema(description = "기사 이름 (assigned, completed 시에만 존재)", example = "김철수")
+        @Schema(
+                description = "기사 이름 - assigned, completed 타입일 때만 값이 있고, open, canceled 타입일 때는 null",
+                example = "김철수",
+                nullable = true
+        )
         String transporterName,
 
-        @Schema(description = "피드 메시지", example = "김철수 기사가 콜 #2024-0001을 배차 받았습니다")
+        @Schema(
+                description = """
+                        자동 생성된 피드 메시지
+                        - open: "배차 #{번호}가 등록되었습니다"
+                        - assigned: "{기사명} 기사가 콜 #{번호}을 배차 받았습니다"
+                        - completed: "{기사명} 기사가 콜 #{번호}을 완료했습니다"
+                        - canceled: "배차 #{번호}이 취소되었습니다"
+                        """,
+                example = "김철수 기사가 콜 #2024-0001을 배차 받았습니다",
+                requiredMode = Schema.RequiredMode.REQUIRED
+        )
         String message,
 
-        @Schema(description = "이벤트 발생 시간 (BaseEntity.createdAt 또는 이벤트별 타임스탬프)", example = "2024-01-15T10:32:00")
+        @Schema(
+                description = """
+                        이벤트 발생 시간 (타입별 타임스탬프)
+                        - open: createdAt (배차 생성 시간)
+                        - assigned: assignedAt (배차 할당 시간)
+                        - completed: completedAt (운송 완료 시간)
+                        - canceled: canceledAt (취소 시간)
+                        """,
+                example = "2024-01-15T10:32:00",
+                requiredMode = Schema.RequiredMode.REQUIRED
+        )
         LocalDateTime timestamp
 ) {
 }
\ No newline at end of file
diff --git a/src/main/java/com/mobility/api/domain/office/service/OfficeService.java b/src/main/java/com/mobility/api/domain/office/service/OfficeService.java
index 00cdaa8..d832b64 100644
--- a/src/main/java/com/mobility/api/domain/office/service/OfficeService.java
+++ b/src/main/java/com/mobility/api/domain/office/service/OfficeService.java
@@ -268,10 +268,16 @@ public List getDispatchFeed(Integer limit) {
                         org.springframework.data.domain.Sort.by(org.springframework.data.domain.Sort.Direction.DESC, "createdAt"))
         ).getContent();
 
-        // 각 배차를 피드 DTO로 변환
-        return recentDispatches.stream()
-                .filter(dispatch -> dispatch.getStatus() != StatusType.HOLD) // HOLD 상태 제외
-                .map(dispatch -> {
+        // HOLD 상태 제외 후 리스트로 변환
+        List filteredDispatches = recentDispatches.stream()
+                .filter(dispatch -> dispatch.getStatus() != StatusType.HOLD)
+                .toList();
+
+        // 각 배차를 피드 DTO로 변환 (연번 부여)
+        return java.util.stream.IntStream.range(0, filteredDispatches.size())
+                .mapToObj(index -> {
+                    Dispatch dispatch = filteredDispatches.get(index);
+                    String feedId = String.format("feed-%02d", index + 1); // feed-01, feed-02, ...
                     String type = dispatch.getStatus().name().toLowerCase();
                     String transporterName = (dispatch.getTransporter() != null) ? dispatch.getTransporter().getName() : null;
                     String dispatchNumberDisplay = (dispatch.getDispatchNumber() != null) ? "#" + dispatch.getDispatchNumber() : "#" + dispatch.getId();
@@ -295,7 +301,7 @@ public List getDispatchFeed(Integer limit) {
                     };
 
                     return DispatchFeedRes.builder()
-                            .id(type + "-" + dispatch.getId())
+                            .id(feedId)
                             .type(type)
                             .dispatchId(dispatch.getId())
                             .dispatchNumber(dispatch.getDispatchNumber())