diff --git a/src/main/java/com/bbangle/bbangle/claim/domain/ExchangeRequest.java b/src/main/java/com/bbangle/bbangle/claim/domain/ExchangeRequest.java index 503e2a52e..6e7d1db01 100644 --- a/src/main/java/com/bbangle/bbangle/claim/domain/ExchangeRequest.java +++ b/src/main/java/com/bbangle/bbangle/claim/domain/ExchangeRequest.java @@ -1,13 +1,22 @@ package com.bbangle.bbangle.claim.domain; +import static com.bbangle.bbangle.claim.domain.constant.ExchangeRequestStatus.APPROVED; +import static com.bbangle.bbangle.claim.domain.constant.ExchangeRequestStatus.REJECTED; +import static com.bbangle.bbangle.claim.domain.constant.ExchangeRequestStatus.REQUESTED; + import com.bbangle.bbangle.claim.domain.constant.ExchangeRequestStatus; +import com.bbangle.bbangle.exception.BbangleErrorCode; +import com.bbangle.bbangle.exception.BbangleException; +import com.bbangle.bbangle.order.domain.OrderItem; import jakarta.persistence.Column; import jakarta.persistence.DiscriminatorValue; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import jakarta.persistence.Table; +import java.time.LocalDateTime; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -22,4 +31,36 @@ public class ExchangeRequest extends Claim { @Enumerated(EnumType.STRING) private ExchangeRequestStatus status; + private String sellerComment; + + @Builder + public ExchangeRequest( + OrderItem orderItem, + String detailReason, + LocalDateTime decidedAt, + ExchangeRequestStatus status, + String sellerComment + ) { + super(orderItem, detailReason, decidedAt); + this.status = status; + this.sellerComment = sellerComment; + } + + public void approve(String reason) { + if (status != REQUESTED) { + throw new BbangleException(BbangleErrorCode.CLAIM_INVALID_STATUS); + } + this.status = APPROVED; + this.sellerComment = reason; + super.decide(); + } + + public void reject(String reason) { + if (status != REQUESTED) { + throw new BbangleException(BbangleErrorCode.CLAIM_INVALID_STATUS); + } + this.status = REJECTED; + this.sellerComment = reason; + super.decide(); + } } diff --git a/src/main/java/com/bbangle/bbangle/claim/domain/ReturnRequest.java b/src/main/java/com/bbangle/bbangle/claim/domain/ReturnRequest.java index 774e8a0b8..d15da829d 100644 --- a/src/main/java/com/bbangle/bbangle/claim/domain/ReturnRequest.java +++ b/src/main/java/com/bbangle/bbangle/claim/domain/ReturnRequest.java @@ -38,10 +38,12 @@ public ReturnRequest( OrderItem orderItem, String detailReason, LocalDateTime decidedAt, - ReturnRequestRequestStatus status + ReturnRequestRequestStatus status, + String sellerComment ) { super(orderItem, detailReason, decidedAt); this.status = status; + this.sellerComment = sellerComment; } public void approve(String reason) { diff --git a/src/main/java/com/bbangle/bbangle/claim/repository/ExchangeRequestRepository.java b/src/main/java/com/bbangle/bbangle/claim/repository/ExchangeRequestRepository.java new file mode 100644 index 000000000..1cde8e083 --- /dev/null +++ b/src/main/java/com/bbangle/bbangle/claim/repository/ExchangeRequestRepository.java @@ -0,0 +1,7 @@ +package com.bbangle.bbangle.claim.repository; + +import com.bbangle.bbangle.claim.domain.ExchangeRequest; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ExchangeRequestRepository extends JpaRepository { +} diff --git a/src/main/java/com/bbangle/bbangle/claim/seller/service/SellerExchangeService.java b/src/main/java/com/bbangle/bbangle/claim/seller/service/SellerExchangeService.java new file mode 100644 index 000000000..75d063b8f --- /dev/null +++ b/src/main/java/com/bbangle/bbangle/claim/seller/service/SellerExchangeService.java @@ -0,0 +1,125 @@ +package com.bbangle.bbangle.claim.seller.service; + +import com.bbangle.bbangle.claim.domain.ExchangeRequest; +import com.bbangle.bbangle.claim.domain.constant.ExchangeRequestStatus; +import com.bbangle.bbangle.claim.repository.ExchangeRequestRepository; +import com.bbangle.bbangle.claim.seller.service.model.ExchangeCreateCommand; +import com.bbangle.bbangle.exception.BbangleErrorCode; +import com.bbangle.bbangle.exception.BbangleException; +import com.bbangle.bbangle.order.domain.Order; +import com.bbangle.bbangle.order.domain.OrderItem; +import com.bbangle.bbangle.order.domain.OrderItemHistory; +import com.bbangle.bbangle.order.repository.OrderItemHistoryRepository; +import com.bbangle.bbangle.order.repository.OrderItemRepository; +import com.bbangle.bbangle.order.repository.OrderRepository; +import com.bbangle.bbangle.order.seller.controller.dto.response.SellerOrderResponse; +import com.bbangle.bbangle.order.seller.controller.dto.response.SellerOrderResponse.ExchangeContent; +import com.bbangle.bbangle.order.seller.controller.dto.response.SellerOrderResponse.ExchangeCreateResponse; +import com.bbangle.bbangle.seller.repository.SellerRepository; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class SellerExchangeService { + + private final ExchangeRequestRepository exchangeRequestRepository; + private final OrderItemHistoryRepository orderItemHistoryRepository; + private final OrderRepository orderRepository; + private final OrderItemRepository orderItemRepository; + private final SellerRepository sellerRepository; + + @Transactional + public ExchangeCreateResponse createExchange(ExchangeCreateCommand command) { + if (command.orderItemIds() == null || command.orderItemIds().isEmpty()) { + throw new BbangleException(BbangleErrorCode.ORDER_ITEM_NOT_FOUND); + } + + List uniqueOrderItemIds = command.orderItemIds().stream() + .distinct() + .toList(); + + int requestedCount = uniqueOrderItemIds.size(); + + Order order = orderRepository.findById(command.orderId()) + .orElseThrow(() -> new BbangleException(BbangleErrorCode.ORDER_NOT_FOUND)); + + Long storeId = getStoreIdOrThrow(command.sellerId()); + + List orderItems = orderItemRepository.findByOrderIdAndIdIn( + order.getId(), + uniqueOrderItemIds + ); + + Set foundIds = orderItems.stream() + .map(OrderItem::getId) + .collect(Collectors.toSet()); + + if (!foundIds.isEmpty()) { + assertOwnedOrderItems(order.getId(), new ArrayList<>(foundIds), storeId); + } + + List notFoundIds = uniqueOrderItemIds.stream() + .filter(id -> !foundIds.contains(id)) + .toList(); + + List successOrderItemIds = new ArrayList<>(); + List failedOrderItemIds = new ArrayList<>(notFoundIds); + List exchangeRequestsToSave = new ArrayList<>(); + List historiesToSave = new ArrayList<>(); + + for (OrderItem orderItem : orderItems) { + if (orderItem.requestExchange()) { + ExchangeRequest exchangeRequest = ExchangeRequest.builder() + .orderItem(orderItem) + .detailReason(command.reason()) + .sellerComment(command.sellerComment()) + .status(ExchangeRequestStatus.REQUESTED) + .build(); + exchangeRequestsToSave.add(exchangeRequest); + historiesToSave.add(OrderItemHistory.create(orderItem)); + successOrderItemIds.add(orderItem.getId()); + } else { + failedOrderItemIds.add(orderItem.getId()); + } + } + + exchangeRequestRepository.saveAll(exchangeRequestsToSave); + orderItemHistoryRepository.saveAll(historiesToSave); + + int successCount = successOrderItemIds.size(); + int failCount = failedOrderItemIds.size(); + + SellerOrderResponse.Summary summary = + SellerOrderResponse.Summary.of(requestedCount, successCount, failCount); + + ExchangeContent content = ExchangeContent.of( + order.getId(), + summary, + successOrderItemIds, + failedOrderItemIds + ); + + return ExchangeCreateResponse.of(content); + } + + private Long getStoreIdOrThrow(Long sellerId) { + Long storeId = sellerRepository.findStoreIdBySellerId(sellerId); + if (storeId == null) { + throw new BbangleException(BbangleErrorCode.SELLER_NOT_FOUND); + } + return storeId; + } + + private void assertOwnedOrderItems(Long orderId, List orderItemIds, Long storeId) { + long ownedCount = orderItemRepository.countOwnedOrderItems(orderId, orderItemIds, storeId); + if (ownedCount != orderItemIds.size()) { + throw new BbangleException(BbangleErrorCode.ORDER_ACCESS_DENIED); + } + } +} diff --git a/src/main/java/com/bbangle/bbangle/claim/seller/service/SellerReturnService.java b/src/main/java/com/bbangle/bbangle/claim/seller/service/SellerReturnService.java index 7fc0cee9a..df57c0fa7 100644 --- a/src/main/java/com/bbangle/bbangle/claim/seller/service/SellerReturnService.java +++ b/src/main/java/com/bbangle/bbangle/claim/seller/service/SellerReturnService.java @@ -2,13 +2,25 @@ import com.bbangle.bbangle.claim.domain.ReturnRequest; import com.bbangle.bbangle.claim.domain.constant.DecisionType; +import com.bbangle.bbangle.claim.domain.constant.ReturnRequestRequestStatus; import com.bbangle.bbangle.claim.repository.ReturnRequestRepository; +import com.bbangle.bbangle.claim.seller.service.model.ReturnCreateCommand; import com.bbangle.bbangle.exception.BbangleErrorCode; import com.bbangle.bbangle.exception.BbangleException; +import com.bbangle.bbangle.order.domain.Order; import com.bbangle.bbangle.order.domain.OrderItem; import com.bbangle.bbangle.order.domain.OrderItemHistory; import com.bbangle.bbangle.order.repository.OrderItemHistoryRepository; +import com.bbangle.bbangle.order.repository.OrderItemRepository; +import com.bbangle.bbangle.order.repository.OrderRepository; +import com.bbangle.bbangle.order.seller.controller.dto.response.SellerOrderResponse; +import com.bbangle.bbangle.order.seller.controller.dto.response.SellerOrderResponse.ReturnContent; +import com.bbangle.bbangle.order.seller.controller.dto.response.SellerOrderResponse.ReturnCreateResponse; +import com.bbangle.bbangle.seller.repository.SellerRepository; +import java.util.ArrayList; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -19,6 +31,98 @@ public class SellerReturnService { private final ReturnRequestRepository returnRequestRepository; private final OrderItemHistoryRepository orderItemHistoryRepository; + private final OrderRepository orderRepository; + private final OrderItemRepository orderItemRepository; + private final SellerRepository sellerRepository; + + @Transactional + public ReturnCreateResponse createReturn(ReturnCreateCommand command) { + if (command.orderItemIds() == null || command.orderItemIds().isEmpty()) { + throw new BbangleException(BbangleErrorCode.ORDER_ITEM_NOT_FOUND); + } + + List uniqueOrderItemIds = command.orderItemIds().stream() + .distinct() + .toList(); + + int requestedCount = uniqueOrderItemIds.size(); + + Order order = orderRepository.findById(command.orderId()) + .orElseThrow(() -> new BbangleException(BbangleErrorCode.ORDER_NOT_FOUND)); + + Long storeId = getStoreIdOrThrow(command.sellerId()); + + List orderItems = orderItemRepository.findByOrderIdAndIdIn( + order.getId(), + uniqueOrderItemIds + ); + + Set foundIds = orderItems.stream() + .map(OrderItem::getId) + .collect(Collectors.toSet()); + + if (!foundIds.isEmpty()) { + assertOwnedOrderItems(order.getId(), new ArrayList<>(foundIds), storeId); + } + + List notFoundIds = uniqueOrderItemIds.stream() + .filter(id -> !foundIds.contains(id)) + .toList(); + + List successOrderItemIds = new ArrayList<>(); + List failedOrderItemIds = new ArrayList<>(notFoundIds); + List returnRequestsToSave = new ArrayList<>(); + List historiesToSave = new ArrayList<>(); + + for (OrderItem orderItem : orderItems) { + if (orderItem.requestReturn()) { + ReturnRequest returnRequest = ReturnRequest.builder() + .orderItem(orderItem) + .detailReason(command.reason()) + .sellerComment(command.sellerComment()) + .status(ReturnRequestRequestStatus.REQUESTED) + .build(); + returnRequestsToSave.add(returnRequest); + historiesToSave.add(OrderItemHistory.create(orderItem)); + successOrderItemIds.add(orderItem.getId()); + } else { + failedOrderItemIds.add(orderItem.getId()); + } + } + + returnRequestRepository.saveAll(returnRequestsToSave); + orderItemHistoryRepository.saveAll(historiesToSave); + + int successCount = successOrderItemIds.size(); + int failCount = failedOrderItemIds.size(); + + SellerOrderResponse.Summary summary = + SellerOrderResponse.Summary.of(requestedCount, successCount, failCount); + + ReturnContent content = ReturnContent.of( + order.getId(), + summary, + successOrderItemIds, + failedOrderItemIds + ); + + return ReturnCreateResponse.of(content); + } + + private Long getStoreIdOrThrow(Long sellerId) { + Long storeId = sellerRepository.findStoreIdBySellerId(sellerId); + if (storeId == null) { + throw new BbangleException(BbangleErrorCode.SELLER_NOT_FOUND); + } + return storeId; + } + + private void assertOwnedOrderItems(Long orderId, List orderItemIds, Long storeId) { + long ownedCount = orderItemRepository.countOwnedOrderItems(orderId, orderItemIds, storeId); + if (ownedCount != orderItemIds.size()) { + throw new BbangleException(BbangleErrorCode.ORDER_ACCESS_DENIED); + } + } @Transactional public void decision(List returnIds, Long sellerId, DecisionType decisionType, String reason) { @@ -28,28 +132,27 @@ public void decision(List returnIds, Long sellerId, DecisionType decisionT } List returnRequests = returnRequestRepository.findAllById(returnIds); + List historiesToSave = new ArrayList<>(); + for (ReturnRequest returnRequest : returnRequests) { - processDecision(returnRequest, decisionType, reason); + OrderItemHistory history = processDecision(returnRequest, decisionType, reason); + historiesToSave.add(history); } + orderItemHistoryRepository.saveAll(historiesToSave); } - private void processDecision(ReturnRequest returnRequest, DecisionType decisionType, String reason) { + private OrderItemHistory processDecision(ReturnRequest returnRequest, DecisionType decisionType, String reason) { + OrderItem orderItem = returnRequest.getOrderItem(); switch (decisionType) { case APPROVE -> { returnRequest.approve(reason); - OrderItem orderItem = returnRequest.getOrderItem(); orderItem.returnApprove(); - OrderItemHistory history = OrderItemHistory.create(orderItem); - orderItemHistoryRepository.save(history); } case REJECT -> { returnRequest.reject(reason); - OrderItem orderItem = returnRequest.getOrderItem(); orderItem.returnReject(); - OrderItemHistory history = OrderItemHistory.create(orderItem); - orderItemHistoryRepository.save(history); } } + return OrderItemHistory.create(orderItem); } - } diff --git a/src/main/java/com/bbangle/bbangle/claim/seller/service/model/ExchangeCreateCommand.java b/src/main/java/com/bbangle/bbangle/claim/seller/service/model/ExchangeCreateCommand.java new file mode 100644 index 000000000..3e1c3c09b --- /dev/null +++ b/src/main/java/com/bbangle/bbangle/claim/seller/service/model/ExchangeCreateCommand.java @@ -0,0 +1,14 @@ +package com.bbangle.bbangle.claim.seller.service.model; + +import java.util.List; +import lombok.Builder; + +@Builder +public record ExchangeCreateCommand( + Long orderId, + List orderItemIds, + String reason, + String sellerComment, + Long sellerId +) { +} diff --git a/src/main/java/com/bbangle/bbangle/claim/seller/service/model/ReturnCreateCommand.java b/src/main/java/com/bbangle/bbangle/claim/seller/service/model/ReturnCreateCommand.java new file mode 100644 index 000000000..4cae547ca --- /dev/null +++ b/src/main/java/com/bbangle/bbangle/claim/seller/service/model/ReturnCreateCommand.java @@ -0,0 +1,14 @@ +package com.bbangle.bbangle.claim.seller.service.model; + +import java.util.List; +import lombok.Builder; + +@Builder +public record ReturnCreateCommand( + Long orderId, + List orderItemIds, + String reason, + String sellerComment, + Long sellerId +) { +} diff --git a/src/main/java/com/bbangle/bbangle/exception/BbangleErrorCode.java b/src/main/java/com/bbangle/bbangle/exception/BbangleErrorCode.java index 17a7bb045..90290640a 100644 --- a/src/main/java/com/bbangle/bbangle/exception/BbangleErrorCode.java +++ b/src/main/java/com/bbangle/bbangle/exception/BbangleErrorCode.java @@ -158,8 +158,10 @@ public enum BbangleErrorCode { // Order Error(781 ~ 800) ORDER_INVALID_STATUS(-781, "요청하신 order의 상태로 변경할 수 없습니다", BAD_REQUEST), - DELIVERY_NOT_FOUND(-782, "해당 주문상품의 배송 정보를 찾을 수 없습니다.", NOT_FOUND), - DELIVERY_MODIFY_NOT_ALLOWED(-783, "현재 배송 상태에서는 운송장을 수정할 수 없습니다.", BAD_REQUEST); + RETURN_NOT_ALLOWED(-782, "반품 요청이 불가능한 상태입니다.", BAD_REQUEST), + EXCHANGE_NOT_ALLOWED(-783, "교환 요청이 불가능한 상태입니다.", BAD_REQUEST), + DELIVERY_NOT_FOUND(-784, "해당 주문상품의 배송 정보를 찾을 수 없습니다.", NOT_FOUND), + DELIVERY_MODIFY_NOT_ALLOWED(-785, "현재 배송 상태에서는 운송장을 수정할 수 없습니다.", BAD_REQUEST); private final int code; private final String message; diff --git a/src/main/java/com/bbangle/bbangle/order/domain/OrderItem.java b/src/main/java/com/bbangle/bbangle/order/domain/OrderItem.java index 52e81f3ba..2fcbc13c7 100644 --- a/src/main/java/com/bbangle/bbangle/order/domain/OrderItem.java +++ b/src/main/java/com/bbangle/bbangle/order/domain/OrderItem.java @@ -128,6 +128,21 @@ public void shipOrder() { this.orderStatus = OrderStatus.SHIPPED; } + public boolean requestReturn() { + if (this.orderStatus != OrderStatus.SHIPPED && this.orderStatus != OrderStatus.PURCHASE_CONFIRMED) { + return false; + } + this.orderStatus = RETURN_REQUESTED; + return true; + } + + public boolean requestExchange() { + if (this.orderStatus != OrderStatus.SHIPPED && this.orderStatus != OrderStatus.PURCHASE_CONFIRMED) { + return false; + } + this.orderStatus = OrderStatus.EXCHANGE_REQUEST; + return true; + } public void returnApprove() { if (orderStatus != RETURN_REQUESTED) { diff --git a/src/main/java/com/bbangle/bbangle/order/seller/controller/SellerOrderApi.java b/src/main/java/com/bbangle/bbangle/order/seller/controller/SellerOrderApi.java index 0ec367884..288d3b9ef 100644 --- a/src/main/java/com/bbangle/bbangle/order/seller/controller/SellerOrderApi.java +++ b/src/main/java/com/bbangle/bbangle/order/seller/controller/SellerOrderApi.java @@ -10,7 +10,9 @@ import com.bbangle.bbangle.order.seller.controller.dto.response.OrderDetailResponse.OrderDetail; import com.bbangle.bbangle.order.seller.controller.dto.response.OrderResponse.OrderItemDetailResponse; import com.bbangle.bbangle.order.seller.controller.dto.response.OrderResponse.OrderSearchResponse; +import com.bbangle.bbangle.order.seller.controller.dto.response.SellerOrderResponse.ExchangeCreateResponse; import com.bbangle.bbangle.order.seller.controller.dto.response.SellerOrderResponse.OrderConfirmResponse; +import com.bbangle.bbangle.order.seller.controller.dto.response.SellerOrderResponse.ReturnCreateResponse; import com.bbangle.bbangle.order.seller.controller.dto.response.SellerOrderResponse.ShipmentModifyResponse; import com.bbangle.bbangle.order.seller.controller.dto.response.SellerOrderResponse.ShipmentRegisterResponse; import io.swagger.v3.oas.annotations.Operation; @@ -73,6 +75,20 @@ SingleResult registerShipment( @Valid @RequestBody SellerOrderRequest.ShipmentRegisterRequest request ); + @Operation(summary = "(판매자) 반품 요청 생성") + SingleResult createReturn( + @AuthenticationPrincipal Long sellerId, + @PathVariable Long orderId, + @Valid @RequestBody SellerOrderRequest.ReturnCreateRequest request + ); + + @Operation(summary = "(판매자) 교환 요청 생성") + SingleResult createExchange( + @AuthenticationPrincipal Long sellerId, + @PathVariable Long orderId, + @Valid @RequestBody SellerOrderRequest.ExchangeCreateRequest request + ); + @Operation(summary = "(판매자) 운송장 수정") SingleResult modifyShipment( @AuthenticationPrincipal Long sellerId, diff --git a/src/main/java/com/bbangle/bbangle/order/seller/controller/SellerOrderController.java b/src/main/java/com/bbangle/bbangle/order/seller/controller/SellerOrderController.java index b00b0e8be..3386e1e84 100644 --- a/src/main/java/com/bbangle/bbangle/order/seller/controller/SellerOrderController.java +++ b/src/main/java/com/bbangle/bbangle/order/seller/controller/SellerOrderController.java @@ -12,9 +12,13 @@ import com.bbangle.bbangle.order.seller.controller.dto.response.OrderDetailResponse.OrderDetail; import com.bbangle.bbangle.order.seller.controller.dto.response.OrderResponse.OrderItemDetailResponse; import com.bbangle.bbangle.order.seller.controller.dto.response.OrderResponse.OrderSearchResponse; +import com.bbangle.bbangle.order.seller.controller.dto.response.SellerOrderResponse.ExchangeCreateResponse; import com.bbangle.bbangle.order.seller.controller.dto.response.SellerOrderResponse.OrderConfirmResponse; +import com.bbangle.bbangle.order.seller.controller.dto.response.SellerOrderResponse.ReturnCreateResponse; import com.bbangle.bbangle.order.seller.controller.dto.response.SellerOrderResponse.ShipmentModifyResponse; import com.bbangle.bbangle.order.seller.controller.dto.response.SellerOrderResponse.ShipmentRegisterResponse; +import com.bbangle.bbangle.claim.seller.service.SellerExchangeService; +import com.bbangle.bbangle.claim.seller.service.SellerReturnService; import com.bbangle.bbangle.order.seller.service.SellerOrderService; import jakarta.validation.Valid; import java.util.List; @@ -42,6 +46,10 @@ public class SellerOrderController implements SellerOrderApi { private final SellerOrderService sellerOrderService; + private final SellerReturnService sellerReturnService; + + private final SellerExchangeService sellerExchangeService; + @Override @GetMapping("/completed") public SingleResult> getCompletedOrders( @@ -146,6 +154,28 @@ public SingleResult registerShipment( return responseService.getSingleResult(result); } + @PostMapping("/{orderId}/returns") + @Override + public SingleResult createReturn( + @AuthenticationPrincipal Long sellerId, + @PathVariable Long orderId, + @Valid @RequestBody SellerOrderRequest.ReturnCreateRequest request + ) { + var result = sellerReturnService.createReturn(request.toCommand(sellerId, orderId)); + return responseService.getSingleResult(result); + } + + @PostMapping("/{orderId}/exchanges") + @Override + public SingleResult createExchange( + @AuthenticationPrincipal Long sellerId, + @PathVariable Long orderId, + @Valid @RequestBody SellerOrderRequest.ExchangeCreateRequest request + ) { + var result = sellerExchangeService.createExchange(request.toCommand(sellerId, orderId)); + return responseService.getSingleResult(result); + } + @PatchMapping("/{orderId}/shipment") @Override public SingleResult modifyShipment( diff --git a/src/main/java/com/bbangle/bbangle/order/seller/controller/dto/request/SellerOrderRequest.java b/src/main/java/com/bbangle/bbangle/order/seller/controller/dto/request/SellerOrderRequest.java index cb44a91bd..d487e6626 100644 --- a/src/main/java/com/bbangle/bbangle/order/seller/controller/dto/request/SellerOrderRequest.java +++ b/src/main/java/com/bbangle/bbangle/order/seller/controller/dto/request/SellerOrderRequest.java @@ -1,5 +1,7 @@ package com.bbangle.bbangle.order.seller.controller.dto.request; +import com.bbangle.bbangle.claim.seller.service.model.ExchangeCreateCommand; +import com.bbangle.bbangle.claim.seller.service.model.ReturnCreateCommand; import com.bbangle.bbangle.order.seller.service.model.SellerOrderCommand.OrderConfirmCommand; import com.bbangle.bbangle.order.seller.service.model.SellerOrderCommand.ShipmentModifyCommand; import com.bbangle.bbangle.order.seller.service.model.SellerOrderCommand.ShipmentRegisterCommand; @@ -56,6 +58,58 @@ public ShipmentRegisterCommand toCommand(Long sellerId, Long orderId) { } } + @Schema(description = "판매자 반품 요청 생성 DTO") + public record ReturnCreateRequest( + + @Schema(description = "반품 대상 주문상품 ID 목록") + @NotEmpty(message = "orderItemIds는 필수입니다.") + List orderItemIds, + + @Schema(description = "반품 사유") + String reason, + + @Schema(description = "판매자 코멘트") + String sellerComment + + ) { + + public ReturnCreateCommand toCommand(Long sellerId, Long orderId) { + return ReturnCreateCommand.builder() + .sellerId(sellerId) + .orderId(orderId) + .orderItemIds(orderItemIds) + .reason(reason) + .sellerComment(sellerComment) + .build(); + } + } + + @Schema(description = "판매자 교환 요청 생성 DTO") + public record ExchangeCreateRequest( + + @Schema(description = "교환 대상 주문상품 ID 목록") + @NotEmpty(message = "orderItemIds는 필수입니다.") + List orderItemIds, + + @Schema(description = "교환 사유") + String reason, + + @Schema(description = "판매자 코멘트") + String sellerComment + + ) { + + public ExchangeCreateCommand toCommand(Long sellerId, Long orderId) { + return ExchangeCreateCommand.builder() + .sellerId(sellerId) + .orderId(orderId) + .orderItemIds(orderItemIds) + .reason(reason) + .sellerComment(sellerComment) + .build(); + } + } + @Schema(description = "판매자 운송장 수정 요청 DTO") public record ShipmentModifyRequest( diff --git a/src/main/java/com/bbangle/bbangle/order/seller/controller/dto/response/SellerOrderResponse.java b/src/main/java/com/bbangle/bbangle/order/seller/controller/dto/response/SellerOrderResponse.java index 1936f9936..d0f80f279 100644 --- a/src/main/java/com/bbangle/bbangle/order/seller/controller/dto/response/SellerOrderResponse.java +++ b/src/main/java/com/bbangle/bbangle/order/seller/controller/dto/response/SellerOrderResponse.java @@ -82,6 +82,102 @@ public static Summary of( } } + @Builder + @Schema(description = "판매자 반품 요청 생성 응답 DTO") + public record ReturnCreateResponse( + + @Schema(description = "반품 요청 결과 컨텐츠") + ReturnContent content + + ) { + + public static ReturnCreateResponse of(ReturnContent content) { + return ReturnCreateResponse.builder() + .content(content) + .build(); + } + } + + @Builder + @Schema(description = "반품 요청 응답 컨텐츠") + public record ReturnContent( + + @Schema(description = "주문 ID") + Long orderId, + + @Schema(description = "반품 요청 요약 정보") + Summary summary, + + @Schema(description = "반품 요청 성공한 주문상품 ID 목록") + List successOrderItemIds, + + @Schema(description = "반품 요청 실패한 주문상품 ID 목록") + List failedOrderItemIds + + ) { + public static ReturnContent of( + Long orderId, + Summary summary, + List successOrderItemIds, + List failedOrderItemIds + ) { + return ReturnContent.builder() + .orderId(orderId) + .summary(summary) + .successOrderItemIds(successOrderItemIds) + .failedOrderItemIds(failedOrderItemIds) + .build(); + } + } + + @Builder + @Schema(description = "판매자 교환 요청 생성 응답 DTO") + public record ExchangeCreateResponse( + + @Schema(description = "교환 요청 결과 컨텐츠") + ExchangeContent content + + ) { + + public static ExchangeCreateResponse of(ExchangeContent content) { + return ExchangeCreateResponse.builder() + .content(content) + .build(); + } + } + + @Builder + @Schema(description = "교환 요청 응답 컨텐츠") + public record ExchangeContent( + + @Schema(description = "주문 ID") + Long orderId, + + @Schema(description = "교환 요청 요약 정보") + Summary summary, + + @Schema(description = "교환 요청 성공한 주문상품 ID 목록") + List successOrderItemIds, + + @Schema(description = "교환 요청 실패한 주문상품 ID 목록") + List failedOrderItemIds + + ) { + public static ExchangeContent of( + Long orderId, + Summary summary, + List successOrderItemIds, + List failedOrderItemIds + ) { + return ExchangeContent.builder() + .orderId(orderId) + .summary(summary) + .successOrderItemIds(successOrderItemIds) + .failedOrderItemIds(failedOrderItemIds) + .build(); + } + } + @Builder @Schema(description = "판매자 운송장 정보 등록 응답 DTO") public record ShipmentRegisterResponse( diff --git a/src/test/java/com/bbangle/bbangle/claim/seller/service/SellerExchangeServiceCreateTest.java b/src/test/java/com/bbangle/bbangle/claim/seller/service/SellerExchangeServiceCreateTest.java new file mode 100644 index 000000000..f6933ce5f --- /dev/null +++ b/src/test/java/com/bbangle/bbangle/claim/seller/service/SellerExchangeServiceCreateTest.java @@ -0,0 +1,319 @@ +package com.bbangle.bbangle.claim.seller.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.times; + +import com.bbangle.bbangle.claim.repository.ExchangeRequestRepository; +import com.bbangle.bbangle.claim.seller.service.model.ExchangeCreateCommand; +import com.bbangle.bbangle.exception.BbangleErrorCode; +import com.bbangle.bbangle.exception.BbangleException; +import com.bbangle.bbangle.fixture.order.OrderItemFixture; +import com.bbangle.bbangle.order.domain.Order; +import com.bbangle.bbangle.order.domain.OrderItem; +import com.bbangle.bbangle.order.domain.model.OrderStatus; +import com.bbangle.bbangle.order.repository.OrderItemHistoryRepository; +import com.bbangle.bbangle.order.repository.OrderItemRepository; +import com.bbangle.bbangle.order.repository.OrderRepository; +import com.bbangle.bbangle.order.seller.controller.dto.response.SellerOrderResponse.ExchangeCreateResponse; +import com.bbangle.bbangle.seller.repository.SellerRepository; +import java.lang.reflect.Constructor; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@DisplayName("[단위 테스트] SellerExchangeService - createExchange") +@ExtendWith(MockitoExtension.class) +class SellerExchangeServiceCreateTest { + + @InjectMocks + private SellerExchangeService sut; + + @Mock + private ExchangeRequestRepository exchangeRequestRepository; + + @Mock + private OrderItemHistoryRepository orderItemHistoryRepository; + + @Mock + private OrderRepository orderRepository; + + @Mock + private OrderItemRepository orderItemRepository; + + @Mock + private SellerRepository sellerRepository; + + @DisplayName("SHIPPED 상태의 주문상품에 대해 교환 요청이 성공한다.") + @Test + void givenShippedOrderItem_whenCreateExchange_thenSuccess() { + // given + Long orderId = 1L; + Long sellerId = 1L; + Long storeId = 100L; + + Order order = newEntity(Order.class); + ReflectionTestUtils.setField(order, "id", orderId); + + OrderItem orderItem = OrderItemFixture.orderItemWithStatus(OrderStatus.SHIPPED); + ReflectionTestUtils.setField(orderItem, "id", 10L); + + ExchangeCreateCommand command = ExchangeCreateCommand.builder() + .sellerId(sellerId) + .orderId(orderId) + .orderItemIds(List.of(10L)) + .reason("상품 불량") + .build(); + + given(orderRepository.findById(orderId)).willReturn(Optional.of(order)); + given(sellerRepository.findStoreIdBySellerId(sellerId)).willReturn(storeId); + given(orderItemRepository.findByOrderIdAndIdIn(orderId, List.of(10L))) + .willReturn(List.of(orderItem)); + given(orderItemRepository.countOwnedOrderItems(orderId, List.of(10L), storeId)) + .willReturn(1L); + + // when + ExchangeCreateResponse result = sut.createExchange(command); + + // then + assertThat(result).isNotNull(); + assertThat(result.content().orderId()).isEqualTo(orderId); + assertThat(result.content().successOrderItemIds()).containsExactly(10L); + assertThat(result.content().failedOrderItemIds()).isEmpty(); + assertThat(result.content().summary().requestedCount()).isEqualTo(1); + assertThat(result.content().summary().successCount()).isEqualTo(1); + assertThat(result.content().summary().failCount()).isEqualTo(0); + assertThat(orderItem.getOrderStatus()).isEqualTo(OrderStatus.EXCHANGE_REQUEST); + + then(exchangeRequestRepository).should(times(1)).saveAll(anyList()); + then(orderItemHistoryRepository).should(times(1)).saveAll(anyList()); + } + + @DisplayName("PURCHASE_CONFIRMED 상태의 주문상품에 대해 교환 요청이 성공한다.") + @Test + void givenPurchaseConfirmedOrderItem_whenCreateExchange_thenSuccess() { + // given + Long orderId = 1L; + Long sellerId = 1L; + Long storeId = 100L; + + Order order = newEntity(Order.class); + ReflectionTestUtils.setField(order, "id", orderId); + + OrderItem orderItem = OrderItemFixture.orderItemWithStatus(OrderStatus.PURCHASE_CONFIRMED); + ReflectionTestUtils.setField(orderItem, "id", 10L); + + ExchangeCreateCommand command = ExchangeCreateCommand.builder() + .sellerId(sellerId) + .orderId(orderId) + .orderItemIds(List.of(10L)) + .reason("사이즈 변경") + .build(); + + given(orderRepository.findById(orderId)).willReturn(Optional.of(order)); + given(sellerRepository.findStoreIdBySellerId(sellerId)).willReturn(storeId); + given(orderItemRepository.findByOrderIdAndIdIn(orderId, List.of(10L))) + .willReturn(List.of(orderItem)); + given(orderItemRepository.countOwnedOrderItems(orderId, List.of(10L), storeId)) + .willReturn(1L); + + // when + ExchangeCreateResponse result = sut.createExchange(command); + + // then + assertThat(result).isNotNull(); + assertThat(result.content().successOrderItemIds()).containsExactly(10L); + assertThat(result.content().failedOrderItemIds()).isEmpty(); + assertThat(orderItem.getOrderStatus()).isEqualTo(OrderStatus.EXCHANGE_REQUEST); + + then(exchangeRequestRepository).should(times(1)).saveAll(anyList()); + then(orderItemHistoryRepository).should(times(1)).saveAll(anyList()); + } + + @DisplayName("혼합 상태(SHIPPED + ORDER_CONFIRMED)에서 SHIPPED만 교환 성공하고, ORDER_CONFIRMED는 실패한다.") + @Test + void givenMixedStatusOrderItems_whenCreateExchange_thenPartialSuccess() { + // given + Long orderId = 1L; + Long sellerId = 1L; + Long storeId = 100L; + + Order order = newEntity(Order.class); + ReflectionTestUtils.setField(order, "id", orderId); + + OrderItem shippedItem = OrderItemFixture.orderItemWithStatus(OrderStatus.SHIPPED); + ReflectionTestUtils.setField(shippedItem, "id", 10L); + + OrderItem confirmedItem = OrderItemFixture.orderItemWithStatus(OrderStatus.ORDER_CONFIRMED); + ReflectionTestUtils.setField(confirmedItem, "id", 11L); + + ExchangeCreateCommand command = ExchangeCreateCommand.builder() + .sellerId(sellerId) + .orderId(orderId) + .orderItemIds(List.of(10L, 11L)) + .reason("혼합 테스트") + .build(); + + given(orderRepository.findById(orderId)).willReturn(Optional.of(order)); + given(sellerRepository.findStoreIdBySellerId(sellerId)).willReturn(storeId); + given(orderItemRepository.findByOrderIdAndIdIn(orderId, List.of(10L, 11L))) + .willReturn(List.of(shippedItem, confirmedItem)); + given(orderItemRepository.countOwnedOrderItems(orderId, List.of(10L, 11L), storeId)) + .willReturn(2L); + + // when + ExchangeCreateResponse result = sut.createExchange(command); + + // then + assertThat(result.content().summary().requestedCount()).isEqualTo(2); + assertThat(result.content().summary().successCount()).isEqualTo(1); + assertThat(result.content().summary().failCount()).isEqualTo(1); + assertThat(result.content().successOrderItemIds()).containsExactly(10L); + assertThat(result.content().failedOrderItemIds()).containsExactly(11L); + + assertThat(shippedItem.getOrderStatus()).isEqualTo(OrderStatus.EXCHANGE_REQUEST); + assertThat(confirmedItem.getOrderStatus()).isEqualTo(OrderStatus.ORDER_CONFIRMED); + + then(exchangeRequestRepository).should(times(1)).saveAll(anyList()); + then(orderItemHistoryRepository).should(times(1)).saveAll(anyList()); + } + + @DisplayName("허용되지 않는 상태(PAYMENT_COMPLETED)의 주문상품은 모두 실패한다.") + @Test + void givenInvalidStatusOrderItems_whenCreateExchange_thenAllFail() { + // given + Long orderId = 1L; + Long sellerId = 1L; + Long storeId = 100L; + + Order order = newEntity(Order.class); + ReflectionTestUtils.setField(order, "id", orderId); + + OrderItem paymentCompletedItem = OrderItemFixture.orderItemWithStatus(OrderStatus.PAYMENT_COMPLETED); + ReflectionTestUtils.setField(paymentCompletedItem, "id", 10L); + + ExchangeCreateCommand command = ExchangeCreateCommand.builder() + .sellerId(sellerId) + .orderId(orderId) + .orderItemIds(List.of(10L)) + .reason("교환 테스트") + .build(); + + given(orderRepository.findById(orderId)).willReturn(Optional.of(order)); + given(sellerRepository.findStoreIdBySellerId(sellerId)).willReturn(storeId); + given(orderItemRepository.findByOrderIdAndIdIn(orderId, List.of(10L))) + .willReturn(List.of(paymentCompletedItem)); + given(orderItemRepository.countOwnedOrderItems(orderId, List.of(10L), storeId)) + .willReturn(1L); + + // when + ExchangeCreateResponse result = sut.createExchange(command); + + // then + assertThat(result.content().summary().successCount()).isEqualTo(0); + assertThat(result.content().summary().failCount()).isEqualTo(1); + assertThat(result.content().successOrderItemIds()).isEmpty(); + assertThat(result.content().failedOrderItemIds()).containsExactly(10L); + + assertThat(paymentCompletedItem.getOrderStatus()).isEqualTo(OrderStatus.PAYMENT_COMPLETED); + + then(exchangeRequestRepository).should(times(1)).saveAll(anyList()); + then(orderItemHistoryRepository).should(times(1)).saveAll(anyList()); + } + + @DisplayName("존재하지 않는 orderId이면 ORDER_NOT_FOUND 예외가 발생한다.") + @Test + void givenNonExistingOrderId_whenCreateExchange_thenThrowsException() { + // given + Long orderId = 999L; + ExchangeCreateCommand command = ExchangeCreateCommand.builder() + .sellerId(1L) + .orderId(orderId) + .orderItemIds(List.of(1L)) + .reason("교환 사유") + .build(); + + given(orderRepository.findById(orderId)).willReturn(Optional.empty()); + + // when + BbangleException result = assertThrows(BbangleException.class, + () -> sut.createExchange(command)); + + // then + assertThat(result.getBbangleErrorCode()).isEqualTo(BbangleErrorCode.ORDER_NOT_FOUND); + } + + @DisplayName("소유권이 없는 주문상품이면 ORDER_ACCESS_DENIED 예외가 발생한다.") + @Test + void givenNonOwnerSeller_whenCreateExchange_thenThrowsException() { + // given + Long orderId = 1L; + Long sellerId = 1L; + Long storeId = 100L; + + Order order = newEntity(Order.class); + ReflectionTestUtils.setField(order, "id", orderId); + + OrderItem orderItem = OrderItemFixture.orderItemWithStatus(OrderStatus.SHIPPED); + ReflectionTestUtils.setField(orderItem, "id", 10L); + + ExchangeCreateCommand command = ExchangeCreateCommand.builder() + .sellerId(sellerId) + .orderId(orderId) + .orderItemIds(List.of(10L)) + .reason("교환 사유") + .build(); + + given(orderRepository.findById(orderId)).willReturn(Optional.of(order)); + given(sellerRepository.findStoreIdBySellerId(sellerId)).willReturn(storeId); + given(orderItemRepository.findByOrderIdAndIdIn(orderId, List.of(10L))) + .willReturn(List.of(orderItem)); + given(orderItemRepository.countOwnedOrderItems(orderId, List.of(10L), storeId)) + .willReturn(0L); + + // when + BbangleException result = assertThrows(BbangleException.class, + () -> sut.createExchange(command)); + + // then + assertThat(result.getBbangleErrorCode()).isEqualTo(BbangleErrorCode.ORDER_ACCESS_DENIED); + } + + @DisplayName("orderItemIds가 비어있으면 ORDER_ITEM_NOT_FOUND 예외가 발생한다.") + @Test + void givenEmptyOrderItemIds_whenCreateExchange_thenThrowsException() { + // given + ExchangeCreateCommand command = ExchangeCreateCommand.builder() + .sellerId(1L) + .orderId(1L) + .orderItemIds(List.of()) + .reason("교환 사유") + .build(); + + // when + BbangleException result = assertThrows(BbangleException.class, + () -> sut.createExchange(command)); + + // then + assertThat(result.getBbangleErrorCode()).isEqualTo(BbangleErrorCode.ORDER_ITEM_NOT_FOUND); + } + + private T newEntity(Class clazz) { + try { + Constructor constructor = clazz.getDeclaredConstructor(); + constructor.setAccessible(true); + return constructor.newInstance(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/test/java/com/bbangle/bbangle/claim/seller/service/SellerReturnServiceCreateTest.java b/src/test/java/com/bbangle/bbangle/claim/seller/service/SellerReturnServiceCreateTest.java new file mode 100644 index 000000000..212802f81 --- /dev/null +++ b/src/test/java/com/bbangle/bbangle/claim/seller/service/SellerReturnServiceCreateTest.java @@ -0,0 +1,319 @@ +package com.bbangle.bbangle.claim.seller.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.times; + +import com.bbangle.bbangle.claim.repository.ReturnRequestRepository; +import com.bbangle.bbangle.claim.seller.service.model.ReturnCreateCommand; +import com.bbangle.bbangle.exception.BbangleErrorCode; +import com.bbangle.bbangle.exception.BbangleException; +import com.bbangle.bbangle.fixture.order.OrderItemFixture; +import com.bbangle.bbangle.order.domain.Order; +import com.bbangle.bbangle.order.domain.OrderItem; +import com.bbangle.bbangle.order.domain.model.OrderStatus; +import com.bbangle.bbangle.order.repository.OrderItemHistoryRepository; +import com.bbangle.bbangle.order.repository.OrderItemRepository; +import com.bbangle.bbangle.order.repository.OrderRepository; +import com.bbangle.bbangle.order.seller.controller.dto.response.SellerOrderResponse.ReturnCreateResponse; +import com.bbangle.bbangle.seller.repository.SellerRepository; +import java.lang.reflect.Constructor; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@DisplayName("[단위 테스트] SellerReturnService - createReturn") +@ExtendWith(MockitoExtension.class) +class SellerReturnServiceCreateTest { + + @InjectMocks + private SellerReturnService sut; + + @Mock + private ReturnRequestRepository returnRequestRepository; + + @Mock + private OrderItemHistoryRepository orderItemHistoryRepository; + + @Mock + private OrderRepository orderRepository; + + @Mock + private OrderItemRepository orderItemRepository; + + @Mock + private SellerRepository sellerRepository; + + @DisplayName("SHIPPED 상태의 주문상품에 대해 반품 요청이 성공한다.") + @Test + void givenShippedOrderItem_whenCreateReturn_thenSuccess() { + // given + Long orderId = 1L; + Long sellerId = 1L; + Long storeId = 100L; + + Order order = newEntity(Order.class); + ReflectionTestUtils.setField(order, "id", orderId); + + OrderItem orderItem = OrderItemFixture.orderItemWithStatus(OrderStatus.SHIPPED); + ReflectionTestUtils.setField(orderItem, "id", 10L); + + ReturnCreateCommand command = ReturnCreateCommand.builder() + .sellerId(sellerId) + .orderId(orderId) + .orderItemIds(List.of(10L)) + .reason("상품 불량") + .build(); + + given(orderRepository.findById(orderId)).willReturn(Optional.of(order)); + given(sellerRepository.findStoreIdBySellerId(sellerId)).willReturn(storeId); + given(orderItemRepository.findByOrderIdAndIdIn(orderId, List.of(10L))) + .willReturn(List.of(orderItem)); + given(orderItemRepository.countOwnedOrderItems(orderId, List.of(10L), storeId)) + .willReturn(1L); + + // when + ReturnCreateResponse result = sut.createReturn(command); + + // then + assertThat(result).isNotNull(); + assertThat(result.content().orderId()).isEqualTo(orderId); + assertThat(result.content().successOrderItemIds()).containsExactly(10L); + assertThat(result.content().failedOrderItemIds()).isEmpty(); + assertThat(result.content().summary().requestedCount()).isEqualTo(1); + assertThat(result.content().summary().successCount()).isEqualTo(1); + assertThat(result.content().summary().failCount()).isEqualTo(0); + assertThat(orderItem.getOrderStatus()).isEqualTo(OrderStatus.RETURN_REQUESTED); + + then(returnRequestRepository).should(times(1)).saveAll(anyList()); + then(orderItemHistoryRepository).should(times(1)).saveAll(anyList()); + } + + @DisplayName("PURCHASE_CONFIRMED 상태의 주문상품에 대해 반품 요청이 성공한다.") + @Test + void givenPurchaseConfirmedOrderItem_whenCreateReturn_thenSuccess() { + // given + Long orderId = 1L; + Long sellerId = 1L; + Long storeId = 100L; + + Order order = newEntity(Order.class); + ReflectionTestUtils.setField(order, "id", orderId); + + OrderItem orderItem = OrderItemFixture.orderItemWithStatus(OrderStatus.PURCHASE_CONFIRMED); + ReflectionTestUtils.setField(orderItem, "id", 10L); + + ReturnCreateCommand command = ReturnCreateCommand.builder() + .sellerId(sellerId) + .orderId(orderId) + .orderItemIds(List.of(10L)) + .reason("단순 변심") + .build(); + + given(orderRepository.findById(orderId)).willReturn(Optional.of(order)); + given(sellerRepository.findStoreIdBySellerId(sellerId)).willReturn(storeId); + given(orderItemRepository.findByOrderIdAndIdIn(orderId, List.of(10L))) + .willReturn(List.of(orderItem)); + given(orderItemRepository.countOwnedOrderItems(orderId, List.of(10L), storeId)) + .willReturn(1L); + + // when + ReturnCreateResponse result = sut.createReturn(command); + + // then + assertThat(result).isNotNull(); + assertThat(result.content().successOrderItemIds()).containsExactly(10L); + assertThat(result.content().failedOrderItemIds()).isEmpty(); + assertThat(orderItem.getOrderStatus()).isEqualTo(OrderStatus.RETURN_REQUESTED); + + then(returnRequestRepository).should(times(1)).saveAll(anyList()); + then(orderItemHistoryRepository).should(times(1)).saveAll(anyList()); + } + + @DisplayName("혼합 상태(SHIPPED + ORDER_CONFIRMED)에서 SHIPPED만 반품 성공하고, ORDER_CONFIRMED는 실패한다.") + @Test + void givenMixedStatusOrderItems_whenCreateReturn_thenPartialSuccess() { + // given + Long orderId = 1L; + Long sellerId = 1L; + Long storeId = 100L; + + Order order = newEntity(Order.class); + ReflectionTestUtils.setField(order, "id", orderId); + + OrderItem shippedItem = OrderItemFixture.orderItemWithStatus(OrderStatus.SHIPPED); + ReflectionTestUtils.setField(shippedItem, "id", 10L); + + OrderItem confirmedItem = OrderItemFixture.orderItemWithStatus(OrderStatus.ORDER_CONFIRMED); + ReflectionTestUtils.setField(confirmedItem, "id", 11L); + + ReturnCreateCommand command = ReturnCreateCommand.builder() + .sellerId(sellerId) + .orderId(orderId) + .orderItemIds(List.of(10L, 11L)) + .reason("혼합 테스트") + .build(); + + given(orderRepository.findById(orderId)).willReturn(Optional.of(order)); + given(sellerRepository.findStoreIdBySellerId(sellerId)).willReturn(storeId); + given(orderItemRepository.findByOrderIdAndIdIn(orderId, List.of(10L, 11L))) + .willReturn(List.of(shippedItem, confirmedItem)); + given(orderItemRepository.countOwnedOrderItems(orderId, List.of(10L, 11L), storeId)) + .willReturn(2L); + + // when + ReturnCreateResponse result = sut.createReturn(command); + + // then + assertThat(result.content().summary().requestedCount()).isEqualTo(2); + assertThat(result.content().summary().successCount()).isEqualTo(1); + assertThat(result.content().summary().failCount()).isEqualTo(1); + assertThat(result.content().successOrderItemIds()).containsExactly(10L); + assertThat(result.content().failedOrderItemIds()).containsExactly(11L); + + assertThat(shippedItem.getOrderStatus()).isEqualTo(OrderStatus.RETURN_REQUESTED); + assertThat(confirmedItem.getOrderStatus()).isEqualTo(OrderStatus.ORDER_CONFIRMED); + + then(returnRequestRepository).should(times(1)).saveAll(anyList()); + then(orderItemHistoryRepository).should(times(1)).saveAll(anyList()); + } + + @DisplayName("허용되지 않는 상태(PAYMENT_COMPLETED)의 주문상품은 모두 실패한다.") + @Test + void givenInvalidStatusOrderItems_whenCreateReturn_thenAllFail() { + // given + Long orderId = 1L; + Long sellerId = 1L; + Long storeId = 100L; + + Order order = newEntity(Order.class); + ReflectionTestUtils.setField(order, "id", orderId); + + OrderItem paymentCompletedItem = OrderItemFixture.orderItemWithStatus(OrderStatus.PAYMENT_COMPLETED); + ReflectionTestUtils.setField(paymentCompletedItem, "id", 10L); + + ReturnCreateCommand command = ReturnCreateCommand.builder() + .sellerId(sellerId) + .orderId(orderId) + .orderItemIds(List.of(10L)) + .reason("반품 테스트") + .build(); + + given(orderRepository.findById(orderId)).willReturn(Optional.of(order)); + given(sellerRepository.findStoreIdBySellerId(sellerId)).willReturn(storeId); + given(orderItemRepository.findByOrderIdAndIdIn(orderId, List.of(10L))) + .willReturn(List.of(paymentCompletedItem)); + given(orderItemRepository.countOwnedOrderItems(orderId, List.of(10L), storeId)) + .willReturn(1L); + + // when + ReturnCreateResponse result = sut.createReturn(command); + + // then + assertThat(result.content().summary().successCount()).isEqualTo(0); + assertThat(result.content().summary().failCount()).isEqualTo(1); + assertThat(result.content().successOrderItemIds()).isEmpty(); + assertThat(result.content().failedOrderItemIds()).containsExactly(10L); + + assertThat(paymentCompletedItem.getOrderStatus()).isEqualTo(OrderStatus.PAYMENT_COMPLETED); + + then(returnRequestRepository).should(times(1)).saveAll(anyList()); + then(orderItemHistoryRepository).should(times(1)).saveAll(anyList()); + } + + @DisplayName("존재하지 않는 orderId이면 ORDER_NOT_FOUND 예외가 발생한다.") + @Test + void givenNonExistingOrderId_whenCreateReturn_thenThrowsException() { + // given + Long orderId = 999L; + ReturnCreateCommand command = ReturnCreateCommand.builder() + .sellerId(1L) + .orderId(orderId) + .orderItemIds(List.of(1L)) + .reason("반품 사유") + .build(); + + given(orderRepository.findById(orderId)).willReturn(Optional.empty()); + + // when + BbangleException result = assertThrows(BbangleException.class, + () -> sut.createReturn(command)); + + // then + assertThat(result.getBbangleErrorCode()).isEqualTo(BbangleErrorCode.ORDER_NOT_FOUND); + } + + @DisplayName("소유권이 없는 주문상품이면 ORDER_ACCESS_DENIED 예외가 발생한다.") + @Test + void givenNonOwnerSeller_whenCreateReturn_thenThrowsException() { + // given + Long orderId = 1L; + Long sellerId = 1L; + Long storeId = 100L; + + Order order = newEntity(Order.class); + ReflectionTestUtils.setField(order, "id", orderId); + + OrderItem orderItem = OrderItemFixture.orderItemWithStatus(OrderStatus.SHIPPED); + ReflectionTestUtils.setField(orderItem, "id", 10L); + + ReturnCreateCommand command = ReturnCreateCommand.builder() + .sellerId(sellerId) + .orderId(orderId) + .orderItemIds(List.of(10L)) + .reason("반품 사유") + .build(); + + given(orderRepository.findById(orderId)).willReturn(Optional.of(order)); + given(sellerRepository.findStoreIdBySellerId(sellerId)).willReturn(storeId); + given(orderItemRepository.findByOrderIdAndIdIn(orderId, List.of(10L))) + .willReturn(List.of(orderItem)); + given(orderItemRepository.countOwnedOrderItems(orderId, List.of(10L), storeId)) + .willReturn(0L); + + // when + BbangleException result = assertThrows(BbangleException.class, + () -> sut.createReturn(command)); + + // then + assertThat(result.getBbangleErrorCode()).isEqualTo(BbangleErrorCode.ORDER_ACCESS_DENIED); + } + + @DisplayName("orderItemIds가 비어있으면 ORDER_ITEM_NOT_FOUND 예외가 발생한다.") + @Test + void givenEmptyOrderItemIds_whenCreateReturn_thenThrowsException() { + // given + ReturnCreateCommand command = ReturnCreateCommand.builder() + .sellerId(1L) + .orderId(1L) + .orderItemIds(List.of()) + .reason("반품 사유") + .build(); + + // when + BbangleException result = assertThrows(BbangleException.class, + () -> sut.createReturn(command)); + + // then + assertThat(result.getBbangleErrorCode()).isEqualTo(BbangleErrorCode.ORDER_ITEM_NOT_FOUND); + } + + private T newEntity(Class clazz) { + try { + Constructor constructor = clazz.getDeclaredConstructor(); + constructor.setAccessible(true); + return constructor.newInstance(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/test/java/com/bbangle/bbangle/claim/seller/service/SellerReturnServiceUnitTest.java b/src/test/java/com/bbangle/bbangle/claim/seller/service/SellerReturnServiceUnitTest.java index 0d2758fba..7972ef117 100644 --- a/src/test/java/com/bbangle/bbangle/claim/seller/service/SellerReturnServiceUnitTest.java +++ b/src/test/java/com/bbangle/bbangle/claim/seller/service/SellerReturnServiceUnitTest.java @@ -68,7 +68,7 @@ void decision_approve_success() { // then then(returnRequestRepository).should(times(1)).countReturnsBySeller(returnIds, sellerId); then(returnRequestRepository).should(times(1)).findAllById(returnIds); - then(orderItemHistoryRepository).should(times(3)).save(any(OrderItemHistory.class)); + then(orderItemHistoryRepository).should(times(1)).saveAll(anyList()); } @Test @@ -93,7 +93,7 @@ void decision_reject_success() { // then then(returnRequestRepository).should(times(1)).countReturnsBySeller(returnIds, sellerId); then(returnRequestRepository).should(times(1)).findAllById(returnIds); - then(orderItemHistoryRepository).should(times(2)).save(any(OrderItemHistory.class)); + then(orderItemHistoryRepository).should(times(1)).saveAll(anyList()); } @Test