diff --git a/features/checkout/shipping_order/seeing_default_shipping_method_selected_based_on_shipping_address.feature b/features/checkout/shipping_order/seeing_default_shipping_method_selected_based_on_shipping_address.feature new file mode 100644 index 00000000000..a69455c2375 --- /dev/null +++ b/features/checkout/shipping_order/seeing_default_shipping_method_selected_based_on_shipping_address.feature @@ -0,0 +1,41 @@ +@checkout +Feature: Seeing default shipping method selected based on shipping address + In order to select correct shipping method for my order + As a Customer + I want to be able to choose only shipping methods that match shipping category of all my items + + Background: + Given the store operates on a channel named "Web" + And the store has a product "Star Trek Ship" priced at "$19.99" + And the store ships to "United Kingdom" + And the store ships to "United States" + And the store has a zone "United Kingdom" with code "UK" + And this zone has the "United Kingdom" country member + And the store has a zone "United States" with code "US" + And this zone has the "United States" country member + And the store has "DHL" shipping method with "$10.00" fee within the "US" zone + And the store has "FedEx" shipping method with "$20.00" fee within the "UK" zone + And I am a logged in customer + + @ui + Scenario: Seeing default shipping method selected based on country from shipping address + Given I have product "Star Trek Ship" in the cart + And I am at the checkout addressing step + When I specify the shipping address as "Ankh Morpork", "Frost Alley", "90210", "United States" for "Jon Snow" + And I complete the addressing step + Then I should be on the checkout shipping step + And I should see selected "DHL" shipping method + And I should not see "FedEx" shipping method + + @ui + Scenario: Seeing default shipping method selected based on country from shipping address after readdressing + Given I have product "Star Trek Ship" in the cart + And I am at the checkout addressing step + When I specify the shipping address as "Ankh Morpork", "Frost Alley", "90210", "United States" for "Jon Snow" + And I complete the addressing step + And I decide to change my address + And I specify the shipping address as "Ankh Morpork", "Frost Alley", "90210", "United Kingdom" for "Jon Snow" + And I complete the addressing step + Then I should be on the checkout shipping step + And I should see selected "FedEx" shipping method + And I should not see "DHL" shipping method diff --git a/src/Sylius/Behat/Context/Ui/Shop/Checkout/CheckoutShippingContext.php b/src/Sylius/Behat/Context/Ui/Shop/Checkout/CheckoutShippingContext.php index f6ecc42343e..c1cd95b81ba 100644 --- a/src/Sylius/Behat/Context/Ui/Shop/Checkout/CheckoutShippingContext.php +++ b/src/Sylius/Behat/Context/Ui/Shop/Checkout/CheckoutShippingContext.php @@ -191,6 +191,14 @@ public function iShouldSeeShippingMethod($shippingMethodName) Assert::true($this->selectShippingPage->hasShippingMethod($shippingMethodName)); } + /** + * @Then I should see selected :shippingMethodName shipping method + */ + public function iShouldSeeSelectedShippingMethod($shippingMethodName) + { + Assert::same($this->selectShippingPage->getSelectedShippingMethodName(), $shippingMethodName); + } + /** * @Then I should not see :shippingMethodName shipping method */ diff --git a/src/Sylius/Behat/Page/Shop/Checkout/SelectShippingPage.php b/src/Sylius/Behat/Page/Shop/Checkout/SelectShippingPage.php index e1f33820c67..42a1b6876ed 100644 --- a/src/Sylius/Behat/Page/Shop/Checkout/SelectShippingPage.php +++ b/src/Sylius/Behat/Page/Shop/Checkout/SelectShippingPage.php @@ -14,6 +14,7 @@ namespace Sylius\Behat\Page\Shop\Checkout; use Behat\Mink\Driver\Selenium2Driver; +use Behat\Mink\Element\NodeElement; use Behat\Mink\Exception\ElementNotFoundException; use Sylius\Behat\Page\SymfonyPage; @@ -57,6 +58,24 @@ public function getShippingMethods() return $shippingMethods; } + /** + * {@inheritdoc} + */ + public function getSelectedShippingMethodName(): ?string + { + $shippingMethods = $this->getSession()->getPage()->findAll('css', '#sylius-shipping-methods .item'); + + /** @var NodeElement $shippingMethod */ + foreach ($shippingMethods as $shippingMethod) { + if (null !== $shippingMethod->find('css', 'input:checked')) { + return $shippingMethod->find('css', '.content label')->getText(); + } + } + + return null; + } + + /** * {@inheritdoc} */ diff --git a/src/Sylius/Behat/Page/Shop/Checkout/SelectShippingPageInterface.php b/src/Sylius/Behat/Page/Shop/Checkout/SelectShippingPageInterface.php index 40ee608e857..9a3185acdb8 100644 --- a/src/Sylius/Behat/Page/Shop/Checkout/SelectShippingPageInterface.php +++ b/src/Sylius/Behat/Page/Shop/Checkout/SelectShippingPageInterface.php @@ -27,6 +27,11 @@ public function selectShippingMethod($shippingMethod); */ public function getShippingMethods(); + /** + * @return string|null + */ + public function getSelectedShippingMethodName(): ?string; + /** * @return bool */ diff --git a/src/Sylius/Bundle/CoreBundle/Resources/config/services.xml b/src/Sylius/Bundle/CoreBundle/Resources/config/services.xml index 426b016e223..cdebe83c23d 100644 --- a/src/Sylius/Bundle/CoreBundle/Resources/config/services.xml +++ b/src/Sylius/Bundle/CoreBundle/Resources/config/services.xml @@ -70,6 +70,7 @@ + diff --git a/src/Sylius/Bundle/CoreBundle/Resources/config/services/order_processing.xml b/src/Sylius/Bundle/CoreBundle/Resources/config/services/order_processing.xml index 503ba37b33d..5d506342560 100644 --- a/src/Sylius/Bundle/CoreBundle/Resources/config/services/order_processing.xml +++ b/src/Sylius/Bundle/CoreBundle/Resources/config/services/order_processing.xml @@ -30,6 +30,7 @@ + diff --git a/src/Sylius/Component/Core/OrderProcessing/OrderShipmentProcessor.php b/src/Sylius/Component/Core/OrderProcessing/OrderShipmentProcessor.php index 4c0b8da8018..f292431efea 100644 --- a/src/Sylius/Component/Core/OrderProcessing/OrderShipmentProcessor.php +++ b/src/Sylius/Component/Core/OrderProcessing/OrderShipmentProcessor.php @@ -20,6 +20,7 @@ use Sylius\Component\Resource\Factory\FactoryInterface; use Sylius\Component\Shipping\Exception\UnresolvedDefaultShippingMethodException; use Sylius\Component\Shipping\Resolver\DefaultShippingMethodResolverInterface; +use Sylius\Component\Shipping\Resolver\ShippingMethodsResolverInterface; use Webmozart\Assert\Assert; final class OrderShipmentProcessor implements OrderProcessorInterface @@ -34,16 +35,31 @@ final class OrderShipmentProcessor implements OrderProcessorInterface */ private $shipmentFactory; + /** + * @var ShippingMethodsResolverInterface|null + */ + private $shippingMethodsResolver; + /** * @param DefaultShippingMethodResolverInterface $defaultShippingMethodResolver * @param FactoryInterface $shipmentFactory + * @param ShippingMethodsResolverInterface|null $shippingMethodsResolver */ public function __construct( DefaultShippingMethodResolverInterface $defaultShippingMethodResolver, - FactoryInterface $shipmentFactory + FactoryInterface $shipmentFactory, + ?ShippingMethodsResolverInterface $shippingMethodsResolver = null ) { $this->defaultShippingMethodResolver = $defaultShippingMethodResolver; $this->shipmentFactory = $shipmentFactory; + $this->shippingMethodsResolver = $shippingMethodsResolver; + + if (2 === func_num_args() || null === $shippingMethodsResolver) { + @trigger_error( + 'Not passing ShippingMethodsResolverInterface explicitly is deprecated since 1.2 and will be prohibited in 2.0', + E_USER_DEPRECATED + ); + } } /** @@ -85,7 +101,7 @@ public function process(BaseOrderInterface $order): void private function getOrderShipment(OrderInterface $order): ?ShipmentInterface { if ($order->hasShipments()) { - return $order->getShipments()->first(); + return $this->getExistingShipmentWithProperMethod($order); } try { @@ -101,4 +117,29 @@ private function getOrderShipment(OrderInterface $order): ?ShipmentInterface return null; } } + + /** + * @param OrderInterface $order + * + * @return ShipmentInterface|null + */ + private function getExistingShipmentWithProperMethod(OrderInterface $order): ?ShipmentInterface + { + /** @var ShipmentInterface $shipment */ + $shipment = $order->getShipments()->first(); + + if (null === $this->shippingMethodsResolver) { + return $shipment; + } + + if (!in_array($shipment->getMethod(), $this->shippingMethodsResolver->getSupportedMethods($shipment), true)) { + try { + $shipment->setMethod($this->defaultShippingMethodResolver->getDefaultShippingMethod($shipment)); + } catch (UnresolvedDefaultShippingMethodException $exception) { + return null; + } + } + + return $shipment; + } } diff --git a/src/Sylius/Component/Core/Resolver/DefaultShippingMethodResolver.php b/src/Sylius/Component/Core/Resolver/DefaultShippingMethodResolver.php index e2f0da66d47..6331ff2c4cb 100644 --- a/src/Sylius/Component/Core/Resolver/DefaultShippingMethodResolver.php +++ b/src/Sylius/Component/Core/Resolver/DefaultShippingMethodResolver.php @@ -13,6 +13,9 @@ namespace Sylius\Component\Core\Resolver; +use Sylius\Component\Addressing\Matcher\ZoneMatcherInterface; +use Sylius\Component\Addressing\Model\ZoneInterface; +use Sylius\Component\Core\Model\AddressInterface; use Sylius\Component\Core\Model\ChannelInterface; use Sylius\Component\Core\Model\ShipmentInterface as CoreShipmentInterface; use Sylius\Component\Core\Repository\ShippingMethodRepositoryInterface; @@ -29,12 +32,28 @@ class DefaultShippingMethodResolver implements DefaultShippingMethodResolverInte */ private $shippingMethodRepository; + /** + * @var ZoneMatcherInterface|null + */ + private $zoneMatcher; + /** * @param ShippingMethodRepositoryInterface $shippingMethodRepository + * @param ZoneMatcherInterface|null $zoneMatcher */ - public function __construct(ShippingMethodRepositoryInterface $shippingMethodRepository) - { + public function __construct( + ShippingMethodRepositoryInterface $shippingMethodRepository, + ?ZoneMatcherInterface $zoneMatcher = null + ) { $this->shippingMethodRepository = $shippingMethodRepository; + $this->zoneMatcher = $zoneMatcher; + + if (1 === func_num_args() || null === $zoneMatcher) { + @trigger_error( + 'Not passing ZoneMatcherInterface explicitly is deprecated since 1.2 and will be prohibited in 2.0', + E_USER_DEPRECATED + ); + } } /** @@ -45,14 +64,36 @@ public function getDefaultShippingMethod(ShipmentInterface $shipment): ShippingM /** @var CoreShipmentInterface $shipment */ Assert::isInstanceOf($shipment, CoreShipmentInterface::class); + $order = $shipment->getOrder(); + /** @var ChannelInterface $channel */ - $channel = $shipment->getOrder()->getChannel(); + $channel = $order->getChannel(); + /** @var AddressInterface $shippingAddress */ + $shippingAddress = $order->getShippingAddress(); - $shippingMethods = $this->shippingMethodRepository->findEnabledForChannel($channel); + $shippingMethods = $this->getShippingMethods($channel, $shippingAddress); if (empty($shippingMethods)) { throw new UnresolvedDefaultShippingMethodException(); } return $shippingMethods[0]; } + + /** + * @param ChannelInterface $channel + * @param AddressInterface|null $address + * + * @return array|ShippingMethodInterface[] + */ + private function getShippingMethods(ChannelInterface $channel, ?AddressInterface $address): array + { + if (null === $address || null === $this->zoneMatcher) { + return $this->shippingMethodRepository->findEnabledForChannel($channel); + } + + /** @var ZoneInterface[] $zones */ + $zones = $this->zoneMatcher->matchAll($address); + + return $this->shippingMethodRepository->findEnabledForZonesAndChannel($zones, $channel); + } } diff --git a/src/Sylius/Component/Core/spec/OrderProcessing/OrderShipmentProcessorSpec.php b/src/Sylius/Component/Core/spec/OrderProcessing/OrderShipmentProcessorSpec.php index 43d38cd4cca..6d895abc0b3 100644 --- a/src/Sylius/Component/Core/spec/OrderProcessing/OrderShipmentProcessorSpec.php +++ b/src/Sylius/Component/Core/spec/OrderProcessing/OrderShipmentProcessorSpec.php @@ -25,14 +25,16 @@ use Sylius\Component\Resource\Factory\FactoryInterface; use Sylius\Component\Shipping\Model\ShippingMethodInterface; use Sylius\Component\Shipping\Resolver\DefaultShippingMethodResolverInterface; +use Sylius\Component\Shipping\Resolver\ShippingMethodsResolverInterface; final class OrderShipmentProcessorSpec extends ObjectBehavior { function let( DefaultShippingMethodResolverInterface $defaultShippingMethodResolver, - FactoryInterface $shipmentFactory + FactoryInterface $shipmentFactory, + ShippingMethodsResolverInterface $shippingMethodsResolver ): void { - $this->beConstructedWith($defaultShippingMethodResolver, $shipmentFactory); + $this->beConstructedWith($defaultShippingMethodResolver, $shipmentFactory, $shippingMethodsResolver); } function it_is_an_order_processor(): void @@ -100,6 +102,44 @@ function it_removes_shipments_and_returns_null_when_shipping_is_not_required( } function it_adds_new_item_units_to_existing_shipment( + ShippingMethodsResolverInterface $shippingMethodsResolver, + OrderInterface $order, + ShipmentInterface $shipment, + Collection $shipments, + OrderItemUnitInterface $itemUnit, + OrderItemUnitInterface $itemUnitWithoutShipment, + OrderItemInterface $orderItem, + ProductVariantInterface $productVariant, + ShippingMethodInterface $shippingMethod + ): void { + $shipments->first()->willReturn($shipment); + + $shipment->getMethod()->willReturn($shippingMethod); + $shippingMethodsResolver->getSupportedMethods($shipment)->willReturn([$shippingMethod]); + + $orderItem->getVariant()->willReturn($productVariant); + + $order->isShippingRequired()->willReturn(true); + + $order->getItems()->willReturn(new ArrayCollection([$orderItem->getWrappedObject()])); + + $order->isEmpty()->willReturn(false); + $order->hasShipments()->willReturn(true); + $order->getItemUnits()->willReturn(new ArrayCollection([$itemUnit->getWrappedObject(), $itemUnitWithoutShipment->getWrappedObject()])); + $order->getShipments()->willReturn($shipments); + + $itemUnit->getShipment()->willReturn($shipment); + + $shipment->getUnits()->willReturn(new ArrayCollection([])); + $shipment->addUnit($itemUnitWithoutShipment)->shouldBeCalled(); + $shipment->addUnit($itemUnit)->shouldNotBeCalled(); + + $this->process($order); + } + + function it_adds_new_item_units_to_existing_shipment_without_checking_methods_if_shipping_methods_resolver_is_not_used( + DefaultShippingMethodResolverInterface $defaultShippingMethodResolver, + FactoryInterface $shipmentFactory, OrderInterface $order, ShipmentInterface $shipment, Collection $shipments, @@ -108,6 +148,8 @@ function it_adds_new_item_units_to_existing_shipment( OrderItemInterface $orderItem, ProductVariantInterface $productVariant ): void { + $this->beConstructedWith($defaultShippingMethodResolver, $shipmentFactory); + $shipments->first()->willReturn($shipment); $orderItem->getVariant()->willReturn($productVariant); @@ -131,14 +173,19 @@ function it_adds_new_item_units_to_existing_shipment( } function it_removes_units_before_adding_new_ones( + ShippingMethodsResolverInterface $shippingMethodsResolver, OrderInterface $order, ShipmentInterface $shipment, Collection $shipments, OrderItemUnitInterface $itemUnit, - OrderItemUnitInterface $itemUnitWithoutShipment + OrderItemUnitInterface $itemUnitWithoutShipment, + ShippingMethodInterface $shippingMethod ): void { $shipments->first()->willReturn($shipment); + $shipment->getMethod()->willReturn($shippingMethod); + $shippingMethodsResolver->getSupportedMethods($shipment)->willReturn([$shippingMethod]); + $order->isShippingRequired()->willReturn(true); $order->isEmpty()->willReturn(false); @@ -156,4 +203,45 @@ function it_removes_units_before_adding_new_ones( $this->process($order); } + + function it_adds_new_item_units_to_existing_shipment_and_replaces_its_method_if_its_ineligible( + DefaultShippingMethodResolverInterface $defaultShippingMethodResolver, + ShippingMethodsResolverInterface $shippingMethodsResolver, + OrderInterface $order, + ShipmentInterface $shipment, + Collection $shipments, + OrderItemUnitInterface $itemUnit, + OrderItemUnitInterface $itemUnitWithoutShipment, + OrderItemInterface $orderItem, + ProductVariantInterface $productVariant, + ShippingMethodInterface $firstShippingMethod, + ShippingMethodInterface $secondShippingMethod + ): void { + $shipments->first()->willReturn($shipment); + + $shipment->getMethod()->willReturn($firstShippingMethod); + $shippingMethodsResolver->getSupportedMethods($shipment)->willReturn([$secondShippingMethod]); + + $defaultShippingMethodResolver->getDefaultShippingMethod($shipment)->willReturn($secondShippingMethod); + $shipment->setMethod($secondShippingMethod)->shouldBeCalled(); + + $orderItem->getVariant()->willReturn($productVariant); + + $order->isShippingRequired()->willReturn(true); + + $order->getItems()->willReturn(new ArrayCollection([$orderItem->getWrappedObject()])); + + $order->isEmpty()->willReturn(false); + $order->hasShipments()->willReturn(true); + $order->getItemUnits()->willReturn(new ArrayCollection([$itemUnit->getWrappedObject(), $itemUnitWithoutShipment->getWrappedObject()])); + $order->getShipments()->willReturn($shipments); + + $itemUnit->getShipment()->willReturn($shipment); + + $shipment->getUnits()->willReturn(new ArrayCollection([])); + $shipment->addUnit($itemUnitWithoutShipment)->shouldBeCalled(); + $shipment->addUnit($itemUnit)->shouldNotBeCalled(); + + $this->process($order); + } } diff --git a/src/Sylius/Component/Core/spec/Resolver/DefaultShippingMethodResolverSpec.php b/src/Sylius/Component/Core/spec/Resolver/DefaultShippingMethodResolverSpec.php index 6ddc3d284f6..9e2915bab45 100644 --- a/src/Sylius/Component/Core/spec/Resolver/DefaultShippingMethodResolverSpec.php +++ b/src/Sylius/Component/Core/spec/Resolver/DefaultShippingMethodResolverSpec.php @@ -14,6 +14,10 @@ namespace spec\Sylius\Component\Core\Resolver; use PhpSpec\ObjectBehavior; +use Prophecy\Argument; +use Sylius\Component\Addressing\Matcher\ZoneMatcherInterface; +use Sylius\Component\Addressing\Model\ZoneInterface; +use Sylius\Component\Core\Model\AddressInterface; use Sylius\Component\Core\Model\ChannelInterface; use Sylius\Component\Core\Model\OrderInterface; use Sylius\Component\Core\Model\ShipmentInterface; @@ -25,9 +29,11 @@ final class DefaultShippingMethodResolverSpec extends ObjectBehavior { - function let(ShippingMethodRepositoryInterface $shippingMethodRepository): void - { - $this->beConstructedWith($shippingMethodRepository); + function let( + ShippingMethodRepositoryInterface $shippingMethodRepository, + ZoneMatcherInterface $zoneMatcher + ): void { + $this->beConstructedWith($shippingMethodRepository, $zoneMatcher); } function it_implements_a_default_shipping_method_resolver_interface(): void @@ -35,7 +41,32 @@ function it_implements_a_default_shipping_method_resolver_interface(): void $this->shouldImplement(DefaultShippingMethodResolverInterface::class); } - function it_returns_first_enabled_shipping_method_from_shipment_order_channel_as_default( + function it_returns_first_enabled_shipping_method_from_shipment_order_channel_if_there_is_not_shipping_address( + ChannelInterface $channel, + OrderInterface $order, + ShipmentInterface $shipment, + ShippingMethodInterface $firstShippingMethod, + ShippingMethodInterface $secondShippingMethod, + ShippingMethodRepositoryInterface $shippingMethodRepository, + ZoneMatcherInterface $zoneMatcher + ): void { + $shipment->getOrder()->willReturn($order); + + $order->getChannel()->willReturn($channel); + $order->getShippingAddress()->willReturn(null); + + $zoneMatcher->matchAll(Argument::any())->shouldNotBeCalled(); + + $shippingMethodRepository + ->findEnabledForChannel($channel) + ->willReturn([$firstShippingMethod, $secondShippingMethod]) + ; + + $this->getDefaultShippingMethod($shipment)->shouldReturn($firstShippingMethod); + } + + function it_returns_first_enabled_shipping_method_from_shipment_order_channel_if_zone_matcher_is_not_used( + AddressInterface $shippingAddress, ChannelInterface $channel, OrderInterface $order, ShipmentInterface $shipment, @@ -43,8 +74,12 @@ function it_returns_first_enabled_shipping_method_from_shipment_order_channel_as ShippingMethodInterface $secondShippingMethod, ShippingMethodRepositoryInterface $shippingMethodRepository ): void { + $this->beConstructedWith($shippingMethodRepository); + $shipment->getOrder()->willReturn($order); + $order->getChannel()->willReturn($channel); + $order->getShippingAddress()->willReturn($shippingAddress); $shippingMethodRepository ->findEnabledForChannel($channel) @@ -54,17 +89,54 @@ function it_returns_first_enabled_shipping_method_from_shipment_order_channel_as $this->getDefaultShippingMethod($shipment)->shouldReturn($firstShippingMethod); } - function it_throws_an_exception_if_there_is_no_enabled_shipping_methods( - ShippingMethodRepositoryInterface $shippingMethodRepository, + function it_returns_first_enabled_shipping_method_matched_by_order_channel_and_shipping_address_zone( + AddressInterface $shippingAddress, + ChannelInterface $channel, + OrderInterface $order, ShipmentInterface $shipment, + ShippingMethodInterface $firstShippingMethod, + ShippingMethodInterface $secondShippingMethod, + ShippingMethodRepositoryInterface $shippingMethodRepository, + ZoneInterface $firstZone, + ZoneInterface $secondZone, + ZoneMatcherInterface $zoneMatcher + ): void { + $shipment->getOrder()->willReturn($order); + + $order->getChannel()->willReturn($channel); + $order->getShippingAddress()->willReturn($shippingAddress); + + $zoneMatcher->matchAll($shippingAddress)->willReturn([$firstZone, $secondZone]); + + $shippingMethodRepository + ->findEnabledForZonesAndChannel([$firstZone, $secondZone], $channel) + ->willReturn([$firstShippingMethod, $secondShippingMethod]) + ; + + $this->getDefaultShippingMethod($shipment)->shouldReturn($firstShippingMethod); + } + + function it_throws_an_exception_if_there_is_no_enabled_shipping_methods_for_order_channel_and_zones( + AddressInterface $shippingAddress, ChannelInterface $channel, - OrderInterface $order + OrderInterface $order, + ShipmentInterface $shipment, + ShippingMethodRepositoryInterface $shippingMethodRepository, + ZoneInterface $firstZone, + ZoneInterface $secondZone, + ZoneMatcherInterface $zoneMatcher ): void { $shipment->getOrder()->willReturn($order); + $order->getChannel()->willReturn($channel); + $order->getShippingAddress()->willReturn($shippingAddress); + + $zoneMatcher->matchAll($shippingAddress)->willReturn([$firstZone, $secondZone]); $shippingMethodRepository - ->findEnabledForChannel($channel)->willReturn([]); + ->findEnabledForZonesAndChannel([$firstZone, $secondZone], $channel) + ->willReturn([]) + ; $this ->shouldThrow(UnresolvedDefaultShippingMethodException::class)