1<?php
2/**
3 * Copyright since 2007 PrestaShop SA and Contributors
4 * PrestaShop is an International Registered Trademark & Property of PrestaShop SA
5 *
6 * NOTICE OF LICENSE
7 *
8 * This source file is subject to the Open Software License (OSL 3.0)
9 * that is bundled with this package in the file LICENSE.md.
10 * It is also available through the world-wide-web at this URL:
11 * https://opensource.org/licenses/OSL-3.0
12 * If you did not receive a copy of the license and are unable to
13 * obtain it through the world-wide-web, please send an email
14 * to license@prestashop.com so we can send you a copy immediately.
15 *
16 * DISCLAIMER
17 *
18 * Do not edit or add to this file if you wish to upgrade PrestaShop to newer
19 * versions in the future. If you wish to customize PrestaShop for your
20 * needs please refer to https://devdocs.prestashop.com/ for more information.
21 *
22 * @author    PrestaShop SA and Contributors <contact@prestashop.com>
23 * @copyright Since 2007 PrestaShop SA and Contributors
24 * @license   https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
25 */
26
27declare(strict_types=1);
28
29namespace PrestaShop\PrestaShop\Adapter\Order;
30
31use Cart;
32use Configuration;
33use Context;
34use Currency;
35use Customer;
36use Customization;
37use Db;
38use Order;
39use OrderDetail;
40use OrderInvoice;
41use Pack;
42use PrestaShop\PrestaShop\Adapter\Cart\Comparator\CartProductsComparator;
43use PrestaShop\PrestaShop\Adapter\Cart\Comparator\CartProductUpdate;
44use PrestaShop\PrestaShop\Adapter\ContextStateManager;
45use PrestaShop\PrestaShop\Adapter\Order\Refund\OrderProductRemover;
46use PrestaShop\PrestaShop\Adapter\StockManager;
47use PrestaShop\PrestaShop\Core\Domain\Order\Exception\OrderException;
48use PrestaShop\PrestaShop\Core\Domain\Product\Exception\ProductOutOfStockException;
49use Product;
50use Shop;
51use StockAvailable;
52use StockManagerFactory;
53use StockMvt;
54use Warehouse;
55
56/**
57 * Increase or decrease quantity of an order's product.
58 * Recalculate cart rules, order's prices and shipping infos.
59 */
60class OrderProductQuantityUpdater
61{
62    /**
63     * @var OrderAmountUpdater
64     */
65    private $orderAmountUpdater;
66
67    /**
68     * @var ContextStateManager
69     */
70    private $contextStateManager;
71
72    /**
73     * @var OrderProductRemover
74     */
75    private $orderProductRemover;
76
77    public function __construct(
78        OrderAmountUpdater $orderAmountUpdater,
79        OrderProductRemover $orderProductRemover,
80        ContextStateManager $contextStateManager
81    ) {
82        $this->orderAmountUpdater = $orderAmountUpdater;
83        $this->orderProductRemover = $orderProductRemover;
84        $this->contextStateManager = $contextStateManager;
85    }
86
87    /**
88     * @param Order $order
89     * @param OrderDetail $orderDetail
90     * @param int $newQuantity
91     * @param OrderInvoice|null $orderInvoice
92     * @param bool $updateCart Used when you don't want to update the cart (CartRule removal for example)
93     *
94     * @return Order
95     *
96     * @throws OrderException
97     * @throws \PrestaShopDatabaseException
98     * @throws \PrestaShopException
99     */
100    public function update(
101        Order $order,
102        OrderDetail $orderDetail,
103        int $newQuantity,
104        ?OrderInvoice $orderInvoice,
105        bool $updateCart = true
106    ): Order {
107        $cart = new Cart($order->id_cart);
108
109        $this->contextStateManager
110            ->saveCurrentContext()
111            ->setCart($cart)
112            ->setCurrency(new Currency($cart->id_currency))
113            ->setCustomer(new Customer($cart->id_customer))
114            ->setLanguage($cart->getAssociatedLanguage())
115            ->setCountry($cart->getTaxCountry())
116            ->setShop(new Shop($cart->id_shop))
117        ;
118
119        try {
120            $this->updateOrderDetail($order, $cart, $orderDetail, $newQuantity, $orderInvoice, $updateCart);
121
122            // Update prices on the order after cart rules are recomputed
123            $this->orderAmountUpdater->update($order, $cart, null !== $orderInvoice ? (int) $orderInvoice->id : null);
124        } finally {
125            $this->contextStateManager->restorePreviousContext();
126        }
127
128        return $order;
129    }
130
131    /**
132     * @param Order $order
133     * @param Cart $cart
134     * @param OrderDetail $orderDetail
135     * @param int $newQuantity
136     * @param OrderInvoice|null $orderInvoice
137     * @param bool $updateCart
138     *
139     * @throws OrderException
140     * @throws ProductOutOfStockException
141     * @throws \PrestaShopDatabaseException
142     * @throws \PrestaShopException
143     */
144    private function updateOrderDetail(
145        Order $order,
146        Cart $cart,
147        OrderDetail $orderDetail,
148        int $newQuantity,
149        ?OrderInvoice $orderInvoice,
150        bool $updateCart
151    ): void {
152        $oldQuantity = (int) $orderDetail->product_quantity;
153
154        // Perform deletion first, we don't want the OrderDetail to be saved with a quantity 0, this could lead to bugs
155        if (0 === $newQuantity) {
156            // Product deletion
157            $cartComparator = $this->orderProductRemover->deleteProductFromOrder($order, $orderDetail, $updateCart);
158            $this->updateCustomizationOnProductDelete($order, $orderDetail, $oldQuantity);
159            $this->applyOtherProductUpdates($order, $cart, $orderInvoice, $cartComparator->getUpdatedProducts());
160            $this->applyOtherProductCreation($order, $cart, $orderInvoice, $cartComparator->getAdditionalProducts());
161        } else {
162            $this->assertValidProductQuantity($orderDetail, $newQuantity);
163            // It's important to override the invoice, this is what allows to switch an OrderDetail from an invoice to another
164            if (null !== $orderInvoice) {
165                $orderDetail->id_order_invoice = $orderInvoice->id;
166            }
167
168            $orderDetail->product_quantity = $newQuantity;
169            $orderDetail->reduction_percent = 0;
170            $orderDetail->update();
171
172            // Update quantity on the cart and stock
173            if ($updateCart) {
174                $cartComparator = $this->updateProductQuantity($cart, $orderDetail, $oldQuantity, $newQuantity);
175                $this->applyOtherProductUpdates($order, $cart, $orderInvoice, $cartComparator->getUpdatedProducts());
176                $this->applyOtherProductCreation($order, $cart, $orderInvoice, $cartComparator->getAdditionalProducts());
177            } elseif ($orderDetail->id_customization > 0) {
178                $customization = new Customization($orderDetail->id_customization);
179                $customization->quantity = $newQuantity;
180                $customization->save();
181            }
182        }
183
184        // Update product stocks
185        $this->updateStocks($cart, $orderDetail, $oldQuantity, $newQuantity);
186    }
187
188    /**
189     * @param Order $order
190     * @param Cart $cart
191     * @param OrderInvoice|null $orderInvoice
192     * @param CartProductUpdate[] $updatedProducts
193     */
194    private function applyOtherProductUpdates(
195        Order $order,
196        Cart $cart,
197        ?OrderInvoice $orderInvoice,
198        array $updatedProducts
199    ): void {
200        // Some products have been affected by the removal of the initial product (probably related to a CartRule)
201        // So we detect the changes that happened in the cart and apply them on the OrderDetail
202        $orderDetails = $order->getOrderDetailList();
203        foreach ($updatedProducts as $updatedProduct) {
204            $updatedCombinationId = $updatedProduct->getCombinationId() !== null
205                ? $updatedProduct->getCombinationId()->getValue()
206                : 0;
207            $updatedOrderDetail = null;
208            foreach ($orderDetails as $orderDetailData) {
209                if ((int) $orderDetailData['product_id'] === $updatedProduct->getProductId()->getValue()
210                    && (int) $orderDetailData['product_attribute_id'] === $updatedCombinationId) {
211                    $updatedOrderDetail = new OrderDetail($orderDetailData['id_order_detail']);
212                    break;
213                }
214            }
215
216            if (null !== $updatedOrderDetail) {
217                $newUpdatedQuantity = (int) $updatedOrderDetail->product_quantity + $updatedProduct->getDeltaQuantity();
218                // Important: we update the OrderDetail but not the cart (it is already updated) to avoid infinite loop
219                $this->updateOrderDetail(
220                    $order,
221                    $cart,
222                    $updatedOrderDetail,
223                    $newUpdatedQuantity,
224                    $orderInvoice,
225                    false
226                );
227            }
228        }
229    }
230
231    /**
232     * @param Order $order
233     * @param Cart $cart
234     * @param OrderInvoice|null $orderInvoice
235     * @param array $createdProducts
236     */
237    private function applyOtherProductCreation(
238        Order $order,
239        Cart $cart,
240        ?OrderInvoice $orderInvoice,
241        array $createdProducts
242    ): void {
243        $productsToAdd = [];
244        foreach ($createdProducts as $createdProduct) {
245            $updatedCombinationId = $createdProduct->getCombinationId() !== null
246                ? $createdProduct->getCombinationId()->getValue()
247                : 0;
248            foreach ($cart->getProducts() as $product) {
249                if ((int) $product['id_product'] === $createdProduct->getProductId()->getValue()
250                    && (int) $product['id_product_attribute'] === $updatedCombinationId) {
251                    $productsToAdd[] = $product;
252                    break;
253                }
254            }
255        }
256        if (count($productsToAdd) > 0) {
257            $orderDetail = new OrderDetail();
258            $orderDetail->createList(
259                $order,
260                $cart,
261                $order->getCurrentState(),
262                $productsToAdd,
263                $orderInvoice ? $orderInvoice->id : 0
264            );
265        }
266    }
267
268    /**
269     * @param Cart $cart
270     * @param OrderDetail $orderDetail
271     * @param int $oldQuantity
272     * @param int $newQuantity
273     *
274     * @return CartProductsComparator
275     */
276    private function updateProductQuantity(
277        Cart $cart,
278        OrderDetail $orderDetail,
279        int $oldQuantity,
280        int $newQuantity
281    ): CartProductsComparator {
282        $cartComparator = new CartProductsComparator($cart);
283
284        $deltaQuantity = $newQuantity - $oldQuantity;
285        if (0 === $deltaQuantity) {
286            return $cartComparator;
287        }
288
289        $knownUpdates = [
290            new CartProductUpdate(
291                (int) $orderDetail->product_id,
292                (int) $orderDetail->product_attribute_id,
293                $deltaQuantity,
294                false,
295                (int) $orderDetail->id_customization
296            ),
297        ];
298        $cartComparator->setKnownUpdates($knownUpdates);
299
300        /**
301         * Here we update product and customization in the cart.
302         *
303         * The last argument "skip quantity check" is set to true because
304         * 1) the quantity has already been checked,
305         * 2) (main reason) when the cart checks the availability ; it substracts
306         * its own quantity from available stock.
307         *
308         * This is because a product in a cart is not really out of the stock, because it is not checked out yet.
309         *
310         * Here we are editing an order, not a cart, so what has been ordered
311         * has already been substracted from the stock.
312         */
313        $updateQuantityResult = $cart->updateQty(
314            abs($deltaQuantity),
315            $orderDetail->product_id,
316            $orderDetail->product_attribute_id,
317            $orderDetail->id_customization,
318            $deltaQuantity < 0 ? 'down' : 'up',
319            0,
320            new Shop($cart->id_shop),
321            true,
322            true
323        );
324
325        if (-1 === $updateQuantityResult) {
326            throw new \LogicException('Minimum quantity is not respected');
327        } elseif (true !== $updateQuantityResult) {
328            throw new \LogicException('Something went wrong');
329        }
330
331        return $cartComparator;
332    }
333
334    /**
335     * @param Cart $cart
336     * @param OrderDetail $orderDetail
337     * @param int $oldQuantity
338     * @param int $newQuantity
339     *
340     * @throws OrderException
341     * @throws \PrestaShopDatabaseException
342     * @throws \PrestaShopException
343     */
344    private function updateStocks(Cart $cart, OrderDetail $orderDetail, int $oldQuantity, int $newQuantity): void
345    {
346        $deltaQuantity = $oldQuantity - $newQuantity;
347
348        if (0 === $deltaQuantity) {
349            return;
350        }
351
352        if (0 === $newQuantity) {
353            // Product deletion. Reinject quantity in stock
354            $this->reinjectQuantity($orderDetail, $oldQuantity, $newQuantity, true);
355        } elseif ($deltaQuantity > 0) {
356            // Increase product quantity
357            StockAvailable::updateQuantity(
358                $orderDetail->product_id,
359                $orderDetail->product_attribute_id,
360                $deltaQuantity,
361                $cart->id_shop,
362                true,
363                [
364                    'id_order' => $orderDetail->id_order,
365                    'id_stock_mvt_reason' => Configuration::get('PS_STOCK_CUSTOMER_RETURN_REASON'),
366                ]
367            );
368        } else {
369            // Decrease product quantity. Reinject quantity in stock
370            $this->reinjectQuantity($orderDetail, $oldQuantity, $newQuantity, false);
371        }
372    }
373
374    /**
375     * @param OrderDetail $orderDetail
376     * @param int $oldQuantity
377     * @param int $newQuantity
378     * @param bool $delete
379     *
380     * @throws OrderException
381     * @throws \PrestaShopDatabaseException
382     * @throws \PrestaShopException
383     */
384    protected function reinjectQuantity(
385        OrderDetail $orderDetail,
386        int $oldQuantity,
387        int $newQuantity,
388        $delete = false
389    ) {
390        // Reinject product
391        $reinjectableQuantity = $oldQuantity - $newQuantity;
392        $quantityToReinject = $oldQuantity > $reinjectableQuantity ? $reinjectableQuantity : $oldQuantity;
393
394        $product = new Product(
395            $orderDetail->product_id,
396            false,
397            (int) Context::getContext()->language->id,
398            (int) $orderDetail->id_shop
399        );
400
401        if (Configuration::get('PS_ADVANCED_STOCK_MANAGEMENT')
402            && $product->advanced_stock_management
403            && $orderDetail->id_warehouse != 0
404        ) {
405            $manager = StockManagerFactory::getManager();
406            $movements = StockMvt::getNegativeStockMvts(
407                $orderDetail->id_order,
408                $orderDetail->product_id,
409                $orderDetail->product_attribute_id,
410                $quantityToReinject
411            );
412
413            foreach ($movements as $movement) {
414                if ($quantityToReinject > $movement['physical_quantity']) {
415                    $quantityToReinject = $movement['physical_quantity'];
416                }
417
418                if (Pack::isPack((int) $product->id)) {
419                    // Gets items
420                    if ($product->pack_stock_type == Pack::STOCK_TYPE_PRODUCTS_ONLY
421                        || $product->pack_stock_type == Pack::STOCK_TYPE_PACK_BOTH
422                        || ($product->pack_stock_type == Pack::STOCK_TYPE_DEFAULT
423                            && Configuration::get('PS_PACK_STOCK_TYPE') > 0)
424                    ) {
425                        $products_pack = Pack::getItems((int) $product->id, (int) Configuration::get('PS_LANG_DEFAULT'));
426                        // Foreach item
427                        foreach ($products_pack as $product_pack) {
428                            if ($product_pack->advanced_stock_management == 1) {
429                                $manager->addProduct(
430                                    $product_pack->id,
431                                    $product_pack->id_pack_product_attribute,
432                                    new Warehouse($movement['id_warehouse']),
433                                    $product_pack->pack_quantity * $quantityToReinject,
434                                    null,
435                                    $movement['price_te']
436                                );
437                            }
438                        }
439                    }
440
441                    if ($product->pack_stock_type == Pack::STOCK_TYPE_PACK_ONLY
442                        || $product->pack_stock_type == Pack::STOCK_TYPE_PACK_BOTH
443                        || (
444                            $product->pack_stock_type == Pack::STOCK_TYPE_DEFAULT
445                            && (Configuration::get('PS_PACK_STOCK_TYPE') == Pack::STOCK_TYPE_PACK_ONLY
446                                || Configuration::get('PS_PACK_STOCK_TYPE') == Pack::STOCK_TYPE_PACK_BOTH)
447                        )
448                    ) {
449                        $manager->addProduct(
450                            $orderDetail->product_id,
451                            $orderDetail->product_attribute_id,
452                            new Warehouse($movement['id_warehouse']),
453                            $quantityToReinject,
454                            null,
455                            $movement['price_te']
456                        );
457                    }
458                } else {
459                    $manager->addProduct(
460                        $orderDetail->product_id,
461                        $orderDetail->product_attribute_id,
462                        new Warehouse($movement['id_warehouse']),
463                        $quantityToReinject,
464                        null,
465                        $movement['price_te']
466                    );
467                }
468            }
469
470            $productId = $orderDetail->product_id;
471
472            if ($delete) {
473                $orderDetail->delete();
474            }
475
476            StockAvailable::synchronize($productId);
477        } elseif ($orderDetail->id_warehouse == 0) {
478            StockAvailable::updateQuantity(
479                $orderDetail->product_id,
480                $orderDetail->product_attribute_id,
481                $quantityToReinject,
482                $orderDetail->id_shop,
483                true,
484                [
485                    'id_order' => $orderDetail->id_order,
486                    'id_stock_mvt_reason' => Configuration::get('PS_STOCK_CUSTOMER_RETURN_REASON'),
487                ]
488            );
489
490            // sync all stock
491            (new StockManager())->updatePhysicalProductQuantity(
492                (int) $orderDetail->id_shop,
493                (int) Configuration::get('PS_OS_ERROR'),
494                (int) Configuration::get('PS_OS_CANCELED'),
495                null,
496                (int) $orderDetail->id_order
497            );
498
499            if ($delete) {
500                $orderDetail->delete();
501            }
502        } else {
503            throw new OrderException('This product cannot be re-stocked.');
504        }
505    }
506
507    /**
508     * @param Order $order
509     * @param OrderDetail $orderDetail
510     * @param int $oldQuantity
511     *
512     * @throws OrderException
513     */
514    private function updateCustomizationOnProductDelete(Order $order, OrderDetail $orderDetail, int $oldQuantity): void
515    {
516        if (!(int) $order->getCurrentState()) {
517            throw new OrderException('Could not get a valid Order state before deletion');
518        }
519
520        if ($order->hasBeenPaid()) {
521            Db::getInstance()->execute('UPDATE `' . _DB_PREFIX_ . 'customization` SET `quantity_refunded` = `quantity_refunded` + ' . (int) $oldQuantity . ' WHERE `id_customization` = ' . (int) $orderDetail->id_customization . ' AND `id_cart` = ' . (int) $order->id_cart . ' AND `id_product` = ' . (int) $orderDetail->product_id);
522        }
523
524        if (!Db::getInstance()->execute('DELETE FROM `' . _DB_PREFIX_ . 'customization` WHERE `quantity` = 0')) {
525            throw new OrderException('Could not delete customization from database.');
526        }
527    }
528
529    /**
530     * @param OrderDetail $orderDetail
531     * @param int $newQuantity
532     *
533     * @throws ProductOutOfStockException
534     */
535    private function assertValidProductQuantity(OrderDetail $orderDetail, int $newQuantity)
536    {
537        //check if product is available in stock
538        if (!Product::isAvailableWhenOutOfStock(StockAvailable::outOfStock($orderDetail->product_id))) {
539            $availableQuantity = StockAvailable::getQuantityAvailableByProduct(
540                $orderDetail->product_id,
541                $orderDetail->product_attribute_id,
542                $orderDetail->id_shop
543            );
544            $quantityDiff = $newQuantity - (int) $orderDetail->product_quantity;
545
546            if ($quantityDiff > $availableQuantity) {
547                throw new ProductOutOfStockException('Not enough products in stock');
548            }
549        }
550    }
551}
552