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
57 changes: 41 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;

/**
* Cached total available stock for the purchasable, keyed by order ID.
*
* @var int[]
* @since 5.6.5
*/
private array $_stockForOrders = [];

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

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

/**
Expand Down Expand Up @@ -797,10 +807,10 @@ public function populateLineItem(LineItem $lineItem): void
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 +821,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 +877,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 +888,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 +1037,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 $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 +1067,24 @@ 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 $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 +1097,13 @@ public function getStock(): int
* @return Collection<InventoryLevel>
* @since 5.0.0
*/
public function getInventoryLevels(): Collection
public function getInventoryLevels(?Order $order = null): Collection
{
if (!$this->inventoryTracked) {
return collect();
}

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

/**
Expand Down
67 changes: 67 additions & 0 deletions src/events/RegisterInventoryLocationsForPurchasableEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?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 5.6.5
*
* @property Collection $inventoryLocations
*/
class RegisterInventoryLocationsForPurchasableEvent extends Event
{
/**
* @var Purchasable The purchasable the inventory locations are being registered for.
*/
public Purchasable $purchasable;

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

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

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

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

/**
* @param Collection $inventoryLocations
* @return void
*/
public function setInventoryLocations(Collection $inventoryLocations): void
{
$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
10 changes: 5 additions & 5 deletions src/services/Inventory.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,10 @@ 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 $order = null): Collection
{
$inventoryLevels = collect();

Expand All @@ -111,10 +112,9 @@ public function getInventoryLevelsForPurchasable(Purchasable $purchasable): Coll
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 Down Expand Up @@ -842,7 +842,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
68 changes: 68 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,29 @@
*/
class InventoryLocations extends Component
{
/**
* @event RegisterInventoryLocationsForPurchasableEvent The event that is triggered when inventory locations are being registered for a purchasable.
*
* ```php
* use craft\commerce\events\RegisterInventoryLocationsForPurchasableEvent;
* use craft\commerce\services\InventoryLocations;
* use yii\base\Event;
*
* Event::on(
* InventoryLocations::class,
* InventoryLocations::EVENT_REGISTER_INVENTORY_LOCATIONS_FOR_PURCHASABLE,
* function(RegisterInventoryLocationsForPurchasableEvent $event) {
* $inventoryLocations = collect();
* // ... custom logic to get the inventory locations for the purchasable
* $event->setInventoryLocations($inventoryLocations);
* }
* );
* ```
*
* @since 5.6.5
*/
public const EVENT_REGISTER_INVENTORY_LOCATIONS_FOR_PURCHASABLE = 'registerInventoryLocationsForPurchasable';

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

/**
* Returns the inventory locations that should be considered for a purchasable, optionally in the context of an order.
*
* Plugins and modules can listen to [[EVENT_REGISTER_INVENTORY_LOCATIONS_FOR_PURCHASABLE]] to narrow or reorder
* the returned locations. The returned collection is always filtered to the store's configured locations.
*
* @param Purchasable $purchasable
* @param Order|null $order
* @param bool $withTrashed
* @return Collection<InventoryLocation>
* @since 5.6.5
*/
public function getInventoryLocationsForPurchasable(Purchasable $purchasable, ?Order $order = null, bool $withTrashed = false): Collection
{
$store = $order?->getStore() ?? $purchasable->getStore();

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

// Skip the event entirely when nothing is listening
if (!$this->hasEventHandlers(self::EVENT_REGISTER_INVENTORY_LOCATIONS_FOR_PURCHASABLE)) {
return $storeInventoryLocations->values();
}

// 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);

// The returned locations must be a subset of the store's inventory locations
$storeLocationIds = $storeInventoryLocations->keyBy('id');
return $event->getInventoryLocations()
->filter(static fn(InventoryLocation $location) => $storeLocationIds->has($location->id))
->values();
}

/**
* Stores the relationship between a Store and its Inventory Locations, ordered by preference.
*
Expand Down
20 changes: 20 additions & 0 deletions src/services/Purchasables.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use craft\elements\User;
use craft\errors\SiteNotFoundException;
use craft\events\RegisterComponentTypesEvent;
use craft\helpers\ArrayHelper;
use Illuminate\Support\Collection;
use Throwable;
use yii\base\Component;
Expand Down Expand Up @@ -171,8 +172,27 @@ public function isPurchasableAvailable(PurchasableInterface $purchasable, Order
if ($currentUser === null) {
$currentUser = Craft::$app->getUser()->getIdentity();
}

$isAvailable = $purchasable->getIsAvailable();

// Purchasable::getIsAvailable() checks for stock across all of the store's inventory locations, but these may differ for this specific order. We can rest assured that the stock for the order is less than the stock for the store.
// We are doing this here so we don't have to change the signature of the PurchasableInterface::getIsAvailable() method.
// We use the ArrayHelper::getValue() to support purchasbles which implement the PurchasableInterface without extending base Purchasable class.
$inventoryTracked = ArrayHelper::getValue($purchasable, 'inventoryTracked') ?? false;
$allowOutOfStockPurchasables = false;
$stock = 0;

if ($purchasable instanceof Purchasable) {
$stock = $purchasable->getStock($order);
$allowOutOfStockPurchasables = Plugin::getInstance()->getPurchasables()->isPurchasableOutOfStockPurchasingAllowed($purchasable, $order, $currentUser);
} else {
$stock = ArrayHelper::getValue($purchasable, 'stock') ?? 0;
}

if ($order && $inventoryTracked && $stock < 1 && !$allowOutOfStockPurchasables) {
$isAvailable = false;
}

$event = new PurchasableAvailableEvent(compact('order', 'purchasable', 'currentUser', 'isAvailable'));

if ($this->hasEventHandlers(self::EVENT_PURCHASABLE_AVAILABLE)) {
Expand Down