diff --git a/app/code/Magento/Sales/Model/Order/Item.php b/app/code/Magento/Sales/Model/Order/Item.php index bc55b2229770d..120566c9fb659 100644 --- a/app/code/Magento/Sales/Model/Order/Item.php +++ b/app/code/Magento/Sales/Model/Order/Item.php @@ -41,6 +41,8 @@ class Item extends AbstractModel implements OrderItemInterface // When qty ordered = qty canceled const STATUS_PARTIAL = 6; + const ZERO_AMOUNT = 0.0; + // If [qty shipped or(max of two) qty invoiced + qty canceled + qty returned] // < qty ordered const STATUS_MIXED = 7; @@ -232,7 +234,21 @@ public function getQtyToShip() */ public function getSimpleQtyToShip() { - $qty = $this->getQtyOrdered() - max($this->getQtyShipped(), $this->getQtyRefunded()) - $this->getQtyCanceled(); + if ($this->getIsVirtual() /*|| $this->getQtyInvoiced() == 0*/) { + return self::ZERO_AMOUNT; + } + + if ($this->getQtyShipped() == $this->getQtyOrdered()) { + return self::ZERO_AMOUNT; + } + + $qty = $this->getQtyOrdered()/*max($this->getQtyOrdered(), $this->getQtyInvoiced())*/ - $this->getQtyShipped() - $this->getQtyCanceled(); // standard flow + + // we have to ship only the items that are not refunded and not shipped + if ($this->getQtyRefunded() > $this->getQtyShipped()) { + $qty -= $this->getQtyRefunded() - $this->getQtyShipped(); + } + return max(round($qty, 8), 0); } diff --git a/app/code/Magento/Sales/Model/ResourceModel/Order/Handler/State.php b/app/code/Magento/Sales/Model/ResourceModel/Order/Handler/State.php index 51c45ed5e5a0d..7bf02aae57b66 100644 --- a/app/code/Magento/Sales/Model/ResourceModel/Order/Handler/State.php +++ b/app/code/Magento/Sales/Model/ResourceModel/Order/Handler/State.php @@ -6,6 +6,7 @@ namespace Magento\Sales\Model\ResourceModel\Order\Handler; +use Magento\Catalog\Model\Product\Type; use Magento\Sales\Model\Order; /** @@ -60,13 +61,30 @@ public function isPartiallyRefundedOrderShipped(Order $order): bool { $isPartiallyRefundedOrderShipped = false; if ($this->getShippedItems($order) > 0 - && $order->getTotalQtyOrdered() <= $this->getRefundedItems($order) + $this->getShippedItems($order)) { + && $this->getQtyItemsToShip($order) <= $this->getRefundedItems($order) + $this->getShippedItems($order)) { $isPartiallyRefundedOrderShipped = true; } return $isPartiallyRefundedOrderShipped; } + /** + * Get all refunded items number + * + * @param Order $order + * @return int + */ + private function getQtyItemsToShip(Order $order): int + { + $numOfItemsToShip = 0; + foreach ($order->getAllItems() as $item) { + if ($item->getProductType() == Type::TYPE_SIMPLE) { // only simple products are accountable for the order qty + $numOfItemsToShip += (int)$item->getQtyOrdered(); + } + } + return $numOfItemsToShip; + } + /** * Get all refunded items number * diff --git a/app/code/Magento/Sales/Test/Unit/Model/Order/ItemTest.php b/app/code/Magento/Sales/Test/Unit/Model/Order/ItemTest.php index 5ccd06db27c5e..03fd6f47a3cb8 100644 --- a/app/code/Magento/Sales/Test/Unit/Model/Order/ItemTest.php +++ b/app/code/Magento/Sales/Test/Unit/Model/Order/ItemTest.php @@ -323,6 +323,13 @@ public function getItemQtyVariants() ], 'expectedResult' => ['to_ship' => 7.0, 'to_invoice' => 0.0] ], + 'partially_refunded_partially_shipped_refund_lower_than_ship' => [ + 'options' => [ + 'qty_ordered' => 12, 'qty_invoiced' => 12, 'qty_refunded' => 2, 'qty_shipped' => 4, + 'qty_canceled' => 0 + ], + 'expectedResult' => ['to_ship' => 8.0, 'to_invoice' => 0.0] + ], 'complete' => [ 'options' => [ 'qty_ordered' => 12, 'qty_invoiced' => 12, 'qty_refunded' => 0, 'qty_shipped' => 12, @@ -350,7 +357,8 @@ public function getItemQtyVariants() 'qty_canceled' => 0.4 ], 'expectedResult' => ['to_ship' => 0.0, 'to_invoice' => 0.0] - ] + ], + ]; } diff --git a/dev/tests/integration/testsuite/Magento/Sales/Model/OrderTest.php b/dev/tests/integration/testsuite/Magento/Sales/Model/OrderTest.php index 8952bde98e385..e4a1accc0e258 100644 --- a/dev/tests/integration/testsuite/Magento/Sales/Model/OrderTest.php +++ b/dev/tests/integration/testsuite/Magento/Sales/Model/OrderTest.php @@ -8,6 +8,7 @@ namespace Magento\Sales\Model; use Magento\Catalog\Test\Fixture\Product as ProductFixture; +use Magento\Catalog\Test\Fixture\Virtual as ProductVirtualFixture; use Magento\Checkout\Test\Fixture\PlaceOrder as PlaceOrderFixture; use Magento\Checkout\Test\Fixture\SetBillingAddress as SetBillingAddressFixture; use Magento\Checkout\Test\Fixture\SetDeliveryMethod as SetDeliveryMethodFixture; @@ -18,6 +19,7 @@ use Magento\Quote\Test\Fixture\GuestCart as GuestCartFixture; use Magento\Sales\Test\Fixture\Creditmemo as CreditmemoFixture; use Magento\Sales\Test\Fixture\Invoice as InvoiceFixture; +use Magento\Sales\Test\Fixture\Shipment as ShipmentFixture; use Magento\SalesRule\Model\Rule; use Magento\SalesRule\Test\Fixture\Rule as RuleFixture; use Magento\TestFramework\Fixture\Config as Config; @@ -90,4 +92,66 @@ public function testMultipleCreditmemosForZeroTotalOrder() 'Should be possible to create second credit memo for zero total order if not all items are refunded yet' ); } + + /** + * Tests that an order with mixed product types in cart and with physical items either shipped or refunded cannot be shipped + */ + #[ + Config('carriers/freeshipping/active', '1', 'store', 'default'), + Config('payment/free/active', '1', 'store', 'default'), + DataFixture(ProductFixture::class, as: 'product'), + DataFixture(ProductVirtualFixture::class, as: 'virtual'), + DataFixture(GuestCartFixture::class, as: 'cart'), + DataFixture( + RuleFixture::class, + [ + 'simple_action' => Rule::BY_PERCENT_ACTION, + 'discount_amount' => 100, + 'apply_to_shipping' => 0, + 'stop_rules_processing' => 0, + 'sort_order' => 1, + ] + ), + DataFixture( + AddProductToCartFixture::class, + ['cart_id' => '$cart.id$', 'product_id' => '$product.id$', 'qty' => 2] + ), + DataFixture( + AddProductToCartFixture::class, + ['cart_id' => '$cart.id$', 'product_id' => '$virtual.id$', 'qty' => 2] + ), + DataFixture(SetBillingAddressFixture::class, ['cart_id' => '$cart.id$']), + DataFixture(SetShippingAddressFixture::class, ['cart_id' => '$cart.id$']), + DataFixture(SetGuestEmailFixture::class, ['cart_id' => '$cart.id$']), + DataFixture( + SetDeliveryMethodFixture::class, + ['cart_id' => '$cart.id$', 'carrier_code' => 'freeshipping', 'method_code' => 'freeshipping'] + ), + DataFixture(SetPaymentMethodFixture::class, ['cart_id' => '$cart.id$', 'method' => 'free']), + DataFixture(PlaceOrderFixture::class, ['cart_id' => '$cart.id$'], 'order'), + DataFixture(InvoiceFixture::class, ['order_id' => '$order.id$'], 'invoice'), + DataFixture( + CreditmemoFixture::class, + ['order_id' => '$order.id$', 'items' => [['qty' => 1, 'product_id' => '$product.id$']]], + 'creditmemo' + ), + DataFixture( + ShipmentFixture::class, + ['order_id' => '$order.id$', 'items' => [['qty' => 1, 'product_id' => '$product.id$']]], + 'shipment' + ) + ] + public function testOrderWithPartialShipmentAndPartialRefundAndMixedCartItems() + { + $order = $this->fixtures->get('order'); + $this->assertFalse( + $order->canShip(), + 'All items are shipped or refunded or virtual' + ); + $this->assertEquals( + Order::STATE_COMPLETE, + $order->getStatus() + ); + } } +