Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 43 additions & 16 deletions src/base/Purchasable.php
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,14 @@ abstract class Purchasable extends Element implements PurchasableInterface, HasS
*/
private ?int $_stock = null;

/**
* This is the cached total available stock across all inventory locations for specific orders.
*
* @var int[]
* @since 5.6.2
*/
private array $_stockForOrders = [];

/**
* @inheritdoc
*/
Expand Down Expand Up @@ -689,11 +697,12 @@ public function setSku(string $sku = null): void
}

/**
* @param Order|null $order The order the stock is being checked for.
* Returns whether this variant has stock.
*/
public function hasStock(): bool
public function hasStock(Order|null $order = null): bool
{
return !$this->inventoryTracked || $this->getStock() > 0;
return !$this->inventoryTracked || $this->getStock($order) > 0;
}

/**
Expand Down Expand Up @@ -793,14 +802,16 @@ public function populateLineItem(LineItem $lineItem): void
{
// Since we do not have a proper stock reservation system, we need deduct stock if they have more in the cart than is available, and to do this quietly.
// If this occurs in the payment request, the user will be notified the order has changed.
if (($order = $lineItem->getOrder()) && !$order->isCompleted) {
if (($order = $lineItem->getOrder()) && !$order->isCompleted)
{
$order = $lineItem->getOrder();
if ($this::hasInventory() &&
!$this->getIsOutOfStockPurchasingAllowed() &&
$this->inventoryTracked &&
($lineItem->qty > $this->getStock()) &&
$this->getStock() > 0
($lineItem->qty > $this->getStock($order)) &&
$this->getStock($order) > 0
) {
$message = Craft::t('commerce', '{description} only has {stock} in stock.', ['description' => $lineItem->getDescription(), 'stock' => $this->getStock()]);
$message = Craft::t('commerce', '{description} only has {stock} in stock.', ['description' => $lineItem->getDescription(), 'stock' => $this->getStock($order)]);
/** @var OrderNotice $notice */
$notice = Craft::createObject([
'class' => OrderNotice::class,
Expand All @@ -811,7 +822,7 @@ public function populateLineItem(LineItem $lineItem): void
],
]);
$order->addNotice($notice);
$lineItem->qty = $this->getStock();
$lineItem->qty = $this->getStock($order);
}
}

Expand Down Expand Up @@ -867,7 +878,9 @@ function($attribute, $params, Validator $validator) use ($lineItem, $lineItemQua
return;
}

