diff --git a/src/base/Purchasable.php b/src/base/Purchasable.php index 0cc9ae527d..28083e7cc4 100644 --- a/src/base/Purchasable.php +++ b/src/base/Purchasable.php @@ -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 */ @@ -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; } /** @@ -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, @@ -811,7 +821,7 @@ public function populateLineItem(LineItem $lineItem): void ], ]); $order->addNotice($notice); - $lineItem->qty = $this->getStock(); + $lineItem->qty = $this->getStock($order); } } @@ -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); @@ -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); } } @@ -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; } @@ -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(); } @@ -1072,13 +1097,13 @@ public function getStock(): int * @return Collection * @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); } /** diff --git a/src/events/RegisterInventoryLocationsForPurchasableEvent.php b/src/events/RegisterInventoryLocationsForPurchasableEvent.php new file mode 100644 index 0000000000..50f039aba4 --- /dev/null +++ b/src/events/RegisterInventoryLocationsForPurchasableEvent.php @@ -0,0 +1,67 @@ + + * @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(); + } +} diff --git a/src/helpers/Order.php b/src/helpers/Order.php index 8d4b41e1e7..b52aa8dd42 100644 --- a/src/helpers/Order.php +++ b/src/helpers/Order.php @@ -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, @@ -107,7 +107,7 @@ public static function normalizeLineItemPurchasableAvailability(OrderElement $or ], ]); $order->addNotice($notice); - $lineItem->qty = $purchasable->getStock(); + $lineItem->qty = $purchasable->getStock($order); } } } diff --git a/src/services/Inventory.php b/src/services/Inventory.php index 1210d8ed36..61766e822c 100644 --- a/src/services/Inventory.php +++ b/src/services/Inventory.php @@ -91,9 +91,10 @@ class Inventory extends Component /** * @param Purchasable $purchasable + * @param Order|null $order * @return Collection */ - public function getInventoryLevelsForPurchasable(Purchasable $purchasable): Collection + public function getInventoryLevelsForPurchasable(Purchasable $purchasable, ?Order $order = null): Collection { $inventoryLevels = collect(); @@ -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) { @@ -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); } } diff --git a/src/services/InventoryLocations.php b/src/services/InventoryLocations.php index 95e2a1f90a..b60577b7d0 100644 --- a/src/services/InventoryLocations.php +++ b/src/services/InventoryLocations.php @@ -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; @@ -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|null */ @@ -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 + * @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. * diff --git a/src/services/Purchasables.php b/src/services/Purchasables.php index e7b9629700..1debdc0951 100644 --- a/src/services/Purchasables.php +++ b/src/services/Purchasables.php @@ -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; @@ -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)) {