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