From eec67d8083f41c4f185ac64f72343bb561a217fd Mon Sep 17 00:00:00 2001 From: Yoannis Jamar Date: Fri, 10 Apr 2026 20:59:43 +0100 Subject: [PATCH 1/3] feat: trigger event to override inventory locations per order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - trigger event when getting the inventory locations for given purchasable `craft\commerce\services\InventoryLocations:: EVENT_REGISTER_INVENTORY_LOCATIONS_FOR_PURCHASABLE ` which allows modules and plugins to select a subset of the store’s inventory locations based on the order details - add first argument `$order` to \craft\commerce\base\Purchasable::getStock()` to aggregate stock in order-specific inventory locations only - add first argument `$order` to \craft\commerce\base\Purchasable::hasStock()` to check for stock in order-specific inventory locations only - verify stock available in order-specific inventory locations when checking a purchasable’s availability --- src/base/Purchasable.php | 59 +++++++++++---- ...rInventoryLocationsForPurchasableEvent.php | 74 +++++++++++++++++++ src/helpers/Order.php | 8 +- src/services/Inventory.php | 14 ++-- src/services/InventoryLocations.php | 66 +++++++++++++++++ src/services/Purchasables.php | 12 +++ 6 files changed, 207 insertions(+), 26 deletions(-) create mode 100644 src/events/RegisterInventoryLocationsForPurchasableEvent.php diff --git a/src/base/Purchasable.php b/src/base/Purchasable.php index d4ed208c95..53b5c40750 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; + /** + * This is the cached total available stock across all inventory locations for specific orders. + * + * @var int[] + * @since 5.6.2 + */ + private array $_stockForOrders = []; + /** * @inheritdoc */ @@ -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; } /** @@ -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, @@ -811,7 +822,7 @@ public function populateLineItem(LineItem $lineItem): void ], ]); $order->addNotice($notice); - $lineItem->qty = $this->getStock(); + $lineItem->qty = $this->getStock($order); } } @@ -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); @@ -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); } } @@ -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; } @@ -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(); } @@ -1072,13 +1099,13 @@ public function getStock(): int * @return Collection * @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); } /** diff --git a/src/events/RegisterInventoryLocationsForPurchasableEvent.php b/src/events/RegisterInventoryLocationsForPurchasableEvent.php new file mode 100644 index 0000000000..4edf35fcc7 --- /dev/null +++ b/src/events/RegisterInventoryLocationsForPurchasableEvent.php @@ -0,0 +1,74 @@ + + * @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(); + } + +} \ No newline at end of file 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 bbbd86b1ff..c460a30ce0 100644 --- a/src/services/Inventory.php +++ b/src/services/Inventory.php @@ -91,9 +91,11 @@ 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|null $order = null): Collection { $inventoryLevels = collect(); @@ -101,10 +103,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) { @@ -113,6 +114,7 @@ public function getInventoryLevelsForPurchasable(Purchasable $purchasable): Coll $inventoryLevels->push($inventoryLevel); } + return $inventoryLevels; } @@ -120,7 +122,7 @@ public function getInventoryLevelsForPurchasable(Purchasable $purchasable): Coll * @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); } @@ -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); } } diff --git a/src/services/InventoryLocations.php b/src/services/InventoryLocations.php index 95e2a1f90a..ed7d3a701a 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,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|null */ @@ -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. * diff --git a/src/services/Purchasables.php b/src/services/Purchasables.php index e7b9629700..60698fef5a 100644 --- a/src/services/Purchasables.php +++ b/src/services/Purchasables.php @@ -171,8 +171,20 @@ 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 getIsAvailable method. + if ( + $order + && $purchasable->inventoryTracked + && $purchasable->getStock($order) < 1 + && !Plugin::getInstance()->getPurchasables()->isPurchasableOutOfStockPurchasingAllowed($this) + ) { + $isAvailable = false; + } + $event = new PurchasableAvailableEvent(compact('order', 'purchasable', 'currentUser', 'isAvailable')); if ($this->hasEventHandlers(self::EVENT_PURCHASABLE_AVAILABLE)) { From 7065a4d08290324712e2e2a4a8ac0b983234d321 Mon Sep 17 00:00:00 2001 From: Yoannis Jamar Date: Thu, 16 Apr 2026 18:37:00 +0100 Subject: [PATCH 2/3] fix: error thrown when checking if purchasable allows out-of-stock purchases --- src/services/Purchasables.php | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/services/Purchasables.php b/src/services/Purchasables.php index 60698fef5a..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; @@ -175,13 +176,20 @@ public function isPurchasableAvailable(PurchasableInterface $purchasable, Order $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 getIsAvailable method. - if ( - $order - && $purchasable->inventoryTracked - && $purchasable->getStock($order) < 1 - && !Plugin::getInstance()->getPurchasables()->isPurchasableOutOfStockPurchasingAllowed($this) - ) { + // 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; } From ab492c85fe54b805c651a55d850ebf4a47edc9f8 Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Wed, 3 Jun 2026 13:41:55 +0800 Subject: [PATCH 3/3] Clean up event class, docblocks, and style in inventory locations event - Fix RegisterInventoryLocationsForPurchasableEvent docblock, @since, @property type, dead instanceof check, and trailing newline - Update the event docblock example on InventoryLocations to reference the correct class and constant, and use the setter - Match the constant value to its name (registerInventoryLocationsForPurchasable) - Skip event construction when no handlers are attached - Restore brace style and remove duplicate $order assignment in Purchasable::populateLineItem - Drop the unused $order parameter from Inventory::getInventoryItemByPurchasable and stray blank line - Normalize Order|null to ?Order, fix @since to 5.6.5 on new symbols --- src/base/Purchasable.php | 20 ++++--- ...rInventoryLocationsForPurchasableEvent.php | 19 +++---- src/services/Inventory.php | 6 +-- src/services/InventoryLocations.php | 52 ++++++++++--------- 4 files changed, 44 insertions(+), 53 deletions(-) diff --git a/src/base/Purchasable.php b/src/base/Purchasable.php index 075a247bde..28083e7cc4 100644 --- a/src/base/Purchasable.php +++ b/src/base/Purchasable.php @@ -261,10 +261,10 @@ 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. + * Cached total available stock for the purchasable, keyed by order ID. * * @var int[] - * @since 5.6.2 + * @since 5.6.5 */ private array $_stockForOrders = []; @@ -697,10 +697,11 @@ public function setSku(string $sku = null): void } /** + * 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. - * Returns whether this variant has stock. */ - public function hasStock(Order|null $order = null): bool + public function hasStock(?Order $order = null): bool { return !$this->inventoryTracked || $this->getStock($order) > 0; } @@ -802,9 +803,7 @@ 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) - { - $order = $lineItem->getOrder(); + if (($order = $lineItem->getOrder()) && !$order->isCompleted) { if ($this::hasInventory() && !$this->getIsOutOfStockPurchasingAllowed() && $this->inventoryTracked && @@ -1041,7 +1040,7 @@ public function setHasUnlimitedStock($value): bool * @param Order|null $order The order the stock is being calculated for. * @return int */ - private function _getStock(Order|null $order = null): int + private function _getStock(?Order $order = null): int { if (!$this->inventoryTracked) { return 0; @@ -1072,11 +1071,10 @@ public function getIsOutOfStockPurchasingAllowed(): bool * 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(Order|null $order = null): int + public function getStock(?Order $order = null): int { $orderId = $order?->id; if ($orderId) { @@ -1099,7 +1097,7 @@ public function getStock(Order|null $order = null): int * @return Collection * @since 5.0.0 */ - public function getInventoryLevels(Order|null $order = null): Collection + public function getInventoryLevels(?Order $order = null): Collection { if (!$this->inventoryTracked) { return collect(); diff --git a/src/events/RegisterInventoryLocationsForPurchasableEvent.php b/src/events/RegisterInventoryLocationsForPurchasableEvent.php index 4edf35fcc7..50f039aba4 100644 --- a/src/events/RegisterInventoryLocationsForPurchasableEvent.php +++ b/src/events/RegisterInventoryLocationsForPurchasableEvent.php @@ -17,9 +17,9 @@ * RegisterInventoryLocationsForPurchasableEvent class. * * @author Pixel & Tonic, Inc. - * @since 3.0 + * @since 5.6.5 * - * @property array|\Illuminate\Support\Collection $inventoryLocations + * @property Collection $inventoryLocations */ class RegisterInventoryLocationsForPurchasableEvent extends Event { @@ -29,9 +29,9 @@ class RegisterInventoryLocationsForPurchasableEvent extends Event public Purchasable $purchasable; /** - * @var Order The order the inventory locations are being registered for. + * @var Order|null The order the inventory locations are being registered for. */ - public Order|null $order = null; + public ?Order $order = null; /** * @var Store The store the inventory locations are being registered for. @@ -39,14 +39,12 @@ class RegisterInventoryLocationsForPurchasableEvent extends Event public Store $store; /** - * @var Collection The collection of inventory locations for the purchasable, sorted by priority. + * @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. - * - * @var bool */ public bool $withTrashed = false; @@ -56,10 +54,6 @@ class RegisterInventoryLocationsForPurchasableEvent extends Event */ public function setInventoryLocations(Collection $inventoryLocations): void { - if (!$inventoryLocations instanceof Collection) { - $inventoryLocations = collect($inventoryLocations); - } - $this->_inventoryLocations = $inventoryLocations; } @@ -70,5 +64,4 @@ public function getInventoryLocations(): Collection { return $this->_inventoryLocations ?? collect(); } - -} \ No newline at end of file +} diff --git a/src/services/Inventory.php b/src/services/Inventory.php index 90513411fa..61766e822c 100644 --- a/src/services/Inventory.php +++ b/src/services/Inventory.php @@ -92,10 +92,9 @@ class Inventory extends Component /** * @param Purchasable $purchasable * @param Order|null $order - * * @return Collection */ - public function getInventoryLevelsForPurchasable(Purchasable $purchasable, Order|null $order = null): Collection + public function getInventoryLevelsForPurchasable(Purchasable $purchasable, ?Order $order = null): Collection { $inventoryLevels = collect(); @@ -124,7 +123,6 @@ public function getInventoryLevelsForPurchasable(Purchasable $purchasable, Order $inventoryLevels->push($inventoryLevel); } - return $inventoryLevels; } @@ -132,7 +130,7 @@ public function getInventoryLevelsForPurchasable(Purchasable $purchasable, Order * @param Purchasable $purchasable * @return InventoryItem */ - public function getInventoryItemByPurchasable(Purchasable $purchasable, Order|null $order = null): InventoryItem + public function getInventoryItemByPurchasable(Purchasable $purchasable): InventoryItem { // Self-heal: if the purchasable has somehow ended up without an associated // inventory item (e.g. due to a draft-apply or duplicate path that didn't diff --git a/src/services/InventoryLocations.php b/src/services/InventoryLocations.php index ed7d3a701a..b60577b7d0 100644 --- a/src/services/InventoryLocations.php +++ b/src/services/InventoryLocations.php @@ -40,26 +40,27 @@ class InventoryLocations extends Component { /** - * @event RegisterInventoryLocationsEvent The event that is triggered when inventory locations are being registered for a purchasable. - * + * @event RegisterInventoryLocationsForPurchasableEvent The event that is triggered when inventory locations are being registered for a purchasable. + * * ```php - * use craft\commerce\events\RegisterInventoryLocationsEvent; + * use craft\commerce\events\RegisterInventoryLocationsForPurchasableEvent; * use craft\commerce\services\InventoryLocations; * use yii\base\Event; * * Event::on( * InventoryLocations::class, - * InventoryLocations::EVENT_REGISTER_INVENTORY_LOCATIONS, - * function(RegisterInventoryLocationsEvent $event) { + * InventoryLocations::EVENT_REGISTER_INVENTORY_LOCATIONS_FOR_PURCHASABLE, + * function(RegisterInventoryLocationsForPurchasableEvent $event) { * $inventoryLocations = collect(); * // ... custom logic to get the inventory locations for the purchasable - * // @var RegisterInventoryLocationsEvent $event - * $event->inventoryLocations = $inventoryLocations; + * $event->setInventoryLocations($inventoryLocations); * } * ); * ``` + * + * @since 5.6.5 */ - public const EVENT_REGISTER_INVENTORY_LOCATIONS_FOR_PURCHASABLE = 'registerInventoryLocations'; + public const EVENT_REGISTER_INVENTORY_LOCATIONS_FOR_PURCHASABLE = 'registerInventoryLocationsForPurchasable'; /** * @var Collection|null @@ -133,24 +134,28 @@ public function getInventoryLocations(?int $storeId = null, bool $withTrashed = } /** - * Undocumented function + * Returns the inventory locations that should be considered for a purchasable, optionally in the context of an order. * - * @param Purchasable $purchasable - * @param Order|null $order - * @param bool $withTrashed + * 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. * - * @return Collection + * @param Purchasable $purchasable + * @param Order|null $order + * @param bool $withTrashed + * @return Collection + * @since 5.6.5 */ - public function getInventoryLocationsForPurchasable(Purchasable $purchasable, Order|null $order = null, bool $withTrashed = false): Collection + public function getInventoryLocationsForPurchasable(Purchasable $purchasable, ?Order $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(); + $store = $order?->getStore() ?? $purchasable->getStore(); // Default to all inventory locations attached to the store - $storeId = $store->id; - $storeInventoryLocations = $this->getInventoryLocations($storeId, $withTrashed); + $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([ @@ -162,15 +167,12 @@ public function getInventoryLocationsForPurchasable(Purchasable $purchasable, Or ]); $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 + // The returned locations must be a subset of the store's inventory locations $storeLocationIds = $storeInventoryLocations->keyBy('id'); - $purchasableInventoryLocations = $orderInventoryLocations + return $event->getInventoryLocations() ->filter(static fn(InventoryLocation $location) => $storeLocationIds->has($location->id)) ->values(); - - return $purchasableInventoryLocations; } /**