if (!$this->hasStock()) {
$order = $lineItem->getOrder();

if (!$this->hasStock($order)) {
if (!Plugin::getInstance()->getPurchasables()->isPurchasableOutOfStockPurchasingAllowed($lineItemPurchasable, $lineItem->getOrder())) {
$error = Craft::t('commerce', '“{description}” is currently out of stock.', ['description' => $lineItemPurchasable->getDescription()]);
$validator->addError($lineItem, $attribute, $error);
Expand All @@ -876,9 +889,9 @@ function($attribute, $params, Validator $validator) use ($lineItem, $lineItemQua

$lineItemQty = $lineItem->purchasableId ? $lineItemQuantitiesByPurchasableId[$lineItem->purchasableId] : $lineItem->qty;

if ($this->hasStock() && $this->inventoryTracked && $lineItemQty > $this->getStock()) {
if ($this->hasStock($order) && $this->inventoryTracked && $lineItemQty > $this->getStock($order)) {
if (!Plugin::getInstance()->getPurchasables()->isPurchasableOutOfStockPurchasingAllowed($lineItemPurchasable, $lineItem->getOrder())) {
$error = Craft::t('commerce', 'There are only {num} “{description}” items left in stock.', ['num' => $this->getStock(), 'description' => $lineItemPurchasable->getDescription()]);
$error = Craft::t('commerce', 'There are only {num} “{description}” items left in stock.', ['num' => $this->getStock($order), 'description' => $lineItemPurchasable->getDescription()]);
$validator->addError($lineItem, $attribute, $error);
}
}
Expand Down Expand Up @@ -1025,16 +1038,18 @@ public function setHasUnlimitedStock($value): bool
}

/**
* @param Order|null $order The order the stock is being calculated for.
* @return int
*/
private function _getStock(): int
private function _getStock(Order|null $order = null): int
{
if (!$this->inventoryTracked) {
return 0;
}

$saleableAmount = 0;
foreach ($this->getInventoryLevels() as $inventoryLevel) {
$inventoryLevels = $this->getInventoryLevels($order);
foreach ($inventoryLevels as $inventoryLevel) {
if ($inventoryLevel->availableTotal > 0) {
$saleableAmount += $inventoryLevel->availableTotal;
}
Expand All @@ -1053,13 +1068,25 @@ public function getIsOutOfStockPurchasingAllowed(): bool
}

/**
* Returns the cached total available stock across all inventory locations for this store.
* Returns the cached total available stock across all inventory locations for this store,
* and optionally for a specific order.
*
* @param Order|null $order The order the stock is being calculated for.
*
* @return int
* @since 5.0.0
*/
public function getStock(): int
public function getStock(Order|null $order = null): int
{
$orderId = $order?->id;
if ($orderId) {
if (!isset($this->_stockForOrders[$orderId])) {
$this->_stockForOrders[$orderId] = $this->_getStock($order);
}

return $this->_stockForOrders[$orderId];
}

if ($this->_stock === null) {
$this->_stock = $this->_getStock();
}
Expand All @@ -1072,13 +1099,13 @@ public function getStock(): int
* @return Collection<InventoryLevel>
* @since 5.0.0
*/
public function getInventoryLevels(): Collection
public function getInventoryLevels(Order|null $order = null): Collection
{
if (!$this->inventoryTracked) {
return collect();
}

return Plugin::getInstance()->getInventory()->getInventoryLevelsForPurchasable($this);
return Plugin::getInstance()->getInventory()->getInventoryLevelsForPurchasable($this, $order);
}

/**
Expand Down
74 changes: 74 additions & 0 deletions src/events/RegisterInventoryLocationsForPurchasableEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php
/**
* @link https://craftcms.com/
* @copyright Copyright (c) Pixel & Tonic, Inc.
* @license https://craftcms.github.io/license/
*/

namespace craft\commerce\events;

use craft\commerce\base\Purchasable;
use craft\commerce\elements\Order;
use craft\commerce\models\Store;
use Illuminate\Support\Collection;
use yii\base\Event;

/**
* RegisterInventoryLocationsForPurchasableEvent class.
*
* @author Pixel & Tonic, Inc. <support@pixelandtonic.com>
* @since 3.0
*
* @property array|\Illuminate\Support\Collection $inventoryLocations
*/
class RegisterInventoryLocationsForPurchasableEvent extends Event
{
/**
* @var Purchasable The purchasable the inventory locations are being registered for.
*/
public Purchasable $purchasable;

/**
* @var Order The order the inventory locations are being registered for.
*/
public Order|null $order = null;

/**
* @var Store The store the inventory locations are being registered for.
*/
public Store $store;

/**
* @var Collection The collection of inventory locations for the purchasable, sorted by priority.
*/
private ?Collection $_inventoryLocations = null;

/**
* @var bool Whether trashed inventory locations should be included.
*
* @var bool
*/
public bool $withTrashed = false;

/**
* @param Collection $inventoryLocations
* @return void
*/
public function setInventoryLocations(Collection $inventoryLocations): void
{
if (!$inventoryLocations instanceof Collection) {
$inventoryLocations = collect($inventoryLocations);
}

$this->_inventoryLocations = $inventoryLocations;
}

/**
* @return Collection
*/
public function getInventoryLocations(): Collection
{
return $this->_inventoryLocations ?? collect();
}

}
8 changes: 4 additions & 4 deletions src/helpers/Order.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,10 @@ public static function normalizeLineItemPurchasableAvailability(OrderElement $or
} elseif ($purchasable::hasInventory() &&
!$purchasable->getIsOutOfStockPurchasingAllowed() &&
$purchasable->inventoryTracked &&
($lineItem->qty > $purchasable->getStock()) &&
$purchasable->getStock() > 0
($lineItem->qty > $purchasable->getStock($order)) &&
$purchasable->getStock($order) > 0
) {
$message = Craft::t('commerce', '{description} only has {stock} in stock.', ['description' => $lineItem->getDescription(), 'stock' => $purchasable->getStock()]);
$message = Craft::t('commerce', '{description} only has {stock} in stock.', ['description' => $lineItem->getDescription(), 'stock' => $purchasable->getStock($order)]);
/** @var OrderNotice $notice */
$notice = Craft::createObject([
'class' => OrderNotice::class,
Expand All @@ -107,7 +107,7 @@ public static function normalizeLineItemPurchasableAvailability(OrderElement $or
],
]);
$order->addNotice($notice);
$lineItem->qty = $purchasable->getStock();
$lineItem->qty = $purchasable->getStock($order);
}
}
}
Expand Down
14 changes: 8 additions & 6 deletions src/services/Inventory.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,20 +91,21 @@ class Inventory extends Component

/**
* @param Purchasable $purchasable
* @param Order|null $order
*
* @return Collection<InventoryLevel>
*/
public function getInventoryLevelsForPurchasable(Purchasable $purchasable): Collection
public function getInventoryLevelsForPurchasable(Purchasable $purchasable, Order|null $order = null): Collection
{
$inventoryLevels = collect();

if (!$purchasable->id || !$purchasable->inventoryItemId) {
return $inventoryLevels; // empty collection
}

$storeId = $purchasable->getStore()->id;
$storeInventoryLocations = Plugin::getInstance()->getInventoryLocations()->getInventoryLocations($storeId);
$purchasableInventoryLocations = Plugin::getInstance()->getInventoryLocations()->getInventoryLocationsForPurchasable($purchasable, $order);

foreach ($storeInventoryLocations as $inventoryLocation) {
foreach ($purchasableInventoryLocations as $inventoryLocation) {
$inventoryLevel = $this->getInventoryLevel($purchasable->inventoryItemId, $inventoryLocation->id);

if (!$inventoryLevel) {
Expand All @@ -113,14 +114,15 @@ public function getInventoryLevelsForPurchasable(Purchasable $purchasable): Coll
$inventoryLevels->push($inventoryLevel);
}


return $inventoryLevels;
}

/**
* @param Purchasable $purchasable
* @return InventoryItem
*/
public function getInventoryItemByPurchasable(Purchasable $purchasable): InventoryItem
public function getInventoryItemByPurchasable(Purchasable $purchasable, Order|null $order = null): InventoryItem
{
return $this->getInventoryItemById($purchasable->inventoryItemId);
}
Expand Down Expand Up @@ -784,7 +786,7 @@ public function orderCompleteHandler(Order $order)
$qtyLineItem[$purchasable->id] = 0;
}
$qtyLineItem[$purchasable->id] += $lineItem->qty;
$allInventoryLevels[$purchasable->id] = $purchasable->getInventoryLevels();
$allInventoryLevels[$purchasable->id] = $purchasable->getInventoryLevels($order);
}
}

Expand Down
66 changes: 66 additions & 0 deletions src/services/InventoryLocations.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@
namespace craft\commerce\services;

use Craft;
use craft\commerce\base\Purchasable;
use craft\commerce\collections\InventoryMovementCollection;
use craft\commerce\db\Table;
use craft\commerce\elements\Order;
use craft\commerce\enums\InventoryTransactionType;
use craft\commerce\events\RegisterInventoryLocationsForPurchasableEvent;
use craft\commerce\models\inventory\DeactivateInventoryLocation;
use craft\commerce\models\inventory\InventoryLocationDeactivatedMovement;
use craft\commerce\models\InventoryLevel;
Expand All @@ -36,6 +39,28 @@
*/
class InventoryLocations extends Component
{
/**
* @event RegisterInventoryLocationsEvent The event that is triggered when inventory locations are being registered for a purchasable.
*
* ```php
* use craft\commerce\events\RegisterInventoryLocationsEvent;
* use craft\commerce\services\InventoryLocations;
* use yii\base\Event;
*
* Event::on(
* InventoryLocations::class,
* InventoryLocations::EVENT_REGISTER_INVENTORY_LOCATIONS,
* function(RegisterInventoryLocationsEvent $event) {
* $inventoryLocations = collect();
* // ... custom logic to get the inventory locations for the purchasable
* // @var RegisterInventoryLocationsEvent $event
* $event->inventoryLocations = $inventoryLocations;
* }
* );
* ```
*/
public const EVENT_REGISTER_INVENTORY_LOCATIONS_FOR_PURCHASABLE = 'registerInventoryLocations';

/**
* @var Collection<InventoryLocation>|null
*/
Expand Down Expand Up @@ -107,6 +132,47 @@ public function getInventoryLocations(?int $storeId = null, bool $withTrashed =
return $this->_getAllInventoryLocations($withTrashed)->whereIn('id', $locationIds)->sortBy(fn($inventoryLocation) => array_search($inventoryLocation->id, $locationIds));
}

/**
* Undocumented function
*
* @param Purchasable $purchasable
* @param Order|null $order
* @param bool $withTrashed
*
* @return Collection
*/
public function getInventoryLocationsForPurchasable(Purchasable $purchasable, Order|null $order = null, bool $withTrashed = false): Collection
{
// @todo: Fix the list of inventory locations on order completion, by adding an `inventoryLocations` property to the order, saving inventory locations there when the order completes, and only re-fetching the inventory locations if that property is not set.

/** @var Store $store */
$store = $order?->getStore() ?? $purchasable->getStore() ?? Plugin::getInstance()->getStores()->getCurrentStore();

// Default to all inventory locations attached to the store
$storeId = $store->id;
$storeInventoryLocations = $this->getInventoryLocations($storeId, $withTrashed);

// Allow modules and plugins to modify the list of inventory locations available for the purchasable.
$event = new RegisterInventoryLocationsForPurchasableEvent([
'purchasable' => $purchasable,
'order' => $order,
'store' => $store,
'inventoryLocations' => $storeInventoryLocations,
'withTrashed' => $withTrashed,
]);

$this->trigger(self::EVENT_REGISTER_INVENTORY_LOCATIONS_FOR_PURCHASABLE, $event);
$orderInventoryLocations = $event->inventoryLocations;

// The order stock locations must be a subset of the store inventory locations
$storeLocationIds = $storeInventoryLocations->keyBy('id');
$purchasableInventoryLocations = $orderInventoryLocations
->filter(static fn(InventoryLocation $location) => $storeLocationIds->has($location->id))
->values();

return $purchasableInventoryLocations;
}

/**
* Stores the relationship between a Store and its Inventory Locations, ordered by preference.
*
Expand Down
Loading