diff --git a/common/src/main/java/com/shipflow/config/message/JacksonConfig.java b/common/src/main/java/com/shipflow/config/message/JacksonConfig.java index 25e613f..6c87acf 100644 --- a/common/src/main/java/com/shipflow/config/message/JacksonConfig.java +++ b/common/src/main/java/com/shipflow/config/message/JacksonConfig.java @@ -1,5 +1,7 @@ package com.shipflow.config.message; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; @@ -17,6 +19,7 @@ public ObjectMapper objectMapper() { mapper.registerModule(new JavaTimeModule()); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + mapper.setVisibility(PropertyAccessor.FIELD, Visibility.ANY); return mapper; } } diff --git a/hub-service/src/main/java/com/shipflow/hubservice/infrastructure/client/DeliveryClient.java b/hub-service/src/main/java/com/shipflow/hubservice/infrastructure/client/DeliveryClient.java index 5f61cde..c65a819 100644 --- a/hub-service/src/main/java/com/shipflow/hubservice/infrastructure/client/DeliveryClient.java +++ b/hub-service/src/main/java/com/shipflow/hubservice/infrastructure/client/DeliveryClient.java @@ -10,7 +10,7 @@ @FeignClient(name = "deliveryservice") public interface DeliveryClient { - @DeleteMapping("/internal/delivery-managers/{hubId}") + @DeleteMapping("/internal/delivery-managers/hubs/{hubId}") void deleteCompanyDeliveryManagers( @RequestHeader("X-Internal-Request") String internalRequest, @RequestHeader("X-User-Id") String requestUserId, diff --git a/order-service/src/main/java/com/shipflow/orderservice/infrastructure/messaging/event/consume/ShipmentCompletedEvent.java b/order-service/src/main/java/com/shipflow/orderservice/infrastructure/messaging/event/consume/ShipmentCompletedEvent.java index 660f235..212716c 100644 --- a/order-service/src/main/java/com/shipflow/orderservice/infrastructure/messaging/event/consume/ShipmentCompletedEvent.java +++ b/order-service/src/main/java/com/shipflow/orderservice/infrastructure/messaging/event/consume/ShipmentCompletedEvent.java @@ -1,21 +1,22 @@ package com.shipflow.orderservice.infrastructure.messaging.event.consume; +import java.util.UUID; + import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.shipflow.common.messaging.event.SagaEvent; + import lombok.Getter; import lombok.NoArgsConstructor; -import java.util.UUID; - @Getter @NoArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) public class ShipmentCompletedEvent extends SagaEvent { - private UUID orderId; + private UUID orderId; - public ShipmentCompletedEvent(UUID orderId) { - super("shipment.completed"); - this.orderId = orderId; - } + public ShipmentCompletedEvent(UUID orderId) { + super("shipment.completed"); + this.orderId = orderId; + } } diff --git a/order-service/src/main/java/com/shipflow/orderservice/infrastructure/messaging/event/consume/ShipmentCreatedEvent.java b/order-service/src/main/java/com/shipflow/orderservice/infrastructure/messaging/event/consume/ShipmentCreatedEvent.java index a211d91..3db46f8 100644 --- a/order-service/src/main/java/com/shipflow/orderservice/infrastructure/messaging/event/consume/ShipmentCreatedEvent.java +++ b/order-service/src/main/java/com/shipflow/orderservice/infrastructure/messaging/event/consume/ShipmentCreatedEvent.java @@ -1,36 +1,37 @@ package com.shipflow.orderservice.infrastructure.messaging.event.consume; +import java.util.UUID; + import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.shipflow.common.messaging.event.SagaEvent; import com.shipflow.orderservice.domain.model.ShipmentStatus; + import lombok.Getter; import lombok.NoArgsConstructor; -import java.util.UUID; - @Getter @NoArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) public class ShipmentCreatedEvent extends SagaEvent { - private UUID orderId; - private UUID shipmentId; - private ShipmentStatus shipmentStatus; - private UUID departureHubId; - private String departureHubName; - private UUID arrivalHubId; - private String arrivalHubName; + private UUID orderId; + private UUID shipmentId; + private ShipmentStatus shipmentStatus; + private UUID departureHubId; + private String departureHubName; + private UUID arrivalHubId; + private String arrivalHubName; - public ShipmentCreatedEvent(UUID orderId, UUID shipmentId, ShipmentStatus shipmentStatus, - UUID departureHubId, String departureHubName, - UUID arrivalHubId, String arrivalHubName) { - super("shipment.created"); - this.orderId = orderId; - this.shipmentId = shipmentId; - this.shipmentStatus = shipmentStatus; - this.departureHubId = departureHubId; - this.departureHubName = departureHubName; - this.arrivalHubId = arrivalHubId; - this.arrivalHubName = arrivalHubName; - } + public ShipmentCreatedEvent(UUID orderId, UUID shipmentId, ShipmentStatus shipmentStatus, + UUID departureHubId, String departureHubName, + UUID arrivalHubId, String arrivalHubName) { + super("shipment.created"); + this.orderId = orderId; + this.shipmentId = shipmentId; + this.shipmentStatus = shipmentStatus; + this.departureHubId = departureHubId; + this.departureHubName = departureHubName; + this.arrivalHubId = arrivalHubId; + this.arrivalHubName = arrivalHubName; + } } diff --git a/order-service/src/main/java/com/shipflow/orderservice/infrastructure/messaging/event/publish/OrderCanceledEvent.java b/order-service/src/main/java/com/shipflow/orderservice/infrastructure/messaging/event/publish/OrderCanceledEvent.java index 38599d8..dd5a7eb 100644 --- a/order-service/src/main/java/com/shipflow/orderservice/infrastructure/messaging/event/publish/OrderCanceledEvent.java +++ b/order-service/src/main/java/com/shipflow/orderservice/infrastructure/messaging/event/publish/OrderCanceledEvent.java @@ -1,25 +1,26 @@ package com.shipflow.orderservice.infrastructure.messaging.event.publish; +import java.util.UUID; + import com.shipflow.common.messaging.event.SagaEvent; + import lombok.Getter; import lombok.NoArgsConstructor; -import java.util.UUID; - @Getter @NoArgsConstructor public class OrderCanceledEvent extends SagaEvent { - private static final String EVENT_TYPE = "order.canceled"; + private static final String EVENT_TYPE = "order.canceled"; - private UUID orderId; - private UUID productId; - private int quantity; + private UUID orderId; + private UUID productId; + private int quantity; - public OrderCanceledEvent(UUID orderId, UUID productId, int quantity) { - super(EVENT_TYPE); - this.orderId = orderId; - this.productId = productId; - this.quantity = quantity; - } + public OrderCanceledEvent(UUID orderId, UUID productId, int quantity) { + super(EVENT_TYPE); + this.orderId = orderId; + this.productId = productId; + this.quantity = quantity; + } } diff --git a/shipment-service/build.gradle b/shipment-service/build.gradle index c34e915..131ea16 100644 --- a/shipment-service/build.gradle +++ b/shipment-service/build.gradle @@ -29,6 +29,10 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' + // Service Discovery + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client' + // Feign implementation "org.springframework.cloud:spring-cloud-starter-openfeign" @@ -45,6 +49,9 @@ dependencies { // RabbitMQ implementation 'org.springframework.boot:spring-boot-starter-amqp' + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/ShipmentserviceApplication.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/ShipmentserviceApplication.java index 6ac891d..39ae2b9 100644 --- a/shipment-service/src/main/java/com/shipflow/shipmentservice/ShipmentserviceApplication.java +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/ShipmentserviceApplication.java @@ -3,8 +3,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.scheduling.annotation.EnableScheduling; @EnableFeignClients +@EnableScheduling @SpringBootApplication public class ShipmentserviceApplication { diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/application/ShipmentEventPublisher.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/application/ShipmentEventPublisher.java new file mode 100644 index 0000000..8cad29c --- /dev/null +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/application/ShipmentEventPublisher.java @@ -0,0 +1,14 @@ +package com.shipflow.shipmentservice.application; + +import com.shipflow.shipmentservice.domain.event.ShipmentCompletedEvent; +import com.shipflow.shipmentservice.domain.event.ShipmentCreatedEvent; +import com.shipflow.shipmentservice.domain.event.ShipmentCreationFailedEvent; + +public interface ShipmentEventPublisher { + + void publishCreated(ShipmentCreatedEvent event); + + void publishCreationFailed(ShipmentCreationFailedEvent event); + + void publishCompleted(ShipmentCompletedEvent event); +} diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/application/ShipmentManagerService.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/application/ShipmentManagerService.java index 0107727..7cbde44 100644 --- a/shipment-service/src/main/java/com/shipflow/shipmentservice/application/ShipmentManagerService.java +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/application/ShipmentManagerService.java @@ -27,6 +27,8 @@ @Transactional(readOnly = true) public class ShipmentManagerService { + private static final UUID SYSTEM_ID = UUID.fromString("00000000-0000-0000-0000-000000000000"); + private final ShipmentManagerRepository shipmentManagerRepository; private final UserClient userClient; @@ -107,4 +109,22 @@ public void deleteShipmentManager(UUID managerId, UUID userId) { shipmentManager.delete(userId); } + + @Transactional + public void markPendingDeletionByHubId(UUID hubId) { + List managers = shipmentManagerRepository.findAllByHubId(hubId); + managers.forEach(ShipmentManager::markPendingDeletion); + } + + @Transactional + public void markPendingDeletionByUserId(UUID userId) { + shipmentManagerRepository.findByUserId(userId) + .ifPresent(ShipmentManager::markPendingDeletion); + } + + @Transactional + public void deleteAllPending() { + List pending = shipmentManagerRepository.findAllPendingDeletion(); + pending.forEach(m -> m.delete(SYSTEM_ID)); + } } diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/application/ShipmentService.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/application/ShipmentService.java index d247e3d..aa8486f 100644 --- a/shipment-service/src/main/java/com/shipflow/shipmentservice/application/ShipmentService.java +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/application/ShipmentService.java @@ -1,5 +1,6 @@ package com.shipflow.shipmentservice.application; +import java.util.Comparator; import java.util.List; import java.util.UUID; @@ -8,26 +9,99 @@ import org.springframework.transaction.annotation.Transactional; import com.shipflow.common.exception.BusinessException; +import com.shipflow.shipmentservice.application.client.CacheClient; +import com.shipflow.shipmentservice.application.client.HubClient; +import com.shipflow.shipmentservice.application.client.UserClient; +import com.shipflow.shipmentservice.application.client.dto.HubRouteResult; +import com.shipflow.shipmentservice.application.client.dto.UserInfo; +import com.shipflow.shipmentservice.application.dto.command.CreateShipmentCommand; +import com.shipflow.shipmentservice.application.dto.command.ShipmentRouteUpdateCommand; +import com.shipflow.shipmentservice.application.dto.command.ShipmentUpdateCommand; +import com.shipflow.shipmentservice.application.dto.result.ShipmentCanceledResult; +import com.shipflow.shipmentservice.application.dto.result.ShipmentCompleteResult; import com.shipflow.shipmentservice.application.dto.result.ShipmentResult; import com.shipflow.shipmentservice.application.dto.result.ShipmentRouteResult; -import com.shipflow.shipmentservice.application.dto.command.ShipmentRouteUpdateCommand; import com.shipflow.shipmentservice.application.dto.result.ShipmentRouteUpdateResult; import com.shipflow.shipmentservice.application.dto.result.ShipmentSearchResult; -import com.shipflow.shipmentservice.application.dto.command.ShipmentUpdateCommand; import com.shipflow.shipmentservice.application.dto.result.ShipmentUpdateResult; import com.shipflow.shipmentservice.domain.Shipment; +import com.shipflow.shipmentservice.domain.ShipmentManager; +import com.shipflow.shipmentservice.domain.ShipmentManagerType; import com.shipflow.shipmentservice.domain.ShipmentRoute; +import com.shipflow.shipmentservice.domain.event.ShipmentCompletedEvent; +import com.shipflow.shipmentservice.domain.event.ShipmentCreatedEvent; +import com.shipflow.shipmentservice.domain.event.ShipmentCreationFailedEvent; import com.shipflow.shipmentservice.domain.exception.ShipmentErrorCode; +import com.shipflow.shipmentservice.domain.repository.ShipmentManagerRepository; import com.shipflow.shipmentservice.domain.repository.ShipmentRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class ShipmentService { + private static final String HUB_MANAGER_CURSOR_KEY = "shipment:hub_manager:cursor"; + private final ShipmentRepository shipmentRepository; + private final ShipmentManagerRepository shipmentManagerRepository; + private final HubClient hubClient; + private final UserClient userClient; + private final CacheClient cacheClient; + private final ShipmentEventPublisher eventPublisher; + + @Transactional + public void createShipment(CreateShipmentCommand command) { + try { + ShipmentManager companyManager = findCompanyManager(); + List hubRoutes = hubClient.getHubRoutes(command.departureHubId(), command.arrivalHubId()); + List hubManagers = findHubManagers(); + + UserInfo recipient = userClient.getUser(command.ordererId()); + + List routes = buildRoutes(hubRoutes, hubManagers); + + Shipment shipment = Shipment.create( + command.orderId(), + command.departureHubId(), + command.arrivalHubId(), + command.shipmentAddress(), + recipient.getName(), + recipient.getSlackId(), + companyManager, + routes + ); + + Shipment saved = shipmentRepository.save(shipment); + + List eventRoutes = saved.getRoutes().stream() + .map(r -> new ShipmentCreatedEvent.Route(r.getSequence(), r.getDepartureHubId(), r.getArrivalHubId())) + .toList(); + + eventPublisher.publishCreated(new ShipmentCreatedEvent( + saved.getOrderId(), + saved.getId(), + command.productId(), + command.quantity(), + saved.getDepartureHubId(), + saved.getArrivalHubId(), + command.requestDeadline(), + command.requestNote(), + companyManager.getSlackId(), + eventRoutes + )); + + log.info("[Shipment] Created | shipmentId={} | orderId={}", saved.getId(), saved.getOrderId()); + + } catch (Exception e) { + log.error("[Shipment] Creation failed | orderId={} | error={}", command.orderId(), e.getMessage(), e); + eventPublisher.publishCreationFailed(new ShipmentCreationFailedEvent(command.orderId())); + throw e; + } + } public List searchShipment(Pageable pageable) { return shipmentRepository.findAll(pageable).stream() @@ -70,23 +144,70 @@ public ShipmentRouteUpdateResult updateShipmentRoute(UUID shipmentId, UUID route ShipmentRoute route; switch (command.getStatus()) { + case MOVING_TO_HUB -> route = shipment.markRouteMovingToHub(routeId); + case ARRIVED_AT_HUB -> route = shipment.markRouteArrivedAtHub(routeId, command.getActualDistance()); + default -> throw new BusinessException(ShipmentErrorCode.INVALID_SHIPMENT_ROUTE_STATUS); + } - case MOVING_TO_HUB -> { - route = shipment.markRouteMovingToHub(routeId); - } + return ShipmentRouteUpdateResult.fromEntity(route); + } - case ARRIVED_AT_HUB -> { - route = shipment.markRouteArrivedAtHub( - routeId, - command.getActualDistance() - ); - } + @Transactional + public ShipmentCompleteResult completeShipment(UUID shipmentId) { + Shipment shipment = shipmentRepository.findByIdWithRoutes(shipmentId) + .orElseThrow(() -> new BusinessException(ShipmentErrorCode.SHIPMENT_NOT_FOUND)); - default -> { - throw new BusinessException(ShipmentErrorCode.INVALID_SHIPMENT_ROUTE_STATUS); - } + shipment.markCompleted(); + + eventPublisher.publishCompleted(new ShipmentCompletedEvent( + shipment.getOrderId(), + shipment.getId() + )); + + return ShipmentCompleteResult.fromEntity(shipment); + } + + @Transactional + public ShipmentCanceledResult cancelShipment(UUID orderId) { + Shipment shipment = shipmentRepository.findByOrderIdWithRoutes(orderId) + .orElseThrow(() -> new BusinessException(ShipmentErrorCode.SHIPMENT_NOT_FOUND)); + + shipment.markCanceled(); + + return ShipmentCanceledResult.fromEntity(shipment); + } + + private ShipmentManager findCompanyManager() { + return shipmentManagerRepository.findFirstAvailableByType(ShipmentManagerType.COMPANY) + .orElseThrow(() -> new BusinessException(ShipmentErrorCode.SHIPMENT_MANAGER_NOT_FOUND)); + } + + private List findHubManagers() { + List managers = shipmentManagerRepository.findAllByType(ShipmentManagerType.HUB); + if (managers.isEmpty()) { + throw new BusinessException(ShipmentErrorCode.SHIPMENT_MANAGER_NOT_FOUND); } + return managers; + } - return ShipmentRouteUpdateResult.fromEntity(route); + private List buildRoutes(List hubRoutes, List hubManagers) { + long cursor = cacheClient.increment(HUB_MANAGER_CURSOR_KEY); + int startIndex = (int)((cursor - 1) % hubManagers.size()); + + return hubRoutes.stream() + .sorted(Comparator.comparingInt(HubRouteResult::getSequence)) + .map(route -> { + int managerIndex = (startIndex + route.getSequence() - 1) % hubManagers.size(); + ShipmentManager assignedManager = hubManagers.get(managerIndex); + return ShipmentRoute.create( + route.getSequence(), + route.getDepartureHubId(), + route.getArrivalHubId(), + route.getEstimatedDistance(), + route.getEstimatedDuration(), + assignedManager + ); + }) + .toList(); } } diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/application/client/CacheClient.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/application/client/CacheClient.java new file mode 100644 index 0000000..c4486ed --- /dev/null +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/application/client/CacheClient.java @@ -0,0 +1,11 @@ +package com.shipflow.shipmentservice.application.client; + +import java.time.Duration; + +public interface CacheClient { + long increment(String key); + + boolean hasKey(String key); + + void set(String key, String value, Duration ttl); +} \ No newline at end of file diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/application/client/HubClient.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/application/client/HubClient.java new file mode 100644 index 0000000..1b25571 --- /dev/null +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/application/client/HubClient.java @@ -0,0 +1,10 @@ +package com.shipflow.shipmentservice.application.client; + +import java.util.List; +import java.util.UUID; + +import com.shipflow.shipmentservice.application.client.dto.HubRouteResult; + +public interface HubClient { + List getHubRoutes(UUID departureHubId, UUID arrivalHubId); +} diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/application/client/dto/HubRouteResult.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/application/client/dto/HubRouteResult.java new file mode 100644 index 0000000..72a3b8f --- /dev/null +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/application/client/dto/HubRouteResult.java @@ -0,0 +1,17 @@ +package com.shipflow.shipmentservice.application.client.dto; + +import java.math.BigDecimal; +import java.util.UUID; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class HubRouteResult { + private Integer sequence; + private UUID departureHubId; + private UUID arrivalHubId; + private BigDecimal estimatedDistance; + private Integer estimatedDuration; +} diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/application/dto/command/CreateShipmentCommand.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/application/dto/command/CreateShipmentCommand.java new file mode 100644 index 0000000..630102d --- /dev/null +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/application/dto/command/CreateShipmentCommand.java @@ -0,0 +1,17 @@ +package com.shipflow.shipmentservice.application.dto.command; + +import java.time.LocalDateTime; +import java.util.UUID; + +public record CreateShipmentCommand( + UUID orderId, + UUID ordererId, + UUID productId, + int quantity, + UUID departureHubId, + UUID arrivalHubId, + LocalDateTime requestDeadline, + String requestNote, + String shipmentAddress +) { +} diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/application/dto/result/ShipmentCanceledResult.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/application/dto/result/ShipmentCanceledResult.java new file mode 100644 index 0000000..76157e0 --- /dev/null +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/application/dto/result/ShipmentCanceledResult.java @@ -0,0 +1,29 @@ +package com.shipflow.shipmentservice.application.dto.result; + +import java.time.LocalDateTime; +import java.util.UUID; + +import com.shipflow.shipmentservice.domain.Shipment; +import com.shipflow.shipmentservice.domain.ShipmentStatus; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ShipmentCanceledResult { + + private UUID shipmentId; + private UUID orderId; + private ShipmentStatus status; + private LocalDateTime canceledAt; + + public static ShipmentCanceledResult fromEntity(Shipment shipment) { + return ShipmentCanceledResult.builder() + .shipmentId(shipment.getId()) + .orderId(shipment.getOrderId()) + .status(shipment.getStatus()) + .canceledAt(shipment.getUpdatedAt()) + .build(); + } +} diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/application/dto/result/ShipmentCompleteResult.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/application/dto/result/ShipmentCompleteResult.java new file mode 100644 index 0000000..76a28a4 --- /dev/null +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/application/dto/result/ShipmentCompleteResult.java @@ -0,0 +1,29 @@ +package com.shipflow.shipmentservice.application.dto.result; + +import java.time.LocalDateTime; +import java.util.UUID; + +import com.shipflow.shipmentservice.domain.Shipment; +import com.shipflow.shipmentservice.domain.ShipmentStatus; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ShipmentCompleteResult { + + private UUID shipmentId; + private UUID orderId; + private ShipmentStatus status; + private LocalDateTime completedAt; + + public static ShipmentCompleteResult fromEntity(Shipment shipment) { + return ShipmentCompleteResult.builder() + .shipmentId(shipment.getId()) + .orderId(shipment.getOrderId()) + .status(shipment.getStatus()) + .completedAt(shipment.getUpdatedAt()) + .build(); + } +} diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/Shipment.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/Shipment.java index b1d767e..503d449 100644 --- a/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/Shipment.java +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/Shipment.java @@ -3,6 +3,7 @@ import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.UUID; @@ -37,7 +38,7 @@ public class Shipment extends BaseEntity { @GeneratedValue(strategy = GenerationType.UUID) private UUID id; - @Column(name = "order_id", nullable = false) + @Column(name = "order_id", nullable = false, unique = true) private UUID orderId; @Enumerated(EnumType.STRING) @@ -114,6 +115,44 @@ public static Shipment create( return shipment; } + public void markCompleted() { + validateCompletable(); + this.status = ShipmentStatus.COMPLETED; + } + + public void markCanceled() { + validateCancelable(); + this.status = ShipmentStatus.CANCELLED; + markAllRoutesCanceled(); + } + + private void validateCompletable() { + if (this.status == ShipmentStatus.COMPLETED) { + throw new BusinessException(ShipmentErrorCode.SHIPMENT_ALREADY_COMPLETED); + } + + if (this.status == ShipmentStatus.CANCELLED) { + throw new BusinessException(ShipmentErrorCode.SHIPMENT_ALREADY_CANCELLED); + } + + boolean allRoutesCompleted = routes.stream() + .allMatch(r -> r.getStatus() == ShipmentRouteStatus.ARRIVED_AT_HUB); + + if (!allRoutesCompleted) { + throw new BusinessException(ShipmentErrorCode.SHIPMENT_ROUTES_NOT_ALL_COMPLETED); + } + } + + private void validateCancelable() { + if (this.status == ShipmentStatus.CANCELLED) { + throw new BusinessException(ShipmentErrorCode.SHIPMENT_ALREADY_CANCELLED); + } + + if (this.status != ShipmentStatus.WAITING_AT_HUB) { + throw new BusinessException(ShipmentErrorCode.SHIPMENT_NOT_CANCELABLE_STATUS); + } + } + public void addRoute(ShipmentRoute route) { this.routes.add(route); route.assignShipment(this); @@ -138,10 +177,14 @@ public ShipmentRoute markRouteArrivedAtHub(UUID routeId, BigDecimal actualDistan return route; } + private void markAllRoutesCanceled() { + routes.forEach(ShipmentRoute::markCanceled); + } + private LocalDateTime getArrivalBaseTime(ShipmentRoute currentRoute) { ShipmentRoute previousRoute = routes.stream() .filter(r -> r.getSequence() < currentRoute.getSequence()) - .max((a, b) -> Integer.compare(a.getSequence(), b.getSequence())) + .max(Comparator.comparingInt(ShipmentRoute::getSequence)) .orElse(null); // 첫 번째 경로인 경우: 현재 경로가 MOVING_TO_HUB 로 변경된 시점 기준 diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/ShipmentManager.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/ShipmentManager.java index e3c36b2..f6e46af 100644 --- a/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/ShipmentManager.java +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/ShipmentManager.java @@ -51,10 +51,17 @@ public class ShipmentManager extends BaseEntity { @Column(name = "shipment_sequence", nullable = false) private Integer shipmentSequence; + @Column(name = "pending_deletion", nullable = false) + private boolean pendingDeletion = false; + public void updateSequence(int sequence) { this.shipmentSequence = sequence; } + public void markPendingDeletion() { + this.pendingDeletion = true; + } + public void delete(UUID deletedBy) { softDelete(deletedBy); } diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/ShipmentRoute.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/ShipmentRoute.java index c90baf3..83d1ab0 100644 --- a/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/ShipmentRoute.java +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/ShipmentRoute.java @@ -118,6 +118,10 @@ void assignShipment(Shipment shipment) { this.shipment = shipment; } + public void markCanceled() { + this.status = ShipmentRouteStatus.CANCELED; + } + public void markMovingToHub() { validateMovableToHub(); this.status = ShipmentRouteStatus.MOVING_TO_HUB; diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/ShipmentRouteStatus.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/ShipmentRouteStatus.java index d8a6c46..dfa07d1 100644 --- a/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/ShipmentRouteStatus.java +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/ShipmentRouteStatus.java @@ -3,5 +3,6 @@ public enum ShipmentRouteStatus { WAITING_AT_HUB, MOVING_TO_HUB, - ARRIVED_AT_HUB + ARRIVED_AT_HUB, + CANCELED } diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/event/ShipmentCompletedEvent.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/event/ShipmentCompletedEvent.java new file mode 100644 index 0000000..4e46c5c --- /dev/null +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/event/ShipmentCompletedEvent.java @@ -0,0 +1,6 @@ +package com.shipflow.shipmentservice.domain.event; + +import java.util.UUID; + +public record ShipmentCompletedEvent(UUID orderId, UUID shipmentId) { +} diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/event/ShipmentCreatedEvent.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/event/ShipmentCreatedEvent.java new file mode 100644 index 0000000..1305b7a --- /dev/null +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/event/ShipmentCreatedEvent.java @@ -0,0 +1,21 @@ +package com.shipflow.shipmentservice.domain.event; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +public record ShipmentCreatedEvent( + UUID orderId, + UUID shipmentId, + UUID productId, + int quantity, + UUID departureHubId, + UUID arrivalHubId, + LocalDateTime requestDeadline, + String requestNote, + String shipmentManagerSlackId, + List routes +) { + public record Route(int sequence, UUID departureHubId, UUID arrivalHubId) { + } +} diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/event/ShipmentCreationFailedEvent.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/event/ShipmentCreationFailedEvent.java new file mode 100644 index 0000000..2fc5183 --- /dev/null +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/event/ShipmentCreationFailedEvent.java @@ -0,0 +1,6 @@ +package com.shipflow.shipmentservice.domain.event; + +import java.util.UUID; + +public record ShipmentCreationFailedEvent(UUID orderId) { +} \ No newline at end of file diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/exception/ShipmentErrorCode.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/exception/ShipmentErrorCode.java index 65a7a9d..9dc88b9 100644 --- a/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/exception/ShipmentErrorCode.java +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/exception/ShipmentErrorCode.java @@ -9,6 +9,10 @@ public enum ShipmentErrorCode implements ErrorCode { // Shipment SHIPMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "배송을 찾을 수 없습니다."), INVALID_SHIPMENT_STATUS(HttpStatus.BAD_REQUEST, "잘못된 배송 상태입니다."), + SHIPMENT_ALREADY_COMPLETED(HttpStatus.CONFLICT, "이미 완료된 배송입니다."), + SHIPMENT_ALREADY_CANCELLED(HttpStatus.CONFLICT, "이미 취소된 배송입니다."), + SHIPMENT_NOT_CANCELABLE_STATUS(HttpStatus.BAD_REQUEST, "현재 배송이 시작되어 취소가 불가능합니다."), + SHIPMENT_ROUTES_NOT_ALL_COMPLETED(HttpStatus.BAD_REQUEST, "완료되지 않은 배송 경로가 존재합니다."), // ShipmentRoute SHIPMENT_ROUTE_NOT_FOUND(HttpStatus.NOT_FOUND, "배송 경로를 찾을 수 없습니다."), @@ -26,9 +30,15 @@ public enum ShipmentErrorCode implements ErrorCode { SHIPMENT_MANAGER_SLACK_ID_REQUIRED(HttpStatus.BAD_REQUEST, "배송 담당자 slackId는 필수입니다."), INVALID_SHIPMENT_SEQUENCE(HttpStatus.BAD_REQUEST, "배송 순번은 0 이상이어야 합니다."), + // Auth + MISSING_USER_ID(HttpStatus.UNAUTHORIZED, "인증 정보가 없습니다."), + MISSING_USER_ROLE(HttpStatus.FORBIDDEN, "권한 정보가 없습니다."), + // External Service USER_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다."), USER_SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "사용자 서비스에 연결할 수 없습니다."), + HUB_ROUTE_NOT_FOUND(HttpStatus.NOT_FOUND, "허브 경로를 찾을 수 없습니다."), + HUB_SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "허브 서비스에 연결할 수 없습니다."), // Validation INVALID_ACTUAL_DISTANCE(HttpStatus.BAD_REQUEST, "실제 이동 거리는 0보다 커야 합니다."), diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/repository/ShipmentManagerRepository.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/repository/ShipmentManagerRepository.java index b02778d..0ae61ff 100644 --- a/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/repository/ShipmentManagerRepository.java +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/repository/ShipmentManagerRepository.java @@ -21,4 +21,14 @@ public interface ShipmentManagerRepository { int findMaxSequenceByTypeAndHubId(ShipmentManagerType type, UUID hubId); Optional findById(UUID managerId); + + Optional findFirstAvailableByType(ShipmentManagerType type); + + List findAllByType(ShipmentManagerType type); + + List findAllByHubId(UUID hubId); + + Optional findByUserId(UUID userId); + + List findAllPendingDeletion(); } diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/repository/ShipmentRepository.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/repository/ShipmentRepository.java index cae2728..702ee01 100644 --- a/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/repository/ShipmentRepository.java +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/domain/repository/ShipmentRepository.java @@ -9,6 +9,8 @@ import com.shipflow.shipmentservice.domain.Shipment; public interface ShipmentRepository { + Shipment save(Shipment shipment); + Optional findById(UUID shipmentId); Optional findByIdWithManager(UUID shipmentId); @@ -16,4 +18,6 @@ public interface ShipmentRepository { List findAll(Pageable pageable); Optional findByIdWithRoutes(UUID shipmentId); + + Optional findByOrderIdWithRoutes(UUID orderId); } diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/client/HubClient.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/client/HubClient.java deleted file mode 100644 index 76cabf1..0000000 --- a/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/client/HubClient.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.shipflow.shipmentservice.infrastructure.client; - -public interface HubClient { -} diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/client/HubClientImpl.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/client/HubClientImpl.java new file mode 100644 index 0000000..f54b41c --- /dev/null +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/client/HubClientImpl.java @@ -0,0 +1,46 @@ +package com.shipflow.shipmentservice.infrastructure.client; + +import java.util.List; +import java.util.UUID; + +import org.springframework.stereotype.Component; + +import com.shipflow.common.exception.ApiResponse; +import com.shipflow.common.exception.BusinessException; +import com.shipflow.shipmentservice.application.client.HubClient; +import com.shipflow.shipmentservice.application.client.dto.HubRouteResult; +import com.shipflow.shipmentservice.domain.exception.ShipmentErrorCode; + +import feign.FeignException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class HubClientImpl implements HubClient { + + private final HubFeignClient hubFeignClient; + + @Override + public List getHubRoutes(UUID departureHubId, UUID arrivalHubId) { + try { + ApiResponse> response = hubFeignClient.getHubRoutes(departureHubId, arrivalHubId); + + List routes = response.getData(); + if (routes == null || routes.isEmpty()) { + throw new BusinessException(ShipmentErrorCode.HUB_ROUTE_NOT_FOUND); + } + + return routes; + } catch (BusinessException e) { + throw e; + } catch (FeignException.NotFound e) { + log.warn("[HubClient] 허브 경로 없음 | departureHubId={} | arrivalHubId={}", departureHubId, arrivalHubId); + throw new BusinessException(ShipmentErrorCode.HUB_ROUTE_NOT_FOUND); + } catch (FeignException e) { + log.error("[HubClient] 허브 서비스 호출 실패 | status={} | message={}", e.status(), e.getMessage()); + throw new BusinessException(ShipmentErrorCode.HUB_SERVICE_UNAVAILABLE); + } + } +} diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/client/HubFeignClient.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/client/HubFeignClient.java new file mode 100644 index 0000000..193d4b7 --- /dev/null +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/client/HubFeignClient.java @@ -0,0 +1,20 @@ +package com.shipflow.shipmentservice.infrastructure.client; + +import java.util.List; +import java.util.UUID; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import com.shipflow.common.exception.ApiResponse; +import com.shipflow.shipmentservice.application.client.dto.HubRouteResult; + +@FeignClient(name = "hub-service", path = "/internal") +public interface HubFeignClient { + @GetMapping("/hub-routes") + ApiResponse> getHubRoutes( + @RequestParam UUID departureHubId, + @RequestParam UUID arrivalHubId + ); +} diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/client/RedisClient.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/client/RedisClient.java new file mode 100644 index 0000000..7f39e5e --- /dev/null +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/client/RedisClient.java @@ -0,0 +1,33 @@ +package com.shipflow.shipmentservice.infrastructure.client; + +import java.time.Duration; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import com.shipflow.shipmentservice.application.client.CacheClient; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class RedisClient implements CacheClient { + + private final RedisTemplate redisTemplate; + + @Override + public long increment(String key) { + Long value = redisTemplate.opsForValue().increment(key); + return value != null ? value : 0L; + } + + @Override + public boolean hasKey(String key) { + return Boolean.TRUE.equals(redisTemplate.hasKey(key)); + } + + @Override + public void set(String key, String value, Duration ttl) { + redisTemplate.opsForValue().set(key, value, ttl); + } +} diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/ShipmentEventPublisherImpl.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/ShipmentEventPublisherImpl.java new file mode 100644 index 0000000..6d912fc --- /dev/null +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/ShipmentEventPublisherImpl.java @@ -0,0 +1,36 @@ +package com.shipflow.shipmentservice.infrastructure.messaging; + +import org.springframework.stereotype.Component; + +import com.shipflow.common.messaging.publisher.EventPublisher; +import com.shipflow.shipmentservice.application.ShipmentEventPublisher; +import com.shipflow.shipmentservice.domain.event.ShipmentCompletedEvent; +import com.shipflow.shipmentservice.domain.event.ShipmentCreatedEvent; +import com.shipflow.shipmentservice.domain.event.ShipmentCreationFailedEvent; +import com.shipflow.shipmentservice.infrastructure.messaging.event.publish.ShipmentCompletedSagaEvent; +import com.shipflow.shipmentservice.infrastructure.messaging.event.publish.ShipmentCreatedSagaEvent; +import com.shipflow.shipmentservice.infrastructure.messaging.event.publish.ShipmentCreationFailedSagaEvent; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class ShipmentEventPublisherImpl implements ShipmentEventPublisher { + + private final EventPublisher eventPublisher; + + @Override + public void publishCreated(ShipmentCreatedEvent event) { + eventPublisher.publish(new ShipmentCreatedSagaEvent(event)); + } + + @Override + public void publishCreationFailed(ShipmentCreationFailedEvent event) { + eventPublisher.publish(new ShipmentCreationFailedSagaEvent(event.orderId())); + } + + @Override + public void publishCompleted(ShipmentCompletedEvent event) { + eventPublisher.publish(new ShipmentCompletedSagaEvent(event)); + } +} diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/config/ShipmentRabbitConfig.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/config/ShipmentRabbitConfig.java index e4f3eb7..eff7b8d 100644 --- a/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/config/ShipmentRabbitConfig.java +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/config/ShipmentRabbitConfig.java @@ -13,11 +13,23 @@ @Configuration public class ShipmentRabbitConfig { + // Routing Keys private static final String ROUTING_PRODUCT_STOCK_DECREASED = "product.stock.decreased"; + private static final String ROUTING_ORDER_CREATED = "order.created"; + private static final String ROUTING_ORDER_CANCELED = "order.canceled"; + // Queue Names public static final String QUEUE_SHIPMENT_PRODUCT_STOCK_DECREASED = "shipment.product.stock.decreased"; - public static final String QUEUE_SHIPMENT_PRODUCT_STOCK_DECREASED_DLQ = + public static final String QUEUE_SHIPMENT_ORDER_CREATED = "shipment.order.created"; + public static final String QUEUE_SHIPMENT_ORDER_CANCELED = "shipment.order.canceled"; + + // DLQ Names + private static final String QUEUE_SHIPMENT_PRODUCT_STOCK_DECREASED_DLQ = QUEUE_SHIPMENT_PRODUCT_STOCK_DECREASED + ".dlq"; + private static final String QUEUE_SHIPMENT_ORDER_CREATED_DLQ = QUEUE_SHIPMENT_ORDER_CREATED + ".dlq"; + private static final String QUEUE_SHIPMENT_ORDER_CANCELED_DLQ = QUEUE_SHIPMENT_ORDER_CANCELED + ".dlq"; + + // ---- product.stock.decreased ---- @Bean public Queue queueShipmentProductStockDecreased() { @@ -42,4 +54,56 @@ public Binding bindShipmentProductStockDecreasedDlq(DirectExchange sagaDlx) { .to(sagaDlx) .with(QUEUE_SHIPMENT_PRODUCT_STOCK_DECREASED_DLQ); } + + // ---- order.created ---- + + @Bean + public Queue queueShipmentOrderCreated() { + return RabbitMqConfig.durableQueue(QUEUE_SHIPMENT_ORDER_CREATED); + } + + @Bean + public Queue queueShipmentOrderCreatedDlq() { + return RabbitMqConfig.dlqQueue(QUEUE_SHIPMENT_ORDER_CREATED_DLQ); + } + + @Bean + public Binding bindShipmentOrderCreated(TopicExchange sagaExchange) { + return BindingBuilder.bind(queueShipmentOrderCreated()) + .to(sagaExchange) + .with(ROUTING_ORDER_CREATED); + } + + @Bean + public Binding bindShipmentOrderCreatedDlq(DirectExchange sagaDlx) { + return BindingBuilder.bind(queueShipmentOrderCreatedDlq()) + .to(sagaDlx) + .with(QUEUE_SHIPMENT_ORDER_CREATED_DLQ); + } + + // ---- order.canceled ---- + + @Bean + public Queue queueShipmentOrderCanceled() { + return RabbitMqConfig.durableQueue(QUEUE_SHIPMENT_ORDER_CANCELED); + } + + @Bean + public Queue queueShipmentOrderCanceledDlq() { + return RabbitMqConfig.dlqQueue(QUEUE_SHIPMENT_ORDER_CANCELED_DLQ); + } + + @Bean + public Binding bindShipmentOrderCanceled(TopicExchange sagaExchange) { + return BindingBuilder.bind(queueShipmentOrderCanceled()) + .to(sagaExchange) + .with(ROUTING_ORDER_CANCELED); + } + + @Bean + public Binding bindShipmentOrderCanceledDlq(DirectExchange sagaDlx) { + return BindingBuilder.bind(queueShipmentOrderCanceledDlq()) + .to(sagaDlx) + .with(QUEUE_SHIPMENT_ORDER_CANCELED_DLQ); + } } diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/event/consume/OrderCanceledEvent.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/event/consume/OrderCanceledEvent.java new file mode 100644 index 0000000..692babb --- /dev/null +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/event/consume/OrderCanceledEvent.java @@ -0,0 +1,18 @@ +package com.shipflow.shipmentservice.infrastructure.messaging.event.consume; + +import java.time.LocalDateTime; +import java.util.UUID; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.shipflow.common.messaging.event.SagaEvent; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class OrderCanceledEvent extends SagaEvent { + + private UUID orderId; +} diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/event/consume/OrderCreatedEvent.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/event/consume/OrderCreatedEvent.java new file mode 100644 index 0000000..795bff4 --- /dev/null +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/event/consume/OrderCreatedEvent.java @@ -0,0 +1,28 @@ +package com.shipflow.shipmentservice.infrastructure.messaging.event.consume; + +import java.time.LocalDateTime; +import java.util.UUID; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.shipflow.common.messaging.event.SagaEvent; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class OrderCreatedEvent extends SagaEvent { + + private UUID orderId; + private UUID ordererId; + private UUID supplierCompanyId; + private UUID receiverCompanyId; + private UUID productId; + private int quantity; + private UUID departureHubId; + private UUID arrivalHubId; + private LocalDateTime requestDeadline; + private String requestNote; + private String shipmentAddress; +} diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/event/publish/ShipmentCompletedSagaEvent.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/event/publish/ShipmentCompletedSagaEvent.java new file mode 100644 index 0000000..2387971 --- /dev/null +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/event/publish/ShipmentCompletedSagaEvent.java @@ -0,0 +1,23 @@ +package com.shipflow.shipmentservice.infrastructure.messaging.event.publish; + +import java.util.UUID; + +import com.shipflow.common.messaging.event.SagaEvent; +import com.shipflow.shipmentservice.domain.event.ShipmentCompletedEvent; + +import lombok.Getter; + +@Getter +public class ShipmentCompletedSagaEvent extends SagaEvent { + + private static final String EVENT_TYPE = "shipment.completed"; + + private final UUID orderId; + private final UUID shipmentId; + + public ShipmentCompletedSagaEvent(ShipmentCompletedEvent event) { + super(EVENT_TYPE); + this.orderId = event.orderId(); + this.shipmentId = event.shipmentId(); + } +} diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/event/publish/ShipmentCreatedSagaEvent.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/event/publish/ShipmentCreatedSagaEvent.java new file mode 100644 index 0000000..b8f5e54 --- /dev/null +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/event/publish/ShipmentCreatedSagaEvent.java @@ -0,0 +1,46 @@ +package com.shipflow.shipmentservice.infrastructure.messaging.event.publish; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import com.shipflow.common.messaging.event.SagaEvent; +import com.shipflow.shipmentservice.domain.event.ShipmentCreatedEvent; + +import lombok.Getter; + +@Getter +public class ShipmentCreatedSagaEvent extends SagaEvent { + + private static final String EVENT_TYPE = "shipment.created"; + + private final UUID orderId; + private final UUID shipmentId; + private final UUID productId; + private final int quantity; + private final UUID departureHubId; + private final UUID arrivalHubId; + private final LocalDateTime requestDeadline; + private final String requestNote; + private final String shipmentManagerSlackId; + private final List routes; + + public ShipmentCreatedSagaEvent(ShipmentCreatedEvent event) { + super(EVENT_TYPE); + this.orderId = event.orderId(); + this.shipmentId = event.shipmentId(); + this.productId = event.productId(); + this.quantity = event.quantity(); + this.departureHubId = event.departureHubId(); + this.arrivalHubId = event.arrivalHubId(); + this.requestDeadline = event.requestDeadline(); + this.requestNote = event.requestNote(); + this.shipmentManagerSlackId = event.shipmentManagerSlackId(); + this.routes = event.routes().stream() + .map(r -> new Route(r.sequence(), r.departureHubId(), r.arrivalHubId())) + .toList(); + } + + public record Route(int sequence, UUID departureHubId, UUID arrivalHubId) { + } +} diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/event/publish/ShipmentCreationFailedSagaEvent.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/event/publish/ShipmentCreationFailedSagaEvent.java new file mode 100644 index 0000000..b735d1f --- /dev/null +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/event/publish/ShipmentCreationFailedSagaEvent.java @@ -0,0 +1,20 @@ +package com.shipflow.shipmentservice.infrastructure.messaging.event.publish; + +import java.util.UUID; + +import com.shipflow.common.messaging.event.SagaEvent; + +import lombok.Getter; + +@Getter +public class ShipmentCreationFailedSagaEvent extends SagaEvent { + + private static final String EVENT_TYPE = "shipment.creation.failed"; + + private final UUID orderId; + + public ShipmentCreationFailedSagaEvent(UUID orderId) { + super(EVENT_TYPE); + this.orderId = orderId; + } +} \ No newline at end of file diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/handler/IdempotentSagaExecutor.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/handler/IdempotentSagaExecutor.java new file mode 100644 index 0000000..e59d372 --- /dev/null +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/handler/IdempotentSagaExecutor.java @@ -0,0 +1,50 @@ +package com.shipflow.shipmentservice.infrastructure.messaging.handler; + +import java.time.Duration; +import java.util.function.Consumer; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import com.shipflow.common.messaging.event.SagaEvent; +import com.shipflow.shipmentservice.application.client.CacheClient; +import com.shipflow.shipmentservice.infrastructure.persistence.ProcessedSagaEventJpaEntity; +import com.shipflow.shipmentservice.infrastructure.persistence.ProcessedSagaEventRepository; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class IdempotentSagaExecutor { + + private static final Duration REDIS_TTL = Duration.ofHours(24); + + private final ProcessedSagaEventRepository processedSagaEventRepository; + private final CacheClient cacheClient; + + public boolean hasProcessed(String redisKey) { + return cacheClient.hasKey(redisKey); + } + + public void rewarmCache(String redisKey) { + cacheClient.set(redisKey, "1", REDIS_TTL); + } + + @Transactional + public void executeWithIdempotency(T event, String redisKey, Consumer doProcess) { + doProcess.accept(event); + + processedSagaEventRepository.save( + ProcessedSagaEventJpaEntity.of(event.getEventId(), event.getEventType()) + ); + + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + cacheClient.set(redisKey, "1", REDIS_TTL); + } + }); + } +} diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/handler/IdempotentSagaHandler.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/handler/IdempotentSagaHandler.java new file mode 100644 index 0000000..24a7d24 --- /dev/null +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/handler/IdempotentSagaHandler.java @@ -0,0 +1,38 @@ +package com.shipflow.shipmentservice.infrastructure.messaging.handler; + +import com.shipflow.common.messaging.event.SagaEvent; +import com.shipflow.common.messaging.handler.AbstractSagaHandler; +import com.shipflow.shipmentservice.infrastructure.persistence.ProcessedSagaEventRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public abstract class IdempotentSagaHandler extends AbstractSagaHandler { + + private static final String REDIS_KEY_PREFIX = "idempotent:saga:"; + + private final ProcessedSagaEventRepository processedSagaEventRepository; + private final IdempotentSagaExecutor sagaExecutor; + + @Override + protected final void process(T event) { + String redisKey = REDIS_KEY_PREFIX + event.getEventId(); + + if (sagaExecutor.hasProcessed(redisKey)) { + log.info("[Idempotent] Skip - already processed (Redis) | eventId={}", event.getEventId()); + return; + } + + if (processedSagaEventRepository.existsById(event.getEventId())) { + log.info("[Idempotent] Skip - already processed (DB) | eventId={}", event.getEventId()); + sagaExecutor.rewarmCache(redisKey); + return; + } + + sagaExecutor.executeWithIdempotency(event, redisKey, this::doProcess); + } + + protected abstract void doProcess(T event); +} diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/handler/OrderCanceledHandler.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/handler/OrderCanceledHandler.java new file mode 100644 index 0000000..d69365e --- /dev/null +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/handler/OrderCanceledHandler.java @@ -0,0 +1,34 @@ +package com.shipflow.shipmentservice.infrastructure.messaging.handler; + +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.stereotype.Component; + +import com.shipflow.shipmentservice.application.ShipmentService; +import com.shipflow.shipmentservice.infrastructure.messaging.config.ShipmentRabbitConfig; +import com.shipflow.shipmentservice.infrastructure.messaging.event.consume.OrderCanceledEvent; +import com.shipflow.shipmentservice.infrastructure.persistence.ProcessedSagaEventRepository; + +@Component +public class OrderCanceledHandler extends IdempotentSagaHandler { + + private final ShipmentService shipmentService; + + public OrderCanceledHandler( + ProcessedSagaEventRepository processedSagaEventRepository, + IdempotentSagaExecutor sagaExecutor, + ShipmentService shipmentService + ) { + super(processedSagaEventRepository, sagaExecutor); + this.shipmentService = shipmentService; + } + + @RabbitListener(queues = ShipmentRabbitConfig.QUEUE_SHIPMENT_ORDER_CANCELED) + public void receive(OrderCanceledEvent event) { + handle(event); + } + + @Override + protected void doProcess(OrderCanceledEvent event) { + shipmentService.cancelShipment(event.getOrderId()); + } +} diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/handler/OrderCreatedHandler.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/handler/OrderCreatedHandler.java new file mode 100644 index 0000000..f3928aa --- /dev/null +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/handler/OrderCreatedHandler.java @@ -0,0 +1,45 @@ +package com.shipflow.shipmentservice.infrastructure.messaging.handler; + +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.stereotype.Component; + +import com.shipflow.shipmentservice.application.ShipmentService; +import com.shipflow.shipmentservice.application.dto.command.CreateShipmentCommand; +import com.shipflow.shipmentservice.infrastructure.messaging.config.ShipmentRabbitConfig; +import com.shipflow.shipmentservice.infrastructure.messaging.event.consume.OrderCreatedEvent; +import com.shipflow.shipmentservice.infrastructure.persistence.ProcessedSagaEventRepository; + +@Component +public class OrderCreatedHandler extends IdempotentSagaHandler { + + private final ShipmentService shipmentService; + + public OrderCreatedHandler( + ProcessedSagaEventRepository processedSagaEventRepository, + IdempotentSagaExecutor sagaExecutor, + ShipmentService shipmentService + ) { + super(processedSagaEventRepository, sagaExecutor); + this.shipmentService = shipmentService; + } + + @RabbitListener(queues = ShipmentRabbitConfig.QUEUE_SHIPMENT_ORDER_CREATED) + public void receive(OrderCreatedEvent event) { + handle(event); + } + + @Override + protected void doProcess(OrderCreatedEvent event) { + shipmentService.createShipment(new CreateShipmentCommand( + event.getOrderId(), + event.getOrdererId(), + event.getProductId(), + event.getQuantity(), + event.getDepartureHubId(), + event.getArrivalHubId(), + event.getRequestDeadline(), + event.getRequestNote(), + event.getShipmentAddress() + )); + } +} \ No newline at end of file diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/scheduler/ProcessedSagaEventCleanupScheduler.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/scheduler/ProcessedSagaEventCleanupScheduler.java new file mode 100644 index 0000000..6da6b83 --- /dev/null +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/scheduler/ProcessedSagaEventCleanupScheduler.java @@ -0,0 +1,28 @@ +package com.shipflow.shipmentservice.infrastructure.messaging.scheduler; + +import java.time.LocalDateTime; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.shipflow.shipmentservice.infrastructure.persistence.ProcessedSagaEventRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ProcessedSagaEventCleanupScheduler { + + private final ProcessedSagaEventRepository processedSagaEventRepository; + + @Scheduled(cron = "0 0 * * * *") + @Transactional + public void cleanUpExpiredEvents() { + LocalDateTime cutoff = LocalDateTime.now().minusHours(24); + processedSagaEventRepository.deleteByProcessedAtBefore(cutoff); + log.info("[Idempotent] Cleanup expired saga events | cutoff={}", cutoff); + } +} diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/scheduler/ShipmentManagerCleanupScheduler.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/scheduler/ShipmentManagerCleanupScheduler.java new file mode 100644 index 0000000..b9e8e3f --- /dev/null +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/scheduler/ShipmentManagerCleanupScheduler.java @@ -0,0 +1,24 @@ +package com.shipflow.shipmentservice.infrastructure.messaging.scheduler; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import com.shipflow.shipmentservice.application.ShipmentManagerService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ShipmentManagerCleanupScheduler { + + private final ShipmentManagerService shipmentManagerService; + + @Scheduled(cron = "0 0 0 * * *") + public void deletePendingManagers() { + log.info("[Scheduler] 삭제 예정 배송 담당자 일괄 삭제 시작"); + shipmentManagerService.deleteAllPending(); + log.info("[Scheduler] 삭제 예정 배송 담당자 일괄 삭제 완료"); + } +} diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/persistence/ProcessedSagaEventJpaEntity.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/persistence/ProcessedSagaEventJpaEntity.java new file mode 100644 index 0000000..9344950 --- /dev/null +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/persistence/ProcessedSagaEventJpaEntity.java @@ -0,0 +1,36 @@ +package com.shipflow.shipmentservice.infrastructure.persistence; + +import java.time.LocalDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "p_processed_saga_events") +public class ProcessedSagaEventJpaEntity { + + @Id + @Column(name = "event_id", length = 36, updatable = false) + private String eventId; + + @Column(name = "event_type", length = 100, nullable = false, updatable = false) + private String eventType; + + @Column(name = "processed_at", nullable = false, updatable = false) + private LocalDateTime processedAt; + + public static ProcessedSagaEventJpaEntity of(String eventId, String eventType) { + ProcessedSagaEventJpaEntity entity = new ProcessedSagaEventJpaEntity(); + entity.eventId = eventId; + entity.eventType = eventType; + entity.processedAt = LocalDateTime.now(); + return entity; + } +} diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/persistence/ProcessedSagaEventRepository.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/persistence/ProcessedSagaEventRepository.java new file mode 100644 index 0000000..c4c01b7 --- /dev/null +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/persistence/ProcessedSagaEventRepository.java @@ -0,0 +1,9 @@ +package com.shipflow.shipmentservice.infrastructure.persistence; + +import java.time.LocalDateTime; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProcessedSagaEventRepository extends JpaRepository { + void deleteByProcessedAtBefore(LocalDateTime cutoff); +} diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/persistence/ShipmentJpaRepository.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/persistence/ShipmentJpaRepository.java index 75e4341..c0c2665 100644 --- a/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/persistence/ShipmentJpaRepository.java +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/persistence/ShipmentJpaRepository.java @@ -27,4 +27,12 @@ public interface ShipmentJpaRepository extends JpaRepository { and s.deletedAt is null """) Optional findByIdWithRoutesAndManager(@Param("shipmentId") UUID shipmentId); + + @Query(""" + select distinct s from Shipment s + left join fetch s.routes r + where s.orderId = :orderId + and s.deletedAt is null + """) + Optional findByOrderIdWithRoutes(@Param("orderId") UUID orderId); } diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/persistence/ShipmentManagerJpaRepository.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/persistence/ShipmentManagerJpaRepository.java index ce1d3bc..5d4b00f 100644 --- a/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/persistence/ShipmentManagerJpaRepository.java +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/persistence/ShipmentManagerJpaRepository.java @@ -1,5 +1,6 @@ package com.shipflow.shipmentservice.infrastructure.persistence; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -33,4 +34,44 @@ int findMaxSequenceByTypeAndHubId( ); Optional findByIdAndDeletedAtIsNull(UUID managerId); + + @Query(""" + select m from ShipmentManager m + where m.type = :type + and m.deletedAt is null + and m.pendingDeletion = false + order by m.shipmentSequence asc + limit 1 + """) + Optional findFirstAvailableByType(@Param("type") ShipmentManagerType type); + + @Query(""" + select m from ShipmentManager m + where m.type = :type + and m.deletedAt is null + and m.pendingDeletion = false + order by m.shipmentSequence asc + """) + List findAllByType(@Param("type") ShipmentManagerType type); + + @Query(""" + select m from ShipmentManager m + where m.hubId = :hubId + and m.deletedAt is null + """) + List findAllByHubId(@Param("hubId") UUID hubId); + + @Query(""" + select m from ShipmentManager m + where m.userId = :userId + and m.deletedAt is null + """) + Optional findByUserId(@Param("userId") UUID userId); + + @Query(""" + select m from ShipmentManager m + where m.pendingDeletion = true + and m.deletedAt is null + """) + List findAllPendingDeletion(); } diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/persistence/ShipmentManagerRepositoryImpl.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/persistence/ShipmentManagerRepositoryImpl.java index 0291114..af664c6 100644 --- a/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/persistence/ShipmentManagerRepositoryImpl.java +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/persistence/ShipmentManagerRepositoryImpl.java @@ -45,4 +45,29 @@ public int findMaxSequenceByType(ShipmentManagerType type) { public int findMaxSequenceByTypeAndHubId(ShipmentManagerType type, UUID hubId) { return shipmentManagerJpaRepository.findMaxSequenceByTypeAndHubId(type, hubId); } + + @Override + public Optional findFirstAvailableByType(ShipmentManagerType type) { + return shipmentManagerJpaRepository.findFirstAvailableByType(type); + } + + @Override + public List findAllByType(ShipmentManagerType type) { + return shipmentManagerJpaRepository.findAllByType(type); + } + + @Override + public List findAllByHubId(UUID hubId) { + return shipmentManagerJpaRepository.findAllByHubId(hubId); + } + + @Override + public Optional findByUserId(UUID userId) { + return shipmentManagerJpaRepository.findByUserId(userId); + } + + @Override + public List findAllPendingDeletion() { + return shipmentManagerJpaRepository.findAllPendingDeletion(); + } } diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/persistence/ShipmentRepositoryImpl.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/persistence/ShipmentRepositoryImpl.java index ec65120..1f458f8 100644 --- a/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/persistence/ShipmentRepositoryImpl.java +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/persistence/ShipmentRepositoryImpl.java @@ -18,6 +18,11 @@ public class ShipmentRepositoryImpl implements ShipmentRepository { private final ShipmentJpaRepository shipmentJpaRepository; + @Override + public Shipment save(Shipment shipment) { + return shipmentJpaRepository.save(shipment); + } + @Override public Optional findById(UUID shipmentId) { return shipmentJpaRepository.findById(shipmentId); @@ -37,4 +42,9 @@ public List findAll(Pageable pageable) { public Optional findByIdWithRoutes(UUID shipmentId) { return shipmentJpaRepository.findByIdWithRoutesAndManager(shipmentId); } + + @Override + public Optional findByOrderIdWithRoutes(UUID orderId) { + return shipmentJpaRepository.findByOrderIdWithRoutes(orderId); + } } diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/presentation/ShipmentController.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/presentation/ShipmentController.java index 0dab227..c2b7afc 100644 --- a/shipment-service/src/main/java/com/shipflow/shipmentservice/presentation/ShipmentController.java +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/presentation/ShipmentController.java @@ -7,19 +7,22 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import com.shipflow.common.exception.ApiResponse; import com.shipflow.shipmentservice.application.ShipmentService; +import com.shipflow.shipmentservice.application.dto.result.ShipmentCompleteResult; import com.shipflow.shipmentservice.application.dto.result.ShipmentRouteUpdateResult; import com.shipflow.shipmentservice.application.dto.result.ShipmentUpdateResult; +import com.shipflow.shipmentservice.presentation.dto.request.PatchShipmentReqDto; +import com.shipflow.shipmentservice.presentation.dto.request.PatchShipmentRouteReqDto; import com.shipflow.shipmentservice.presentation.dto.response.GetShipmentResDto; import com.shipflow.shipmentservice.presentation.dto.response.GetShipmentRouteListResDto; -import com.shipflow.shipmentservice.presentation.dto.request.PatchShipmentReqDto; import com.shipflow.shipmentservice.presentation.dto.response.PatchShipmentResDto; -import com.shipflow.shipmentservice.presentation.dto.request.PatchShipmentRouteReqDto; import com.shipflow.shipmentservice.presentation.dto.response.PatchShipmentRouteResDto; +import com.shipflow.shipmentservice.presentation.dto.response.ShipmentCompleteResDto; import com.shipflow.shipmentservice.presentation.dto.response.ShipmentSearchResDto; import jakarta.validation.Valid; @@ -36,7 +39,8 @@ public class ShipmentController { */ @GetMapping("/api/shipments") public ApiResponse> searchShipment( - Pageable pageable + Pageable pageable, + UserContext userContext ) { List shipmentList = shipmentService.searchShipment(pageable).stream() .map(ShipmentSearchResDto::fromResult) @@ -49,7 +53,8 @@ public ApiResponse> searchShipment( */ @GetMapping("/api/shipments/{shipmentId}") public ApiResponse getShipment( - @PathVariable UUID shipmentId + @PathVariable UUID shipmentId, + UserContext userContext ) { return ApiResponse.ok(GetShipmentResDto.fromResult(shipmentService.getShipment(shipmentId))); } @@ -59,7 +64,8 @@ public ApiResponse getShipment( */ @GetMapping("/api/shipments/{shipmentId}/routes") public ApiResponse> getShipmentRoutes( - @PathVariable UUID shipmentId + @PathVariable UUID shipmentId, + UserContext userContext ) { List routes = shipmentService.getShipmentRoutes(shipmentId).stream() .map(GetShipmentRouteListResDto::fromResult) @@ -73,7 +79,8 @@ public ApiResponse> getShipmentRoutes( @PatchMapping("/api/shipments/{shipmentId}") public ApiResponse updateShipment( @PathVariable UUID shipmentId, - @Valid @RequestBody PatchShipmentReqDto request + @Valid @RequestBody PatchShipmentReqDto request, + UserContext userContext ) { ShipmentUpdateResult result = shipmentService.updateShipment(shipmentId, request.toCommand()); return ApiResponse.ok(PatchShipmentResDto.fromResult(result)); @@ -86,15 +93,23 @@ public ApiResponse updateShipment( public ApiResponse updateShipmentRoute( @PathVariable UUID shipmentId, @PathVariable UUID routeId, - @Valid @RequestBody PatchShipmentRouteReqDto request + @Valid @RequestBody PatchShipmentRouteReqDto request, + UserContext userContext ) { ShipmentRouteUpdateResult result = shipmentService.updateShipmentRoute( shipmentId, routeId, request.toCommand() ); - return ApiResponse.ok(PatchShipmentRouteResDto.fromResult(result)); } -} + @PostMapping("/api/shipments/{shipmentId}/complete") + public ApiResponse completeShipment( + @PathVariable UUID shipmentId, + UserContext userContext + ) { + ShipmentCompleteResult result = shipmentService.completeShipment(shipmentId); + return ApiResponse.ok(ShipmentCompleteResDto.fromResult(result)); + } +} \ No newline at end of file diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/presentation/ShipmentManagerController.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/presentation/ShipmentManagerController.java index c9cc69b..64152c7 100644 --- a/shipment-service/src/main/java/com/shipflow/shipmentservice/presentation/ShipmentManagerController.java +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/presentation/ShipmentManagerController.java @@ -5,7 +5,6 @@ import org.springframework.data.domain.Pageable; import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -37,14 +36,18 @@ public class ShipmentManagerController { @PostMapping public ApiResponse createShipmentManager( - @Valid @RequestBody PostShipmentManagerReqDto request + @Valid @RequestBody PostShipmentManagerReqDto request, + UserContext userContext ) { ShipmentManagerCreateResult result = shipmentManagerService.createShipmentManager(request.toCommand()); return ApiResponse.ok(PostShipmentManagerResDto.fromResult(result)); } @GetMapping("/{managerId}") - public ApiResponse getShipmentManager(@PathVariable UUID managerId) { + public ApiResponse getShipmentManager( + @PathVariable UUID managerId, + UserContext userContext + ) { ShipmentManagerResult result = shipmentManagerService.getShipmentManager(managerId); return ApiResponse.ok(GetShipmentManagerResDto.fromResult(result)); } @@ -53,7 +56,8 @@ public ApiResponse getShipmentManager(@PathVariable UU public ApiResponse> searchShipmentManager( @RequestParam(required = false) ShipmentManagerType type, @RequestParam(required = false) UUID hubId, - Pageable pageable + Pageable pageable, + UserContext userContext ) { ShipmentManagerSearchQuery query = ShipmentManagerSearchQuery.builder() .type(type) @@ -69,9 +73,9 @@ public ApiResponse> searchShipmentManager( @DeleteMapping("/{managerId}") public ApiResponse deleteShipmentManager( @PathVariable UUID managerId, - @RequestHeader("X-User-Id") UUID userId + UserContext userContext ) { - shipmentManagerService.deleteShipmentManager(managerId, userId); + shipmentManagerService.deleteShipmentManager(managerId, userContext.userId()); return ApiResponse.ok(null); } -} +} \ No newline at end of file diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/presentation/ShipmentManagerInternalController.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/presentation/ShipmentManagerInternalController.java new file mode 100644 index 0000000..8f9e4cb --- /dev/null +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/presentation/ShipmentManagerInternalController.java @@ -0,0 +1,33 @@ +package com.shipflow.shipmentservice.presentation; + +import java.util.UUID; + +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.shipflow.common.exception.ApiResponse; +import com.shipflow.shipmentservice.application.ShipmentManagerService; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/internal/shipment-managers") +public class ShipmentManagerInternalController { + + private final ShipmentManagerService shipmentManagerService; + + @DeleteMapping("/hubs/{hubId}") + public ApiResponse markPendingDeletionByHub(@PathVariable UUID hubId) { + shipmentManagerService.markPendingDeletionByHubId(hubId); + return ApiResponse.ok(null); + } + + @DeleteMapping("/users/{userId}") + public ApiResponse markPendingDeletionByUser(@PathVariable UUID userId) { + shipmentManagerService.markPendingDeletionByUserId(userId); + return ApiResponse.ok(null); + } +} diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/presentation/UserContext.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/presentation/UserContext.java new file mode 100644 index 0000000..10e0467 --- /dev/null +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/presentation/UserContext.java @@ -0,0 +1,6 @@ +package com.shipflow.shipmentservice.presentation; + +import java.util.UUID; + +public record UserContext(UUID userId, String role) { +} \ No newline at end of file diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/presentation/UserContextArgumentResolver.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/presentation/UserContextArgumentResolver.java new file mode 100644 index 0000000..f45c3a5 --- /dev/null +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/presentation/UserContextArgumentResolver.java @@ -0,0 +1,45 @@ +package com.shipflow.shipmentservice.presentation; + +import java.util.UUID; + +import org.springframework.core.MethodParameter; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import com.shipflow.common.exception.BusinessException; +import com.shipflow.shipmentservice.domain.exception.ShipmentErrorCode; + +import jakarta.servlet.http.HttpServletRequest; + +public class UserContextArgumentResolver implements HandlerMethodArgumentResolver { + + private static final String USER_ID_HEADER = "X-User-Id"; + private static final String USER_ROLE_HEADER = "X-User-Role"; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(UserContext.class); + } + + @Override + public UserContext resolveArgument(MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + + HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); + String userId = request.getHeader(USER_ID_HEADER); + String role = request.getHeader(USER_ROLE_HEADER); + + if (userId == null || userId.isBlank()) { + throw new BusinessException(ShipmentErrorCode.MISSING_USER_ID); + } + if (role == null || role.isBlank()) { + throw new BusinessException(ShipmentErrorCode.MISSING_USER_ROLE); + } + + return new UserContext(UUID.fromString(userId), role); + } +} \ No newline at end of file diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/presentation/WebMvcConfig.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/presentation/WebMvcConfig.java new file mode 100644 index 0000000..530b16a --- /dev/null +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/presentation/WebMvcConfig.java @@ -0,0 +1,16 @@ +package com.shipflow.shipmentservice.presentation; + +import java.util.List; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(new UserContextArgumentResolver()); + } +} \ No newline at end of file diff --git a/shipment-service/src/main/java/com/shipflow/shipmentservice/presentation/dto/response/ShipmentCompleteResDto.java b/shipment-service/src/main/java/com/shipflow/shipmentservice/presentation/dto/response/ShipmentCompleteResDto.java new file mode 100644 index 0000000..2498e41 --- /dev/null +++ b/shipment-service/src/main/java/com/shipflow/shipmentservice/presentation/dto/response/ShipmentCompleteResDto.java @@ -0,0 +1,29 @@ +package com.shipflow.shipmentservice.presentation.dto.response; + +import java.time.LocalDateTime; +import java.util.UUID; + +import com.shipflow.shipmentservice.application.dto.result.ShipmentCompleteResult; +import com.shipflow.shipmentservice.domain.ShipmentStatus; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ShipmentCompleteResDto { + + private UUID shipmentId; + private UUID orderId; + private ShipmentStatus status; + private LocalDateTime completedAt; + + public static ShipmentCompleteResDto fromResult(ShipmentCompleteResult result) { + return ShipmentCompleteResDto.builder() + .shipmentId(result.getShipmentId()) + .orderId(result.getOrderId()) + .status(result.getStatus()) + .completedAt(result.getCompletedAt()) + .build(); + } +} diff --git a/shipment-service/src/main/resources/application.yaml b/shipment-service/src/main/resources/application.yaml index 8cc5056..723a567 100644 --- a/shipment-service/src/main/resources/application.yaml +++ b/shipment-service/src/main/resources/application.yaml @@ -1,13 +1,15 @@ spring: application: - name: shipment-service + name: shipmentservice + #DB datasource: driver-class-name: org.postgresql.Driver url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:shipflow}?currentSchema=shipment username: ${DB_USER:shipflow} password: ${DB_PASSWORD:1234} + #JPA jpa: hibernate: ddl-auto: update @@ -16,3 +18,55 @@ spring: show_sql: true format_sql: true default_schema: shipment + + #Redis + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + + # RabbitMQ + rabbitmq: + host: ${RABBITMQ_HOST:localhost} + port: 5672 + username: ${RABBITMQ_USERNAME:guest} + password: ${RABBITMQ_PASSWORD:guest} + listener: + simple: + retry: + enabled: true + max-attempts: 3 + initial-interval: 1000ms + multiplier: 2.0 +server: + port: 8080 + +# Service Discovery +eureka: + client: + service-url: + defaultZone: http://discoveryserver:8761/eureka/ + registry-fetch-interval-seconds: 5 + instance: + prefer-ip-address: true + lease-renewal-interval-in-seconds: 10 + lease-expiration-duration-in-seconds: 30 + +# Actuator +management: + endpoints: + web: + exposure: + include: health, info + endpoint: + health: + show-details: always + +# Feign +feign: + client: + config: + default: + connectTimeout: 5000 + readTimeout: 5000 + loggerLevel: full diff --git a/shipment-service/src/test/java/com/shipflow/shipmentservice/application/ShipmentServiceTest.java b/shipment-service/src/test/java/com/shipflow/shipmentservice/application/ShipmentServiceTest.java index 7cf5829..57e5586 100644 --- a/shipment-service/src/test/java/com/shipflow/shipmentservice/application/ShipmentServiceTest.java +++ b/shipment-service/src/test/java/com/shipflow/shipmentservice/application/ShipmentServiceTest.java @@ -3,6 +3,9 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.BDDMockito.*; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -16,22 +19,35 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; import com.shipflow.common.exception.BusinessException; +import com.shipflow.shipmentservice.application.client.CacheClient; +import com.shipflow.shipmentservice.application.client.HubClient; +import com.shipflow.shipmentservice.application.client.UserClient; +import com.shipflow.shipmentservice.application.client.dto.HubRouteResult; +import com.shipflow.shipmentservice.application.client.dto.UserInfo; +import com.shipflow.shipmentservice.application.dto.command.CreateShipmentCommand; +import com.shipflow.shipmentservice.application.dto.command.ShipmentRouteUpdateCommand; +import com.shipflow.shipmentservice.application.dto.command.ShipmentUpdateCommand; +import com.shipflow.shipmentservice.application.dto.result.ShipmentCanceledResult; +import com.shipflow.shipmentservice.application.dto.result.ShipmentCompleteResult; import com.shipflow.shipmentservice.application.dto.result.ShipmentResult; import com.shipflow.shipmentservice.application.dto.result.ShipmentRouteResult; -import com.shipflow.shipmentservice.application.dto.command.ShipmentRouteUpdateCommand; import com.shipflow.shipmentservice.application.dto.result.ShipmentRouteUpdateResult; import com.shipflow.shipmentservice.application.dto.result.ShipmentSearchResult; -import com.shipflow.shipmentservice.application.dto.command.ShipmentUpdateCommand; import com.shipflow.shipmentservice.application.dto.result.ShipmentUpdateResult; import com.shipflow.shipmentservice.domain.Shipment; +import com.shipflow.shipmentservice.domain.ShipmentManager; +import com.shipflow.shipmentservice.domain.ShipmentManagerType; import com.shipflow.shipmentservice.domain.ShipmentRoute; import com.shipflow.shipmentservice.domain.ShipmentRouteStatus; import com.shipflow.shipmentservice.domain.ShipmentStatus; import com.shipflow.shipmentservice.domain.exception.ShipmentErrorCode; +import com.shipflow.shipmentservice.domain.repository.ShipmentManagerRepository; import com.shipflow.shipmentservice.domain.repository.ShipmentRepository; import com.shipflow.shipmentservice.fixture.ShipmentFixture; +import com.shipflow.shipmentservice.fixture.ShipmentManagerFixture; import com.shipflow.shipmentservice.fixture.ShipmentRouteFixture; @ExtendWith(MockitoExtension.class) @@ -39,10 +55,286 @@ class ShipmentServiceTest { @Mock private ShipmentRepository shipmentRepository; + @Mock + private ShipmentManagerRepository shipmentManagerRepository; + @Mock + private HubClient hubClient; + @Mock + private UserClient userClient; + @Mock + private CacheClient cacheClient; + @Mock + private ShipmentEventPublisher eventPublisher; @InjectMocks private ShipmentService shipmentService; + private HubRouteResult createHubRouteResult(int sequence, UUID departureHubId, UUID arrivalHubId) { + HubRouteResult result = new HubRouteResult(); + ReflectionTestUtils.setField(result, "sequence", sequence); + ReflectionTestUtils.setField(result, "departureHubId", departureHubId); + ReflectionTestUtils.setField(result, "arrivalHubId", arrivalHubId); + ReflectionTestUtils.setField(result, "estimatedDistance", new BigDecimal("12.50")); + ReflectionTestUtils.setField(result, "estimatedDuration", 30); + return result; + } + + @Nested + @DisplayName("배송 생성") + class CreateShipmentTest { + + @Test + @DisplayName("배송 생성 성공") + void createShipment_success() { + // given + UUID orderId = UUID.randomUUID(); + UUID ordererId = UUID.randomUUID(); + UUID departureHubId = UUID.randomUUID(); + UUID arrivalHubId = UUID.randomUUID(); + + CreateShipmentCommand command = new CreateShipmentCommand( + orderId, ordererId, UUID.randomUUID(), 5, + departureHubId, arrivalHubId, + LocalDateTime.now().plusDays(7), "요청사항", "서울시 강남구 테헤란로 123" + ); + + ShipmentManager companyManager = ShipmentManagerFixture.createCompanyManager(); + ShipmentManager hubManager = ShipmentManagerFixture.createHubManager(); + HubRouteResult hubRoute = createHubRouteResult(1, departureHubId, arrivalHubId); + UserInfo userInfo = new UserInfo(ordererId, "홍길동", "hong123"); + Shipment savedShipment = ShipmentFixture.createShipment(); + + given(shipmentManagerRepository.findFirstAvailableByType(ShipmentManagerType.COMPANY)) + .willReturn(Optional.of(companyManager)); + given(hubClient.getHubRoutes(departureHubId, arrivalHubId)) + .willReturn(List.of(hubRoute)); + given(shipmentManagerRepository.findAllByType(ShipmentManagerType.HUB)) + .willReturn(List.of(hubManager)); + given(userClient.getUser(ordererId)) + .willReturn(userInfo); + given(cacheClient.increment(anyString())) + .willReturn(1L); + given(shipmentRepository.save(any())) + .willReturn(savedShipment); + + // when & then + assertThatNoException().isThrownBy(() -> shipmentService.createShipment(command)); + then(eventPublisher).should().publishCreated(any()); + } + + @Test + @DisplayName("업체 배송 담당자가 없으면 생성에 실패하고 실패 이벤트를 발행한다") + void createShipment_companyManagerNotFound() { + // given + CreateShipmentCommand command = new CreateShipmentCommand( + UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), 5, + UUID.randomUUID(), UUID.randomUUID(), + LocalDateTime.now().plusDays(7), "요청사항", "서울시 강남구 테헤란로 123" + ); + + given(shipmentManagerRepository.findFirstAvailableByType(ShipmentManagerType.COMPANY)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> shipmentService.createShipment(command)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ShipmentErrorCode.SHIPMENT_MANAGER_NOT_FOUND); + + then(eventPublisher).should().publishCreationFailed(any()); + } + + @Test + @DisplayName("허브 배송 담당자가 없으면 생성에 실패하고 실패 이벤트를 발행한다") + void createShipment_hubManagerNotFound() { + // given + UUID departureHubId = UUID.randomUUID(); + UUID arrivalHubId = UUID.randomUUID(); + + CreateShipmentCommand command = new CreateShipmentCommand( + UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), 5, + departureHubId, arrivalHubId, + LocalDateTime.now().plusDays(7), "요청사항", "서울시 강남구 테헤란로 123" + ); + + given(shipmentManagerRepository.findFirstAvailableByType(ShipmentManagerType.COMPANY)) + .willReturn(Optional.of(ShipmentManagerFixture.createCompanyManager())); + given(hubClient.getHubRoutes(departureHubId, arrivalHubId)) + .willReturn(List.of(createHubRouteResult(1, departureHubId, arrivalHubId))); + given(shipmentManagerRepository.findAllByType(ShipmentManagerType.HUB)) + .willReturn(Collections.emptyList()); + + // when & then + assertThatThrownBy(() -> shipmentService.createShipment(command)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ShipmentErrorCode.SHIPMENT_MANAGER_NOT_FOUND); + + then(eventPublisher).should().publishCreationFailed(any()); + } + } + + @Nested + @DisplayName("배송 완료") + class CompleteShipmentTest { + + @Test + @DisplayName("모든 경로가 완료되면 배송 완료 성공") + void completeShipment_success() { + // given + UUID shipmentId = UUID.randomUUID(); + List arrivedRoutes = List.of( + ShipmentRouteFixture.createArrivedRoute(UUID.randomUUID(), 1, LocalDateTime.now().minusHours(2)), + ShipmentRouteFixture.createArrivedRoute(UUID.randomUUID(), 2, LocalDateTime.now().minusHours(1)) + ); + Shipment shipment = ShipmentFixture.createShipmentWithRoutes(shipmentId, arrivedRoutes); + + given(shipmentRepository.findByIdWithRoutes(shipmentId)) + .willReturn(Optional.of(shipment)); + + // when + ShipmentCompleteResult result = shipmentService.completeShipment(shipmentId); + + // then + assertThat(result).isNotNull(); + assertThat(result.getShipmentId()).isEqualTo(shipmentId); + then(eventPublisher).should().publishCompleted(any()); + } + + @Test + @DisplayName("배송 정보가 없으면 실패한다") + void completeShipment_notFound() { + // given + UUID shipmentId = UUID.randomUUID(); + given(shipmentRepository.findByIdWithRoutes(shipmentId)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> shipmentService.completeShipment(shipmentId)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ShipmentErrorCode.SHIPMENT_NOT_FOUND); + } + + @Test + @DisplayName("이미 완료된 배송이면 실패한다") + void completeShipment_alreadyCompleted() { + // given + UUID shipmentId = UUID.randomUUID(); + Shipment shipment = ShipmentFixture.createShipmentWithStatus(shipmentId, ShipmentStatus.COMPLETED); + + given(shipmentRepository.findByIdWithRoutes(shipmentId)) + .willReturn(Optional.of(shipment)); + + // when & then + assertThatThrownBy(() -> shipmentService.completeShipment(shipmentId)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ShipmentErrorCode.SHIPMENT_ALREADY_COMPLETED); + } + + @Test + @DisplayName("완료되지 않은 경로가 있으면 실패한다") + void completeShipment_routesNotAllCompleted() { + // given + UUID shipmentId = UUID.randomUUID(); + List routes = List.of( + ShipmentRouteFixture.createArrivedRoute(UUID.randomUUID(), 1, LocalDateTime.now().minusHours(1)), + ShipmentRouteFixture.createRoute(UUID.randomUUID(), 2) // WAITING_AT_HUB + ); + Shipment shipment = ShipmentFixture.createShipmentWithRoutes(shipmentId, routes); + + given(shipmentRepository.findByIdWithRoutes(shipmentId)) + .willReturn(Optional.of(shipment)); + + // when & then + assertThatThrownBy(() -> shipmentService.completeShipment(shipmentId)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ShipmentErrorCode.SHIPMENT_ROUTES_NOT_ALL_COMPLETED); + } + } + + @Nested + @DisplayName("배송 취소") + class CancelShipmentTest { + + @Test + @DisplayName("WAITING_AT_HUB 상태의 배송은 취소 성공") + void cancelShipment_success() { + // given + UUID orderId = UUID.randomUUID(); + UUID shipmentId = UUID.randomUUID(); + List routes = List.of( + ShipmentRouteFixture.createRoute(UUID.randomUUID(), 1), + ShipmentRouteFixture.createRoute(UUID.randomUUID(), 2) + ); + Shipment shipment = ShipmentFixture.createShipmentWithRoutes(shipmentId, routes); + + given(shipmentRepository.findByOrderIdWithRoutes(orderId)) + .willReturn(Optional.of(shipment)); + + // when + ShipmentCanceledResult result = shipmentService.cancelShipment(orderId); + + // then + assertThat(result).isNotNull(); + assertThat(result.getShipmentId()).isEqualTo(shipmentId); + assertThat(result.getStatus()).isEqualTo(ShipmentStatus.CANCELLED); + } + + @Test + @DisplayName("배송 정보가 없으면 실패한다") + void cancelShipment_notFound() { + // given + UUID orderId = UUID.randomUUID(); + given(shipmentRepository.findByOrderIdWithRoutes(orderId)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> shipmentService.cancelShipment(orderId)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ShipmentErrorCode.SHIPMENT_NOT_FOUND); + } + + @Test + @DisplayName("이미 취소된 배송이면 실패한다") + void cancelShipment_alreadyCancelled() { + // given + UUID orderId = UUID.randomUUID(); + UUID shipmentId = UUID.randomUUID(); + Shipment shipment = ShipmentFixture.createShipmentWithStatus(shipmentId, ShipmentStatus.CANCELLED); + + given(shipmentRepository.findByOrderIdWithRoutes(orderId)) + .willReturn(Optional.of(shipment)); + + // when & then + assertThatThrownBy(() -> shipmentService.cancelShipment(orderId)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ShipmentErrorCode.SHIPMENT_ALREADY_CANCELLED); + } + + @Test + @DisplayName("배송이 이미 시작된 경우 취소 실패") + void cancelShipment_notCancelableStatus() { + // given + UUID orderId = UUID.randomUUID(); + UUID shipmentId = UUID.randomUUID(); + Shipment shipment = ShipmentFixture.createShipmentWithStatus(shipmentId, ShipmentStatus.MOVING_TO_HUB); + + given(shipmentRepository.findByOrderIdWithRoutes(orderId)) + .willReturn(Optional.of(shipment)); + + // when & then + assertThatThrownBy(() -> shipmentService.cancelShipment(orderId)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ShipmentErrorCode.SHIPMENT_NOT_CANCELABLE_STATUS); + } + } + @Nested @DisplayName("배송 단건 조회") class GetShipmentTest {