diff --git a/src/Api/Inventory/AreProductsAssignedToStockInterface.php b/src/Api/Inventory/AreProductsAssignedToStockInterface.php
new file mode 100644
index 0000000..6cc1a3e
--- /dev/null
+++ b/src/Api/Inventory/AreProductsAssignedToStockInterface.php
@@ -0,0 +1,20 @@
+
+ * @copyright Copyright (c) 2022 Scandiweb, Inc (https://scandiweb.com)
+ * @license http://opensource.org/licenses/OSL-3.0 The Open Software License 3.0 (OSL-3.0)
+ */
+declare(strict_types=1);
+
+namespace ScandiPWA\Performance\Api\Inventory;
+
+interface AreProductsAssignedToStockInterface
+{
+ /**
+ * @param array $skuArray
+ * @param int $stockId
+ * @return array
+ */
+ public function execute(array $skuArray, int $stockId): array;
+}
diff --git a/src/Api/Inventory/AreProductsSalableInterface.php b/src/Api/Inventory/AreProductsSalableInterface.php
new file mode 100644
index 0000000..6e4d9a0
--- /dev/null
+++ b/src/Api/Inventory/AreProductsSalableInterface.php
@@ -0,0 +1,27 @@
+
+ * @copyright Copyright (c) 2022 Scandiweb, Inc (https://scandiweb.com)
+ * @license http://opensource.org/licenses/OSL-3.0 The Open Software License 3.0 (OSL-3.0)
+ */
+declare(strict_types=1);
+
+namespace ScandiPWA\Performance\Api\Inventory;
+
+/**
+ * Service which detects whether products are salable for given stock (stock data + reservations).
+ *
+ * @api
+ */
+interface AreProductsSalableInterface
+{
+ /**
+ * Get products salable status for given SKUs and given Stock.
+ *
+ * @param string[] $skuArray
+ * @param int $stockId
+ * @return array
+ */
+ public function execute(array $skuArray, int $stockId): array;
+}
diff --git a/src/Api/Inventory/GetReservationsQuantitiesInterface.php b/src/Api/Inventory/GetReservationsQuantitiesInterface.php
new file mode 100644
index 0000000..b3ba4ae
--- /dev/null
+++ b/src/Api/Inventory/GetReservationsQuantitiesInterface.php
@@ -0,0 +1,27 @@
+
+ * @copyright Copyright (c) 2022 Scandiweb, Inc (https://scandiweb.com)
+ * @license http://opensource.org/licenses/OSL-3.0 The Open Software License 3.0 (OSL-3.0)
+ */
+declare(strict_types=1);
+
+namespace ScandiPWA\Performance\Api\Inventory;
+
+/**
+ * Responsible for retrieving Reservation Quantity (without stock data) for SKU array
+ *
+ * @api
+ */
+interface GetReservationsQuantitiesInterface
+{
+ /**
+ * Given a product sku array and a stock id, return reservation quantity for each sku
+ *
+ * @param array $skuArray
+ * @param int $stockId
+ * @return array
+ */
+ public function execute(array $skuArray, int $stockId): array;
+}
diff --git a/src/Api/Inventory/GetStockItemsConfigurationsInterface.php b/src/Api/Inventory/GetStockItemsConfigurationsInterface.php
new file mode 100644
index 0000000..1fc1851
--- /dev/null
+++ b/src/Api/Inventory/GetStockItemsConfigurationsInterface.php
@@ -0,0 +1,32 @@
+
+ * @copyright Copyright (c) 2022 Scandiweb, Inc (https://scandiweb.com)
+ * @license http://opensource.org/licenses/OSL-3.0 The Open Software License 3.0 (OSL-3.0)
+ */
+declare(strict_types=1);
+
+namespace ScandiPWA\Performance\Api\Inventory;
+
+use Magento\Framework\Exception\LocalizedException;
+use Magento\InventoryConfigurationApi\Api\Data\StockItemConfigurationInterface;
+use Magento\InventoryConfigurationApi\Exception\SkuIsNotAssignedToStockException;
+
+/**
+ * Returns stock item configuration data by sku and stock id.
+ *
+ * @api
+ */
+interface GetStockItemsConfigurationsInterface
+{
+ /**
+ * @param array $skuArray
+ * @param int $stockId
+ * @return StockItemConfigurationInterface[]
+ */
+ public function execute(
+ array $skuArray,
+ int $stockId
+ ): array;
+}
diff --git a/src/Api/Inventory/GetStockItemsDataInterface.php b/src/Api/Inventory/GetStockItemsDataInterface.php
new file mode 100644
index 0000000..4dff0b2
--- /dev/null
+++ b/src/Api/Inventory/GetStockItemsDataInterface.php
@@ -0,0 +1,36 @@
+
+ * @copyright Copyright (c) 2022 Scandiweb, Inc (https://scandiweb.com)
+ * @license http://opensource.org/licenses/OSL-3.0 The Open Software License 3.0 (OSL-3.0)
+ */
+declare(strict_types=1);
+
+namespace ScandiPWA\Performance\Api\Inventory;
+
+/**
+ * Responsible for retrieving StockItem Data for SKU array
+ *
+ * @api
+ */
+interface GetStockItemsDataInterface
+{
+ /**
+ * Constants for represent fields in result array
+ */
+ const SKU = 'sku';
+ const QUANTITY = 'quantity';
+ const IS_SALABLE = 'is_salable';
+
+ /**#@-*/
+
+ /**
+ * Given a product sku array and a stock id, return stock item data for each sku
+ *
+ * @param array $skuArray
+ * @param int $stockId
+ * @return array
+ */
+ public function execute(array $skuArray, int $stockId): array;
+}
diff --git a/src/Model/Inventory/AreProductsAssignedToStock.php b/src/Model/Inventory/AreProductsAssignedToStock.php
new file mode 100644
index 0000000..bafdf46
--- /dev/null
+++ b/src/Model/Inventory/AreProductsAssignedToStock.php
@@ -0,0 +1,113 @@
+
+ * @copyright Copyright (c) 2022 Scandiweb, Inc (https://scandiweb.com)
+ * @license http://opensource.org/licenses/OSL-3.0 The Open Software License 3.0 (OSL-3.0)
+ */
+declare(strict_types=1);
+
+namespace ScandiPWA\Performance\Model\Inventory;
+
+use Magento\Framework\App\ResourceConnection;
+use Magento\Inventory\Model\ResourceModel\SourceItem;
+use Magento\Inventory\Model\ResourceModel\StockSourceLink;
+use Magento\InventoryApi\Api\Data\SourceItemInterface;
+use Magento\InventoryApi\Api\Data\StockSourceLinkInterface;
+use ScandiPWA\Performance\Api\Inventory\AreProductsAssignedToStockInterface;
+
+class AreProductsAssignedToStock implements AreProductsAssignedToStockInterface
+{
+ /**
+ * @var ResourceConnection
+ */
+ protected ResourceConnection $resource;
+
+ /**
+ * @var array
+ */
+ protected $resultsByStockAndSku = [];
+
+ /**
+ * @param ResourceConnection $resource
+ */
+ public function __construct(
+ ResourceConnection $resource
+ ) {
+ $this->resource = $resource;
+ }
+
+ /**
+ * Cache wrapper for actual loading
+ * @param array $skuArray
+ * @param int $stockId
+ * @return array
+ */
+ public function execute(array $skuArray, int $stockId): array
+ {
+ $resultsBySku = [];
+ $loadSkus = [];
+
+ foreach ($skuArray as $sku) {
+ if (isset($this->resultsByStockAndSku[$stockId][$sku])) {
+ $resultsBySku[$sku] = $this->resultsByStockAndSku[$stockId][$sku];
+ } else {
+ $loadSkus[] = $sku;
+ $resultsBySku[$sku] = null;
+ }
+ }
+
+ if (count($loadSkus)) {
+ $results = $this->getAreProductsAssignedToStock($loadSkus, $stockId);
+
+ foreach ($results as $sku => $result) {
+ $this->resultsByStockAndSku[$stockId][$sku] = $result;
+ $resultsBySku[$sku] = $result;
+ }
+ }
+
+ return $resultsBySku;
+ }
+
+ /**
+ * Checks if products are assigned to stock, by sku list and stock id
+ * @param array $skuArray
+ * @param int $stockId
+ * @return array
+ */
+ public function getAreProductsAssignedToStock(array $skuArray, int $stockId): array
+ {
+ $finalResults = [];
+
+ foreach ($skuArray as $sku) {
+ $finalResults[$sku] = false;
+ }
+
+ $connection = $this->resource->getConnection();
+ $select = $connection->select()
+ ->from(
+ ['stock_source_link' => $this->resource->getTableName(StockSourceLink::TABLE_NAME_STOCK_SOURCE_LINK)]
+ )->join(
+ ['inventory_source_item' => $this->resource->getTableName(SourceItem::TABLE_NAME_SOURCE_ITEM)],
+ 'inventory_source_item.' . SourceItemInterface::SOURCE_CODE . '
+ = stock_source_link.' . SourceItemInterface::SOURCE_CODE,
+ ['inventory_source_item.sku']
+ )->where(
+ 'stock_source_link.' . StockSourceLinkInterface::STOCK_ID . ' = ?',
+ $stockId
+ )->where(
+ 'inventory_source_item.' . SourceItemInterface::SKU . ' IN (?)',
+ $skuArray
+ )->group('inventory_source_item.' . SourceItemInterface::SKU);
+
+ $results = $connection->fetchAll($select);
+
+ foreach ($results as $result) {
+ if (isset($result['sku'])) {
+ $finalResults[$result['sku']] = true;
+ }
+ }
+
+ return $finalResults;
+ }
+}
diff --git a/src/Model/Inventory/AreProductsSalableCondition/AreSalableWithReservationsCondition.php b/src/Model/Inventory/AreProductsSalableCondition/AreSalableWithReservationsCondition.php
new file mode 100644
index 0000000..0d523bc
--- /dev/null
+++ b/src/Model/Inventory/AreProductsSalableCondition/AreSalableWithReservationsCondition.php
@@ -0,0 +1,116 @@
+
+ * @copyright Copyright (c) 2022 Scandiweb, Inc (https://scandiweb.com)
+ * @license http://opensource.org/licenses/OSL-3.0 The Open Software License 3.0 (OSL-3.0)
+ */
+declare(strict_types=1);
+
+namespace ScandiPWA\Performance\Model\Inventory\AreProductsSalableCondition;
+
+use Magento\InventoryCatalogApi\Model\GetProductTypesBySkusInterface;
+use Magento\InventoryConfigurationApi\Api\GetStockItemConfigurationInterface;
+use Magento\InventoryConfigurationApi\Model\IsSourceItemManagementAllowedForProductTypeInterface;
+use ScandiPWA\Performance\Api\Inventory\GetReservationsQuantitiesInterface;
+use ScandiPWA\Performance\Api\Inventory\GetStockItemsDataInterface;
+use ScandiPWA\Performance\Api\Inventory\AreProductsSalableInterface;
+use ScandiPWA\Performance\Api\Inventory\GetStockItemsConfigurationsInterface;
+
+class AreSalableWithReservationsCondition implements AreProductsSalableInterface
+{
+ /**
+ * @var GetStockItemsConfigurationsInterface
+ */
+ protected GetStockItemsConfigurationsInterface $getStockItemsConfigurations;
+
+ /**
+ * @var GetProductTypesBySkusInterface
+ */
+ protected GetProductTypesBySkusInterface $getProductTypesBySkus;
+
+ /**
+ * @var IsSourceItemManagementAllowedForProductTypeInterface
+ */
+ protected IsSourceItemManagementAllowedForProductTypeInterface $isSourceItemManagementAllowedForProductType;
+
+ /**
+ * @var GetStockItemsDataInterface
+ */
+ protected GetStockItemsDataInterface $getStockItemsData;
+
+ /**
+ * @var GetReservationsQuantitiesInterface
+ */
+ protected GetReservationsQuantitiesInterface $getReservationsQuantities;
+
+ /**
+ * @var GetStockItemConfigurationInterface
+ */
+ protected GetStockItemConfigurationInterface $getStockItemConfiguration;
+
+ /**
+ * @param GetStockItemsConfigurationsInterface $getStockItemsConfigurations
+ * @param GetStockItemsDataInterface $getStockItemsData
+ * @param GetReservationsQuantitiesInterface $getReservationsQuantities
+ * @param IsSourceItemManagementAllowedForProductTypeInterface $isSourceItemManagementAllowedForProductType
+ * @param GetProductTypesBySkusInterface $getProductTypesBySkus
+ */
+ public function __construct(
+ GetStockItemsConfigurationsInterface $getStockItemsConfigurations,
+ GetStockItemsDataInterface $getStockItemsData,
+ GetReservationsQuantitiesInterface $getReservationsQuantities,
+ IsSourceItemManagementAllowedForProductTypeInterface $isSourceItemManagementAllowedForProductType,
+ GetProductTypesBySkusInterface $getProductTypesBySkus
+ ) {
+ $this->getStockItemsConfigurations = $getStockItemsConfigurations;
+ $this->getStockItemsData = $getStockItemsData;
+ $this->getReservationsQuantities = $getReservationsQuantities;
+ $this->isSourceItemManagementAllowedForProductType = $isSourceItemManagementAllowedForProductType;
+ $this->getProductTypesBySkus = $getProductTypesBySkus;
+ }
+
+ /**
+ * @param array $skuArray
+ * @param int $stockId
+ * @return array
+ */
+ public function execute(array $skuArray, int $stockId): array
+ {
+ $result = [];
+ $skusToCheck = array_flip($skuArray);
+
+ $stockItemDataArray = $this->getStockItemsData->execute($skuArray, $stockId);
+
+ foreach ($stockItemDataArray as $sku => $stockItemData) {
+ if (null === $stockItemData) {
+ // Sku is not assigned to Stock
+ $result[$sku] = false;
+ unset($skusToCheck[$sku]);
+ continue;
+ }
+
+ // these values will be taken from cache, so can be executed individually
+ $productType = $this->getProductTypesBySkus->execute([$sku])[$sku];
+
+ // source item management not active for product type, do not check reservations
+ if (false === $this->isSourceItemManagementAllowedForProductType->execute($productType)) {
+ $result[$sku] = (bool)$stockItemData[GetStockItemsDataInterface::IS_SALABLE];
+ unset($skusToCheck[$sku]);
+ }
+ }
+
+ if (count($skusToCheck)) {
+ // need to check reservations for the remaining skus
+ $stockItemConfigurations = $this->getStockItemsConfigurations->execute(array_keys($skusToCheck), $stockId);
+ $reservationQtys = $this->getReservationsQuantities->execute(array_keys($skusToCheck), $stockId);
+
+ foreach ($stockItemConfigurations as $sku => $stockItemConfiguration) {
+ $qtyWithReservation = $stockItemDataArray[$sku][GetStockItemsDataInterface::QUANTITY] + $reservationQtys[$sku];
+ $result[$sku] = $qtyWithReservation > $stockItemConfiguration->getMinQty();
+ }
+ }
+
+ return $result;
+ }
+}
diff --git a/src/Model/Inventory/AreProductsSalableCondition/BackOrderCondition.php b/src/Model/Inventory/AreProductsSalableCondition/BackOrderCondition.php
new file mode 100644
index 0000000..b06eb01
--- /dev/null
+++ b/src/Model/Inventory/AreProductsSalableCondition/BackOrderCondition.php
@@ -0,0 +1,60 @@
+
+ * @copyright Copyright (c) 2022 Scandiweb, Inc (https://scandiweb.com)
+ * @license http://opensource.org/licenses/OSL-3.0 The Open Software License 3.0 (OSL-3.0)
+ */
+declare(strict_types=1);
+
+namespace ScandiPWA\Performance\Model\Inventory\AreProductsSalableCondition;
+
+use Magento\InventoryConfigurationApi\Api\Data\StockItemConfigurationInterface;
+use ScandiPWA\Performance\Api\Inventory\AreProductsSalableInterface;
+use ScandiPWA\Performance\Api\Inventory\GetStockItemsConfigurationsInterface;
+
+class BackOrderCondition implements AreProductsSalableInterface
+{
+ /**
+ * @var GetStockItemsConfigurationsInterface
+ */
+ protected GetStockItemsConfigurationsInterface $getStockItemsConfigurations;
+
+ /**
+ * @param GetStockItemsConfigurationsInterface $getStockItemsConfigurations
+ */
+ public function __construct(
+ GetStockItemsConfigurationsInterface $getStockItemsConfigurations
+ ) {
+ $this->getStockItemsConfigurations = $getStockItemsConfigurations;
+ }
+
+ /**
+ * @param array $skuArray
+ * @param int $stockId
+ * @return array
+ */
+ public function execute(array $skuArray, int $stockId): array
+ {
+ $result = [];
+
+ $stockItemConfigurations = $this->getStockItemsConfigurations->execute($skuArray, $stockId);
+
+ foreach ($stockItemConfigurations as $sku => $stockItemConfiguration) {
+ if (!$stockItemConfiguration) {
+ $result[$sku] = false;
+
+ continue;
+ }
+
+ if ($stockItemConfiguration->getBackorders() !== StockItemConfigurationInterface::BACKORDERS_NO
+ && $stockItemConfiguration->getMinQty() >= 0) {
+ $result[$sku] = true;
+ } else {
+ $result[$sku] = false;
+ }
+ }
+
+ return $result;
+ }
+}
diff --git a/src/Model/Inventory/AreProductsSalableCondition/IsAnySourceItemInStockCondition.php b/src/Model/Inventory/AreProductsSalableCondition/IsAnySourceItemInStockCondition.php
new file mode 100644
index 0000000..7af2a0a
--- /dev/null
+++ b/src/Model/Inventory/AreProductsSalableCondition/IsAnySourceItemInStockCondition.php
@@ -0,0 +1,160 @@
+
+ * @copyright Copyright (c) 2022 Scandiweb, Inc (https://scandiweb.com)
+ * @license http://opensource.org/licenses/OSL-3.0 The Open Software License 3.0 (OSL-3.0)
+ */
+declare(strict_types=1);
+
+namespace ScandiPWA\Performance\Model\Inventory\AreProductsSalableCondition;
+
+use Magento\Framework\Api\SearchCriteria\CollectionProcessorInterface;
+use Magento\Framework\Api\SearchCriteriaBuilder;
+use Magento\Framework\Exception\InputException;
+use Magento\Framework\Exception\LocalizedException;
+use Magento\Inventory\Model\ResourceModel\SourceItem\Collection;
+use Magento\Inventory\Model\SourceItem;
+use Magento\InventoryApi\Api\Data\SourceItemInterface;
+use Magento\InventoryApi\Api\GetSourcesAssignedToStockOrderedByPriorityInterface;
+use Magento\InventoryApi\Api\SourceItemRepositoryInterface;
+use Magento\InventoryConfigurationApi\Model\IsSourceItemManagementAllowedForSkuInterface;
+use Magento\Inventory\Model\ResourceModel\SourceItem\CollectionFactory;
+use ScandiPWA\Performance\Api\Inventory\AreProductsSalableInterface;
+
+class IsAnySourceItemInStockCondition implements AreProductsSalableInterface
+{
+ /**
+ * @var SourceItemRepositoryInterface
+ */
+ protected SourceItemRepositoryInterface $sourceItemRepository;
+
+ /**
+ * @var SearchCriteriaBuilder
+ */
+ protected SearchCriteriaBuilder $searchCriteriaBuilder;
+
+ /**
+ * @var GetSourcesAssignedToStockOrderedByPriorityInterface
+ */
+ protected GetSourcesAssignedToStockOrderedByPriorityInterface $getSourcesAssignedToStockOrderedByPriority;
+
+ /**
+ * @var IsSourceItemManagementAllowedForSkuInterface
+ */
+ protected IsSourceItemManagementAllowedForSkuInterface $isSourceItemManagementAllowedForSku;
+
+ /**
+ * @var ManageStockCondition
+ */
+ protected ManageStockCondition $manageStockCondition;
+
+ /**
+ * @var CollectionProcessorInterface
+ */
+ protected CollectionProcessorInterface $collectionProcessor;
+
+ /**
+ * @var CollectionFactory
+ */
+ protected CollectionFactory $sourceItemCollectionFactory;
+
+ /**
+ * @param SourceItemRepositoryInterface $sourceItemRepository
+ * @param SearchCriteriaBuilder $searchCriteriaBuilder
+ * @param GetSourcesAssignedToStockOrderedByPriorityInterface $getSourcesAssignedToStockOrderedByPriority
+ * @param IsSourceItemManagementAllowedForSkuInterface $isSourceItemManagementAllowedForSku
+ * @param ManageStockCondition $manageStockCondition
+ * @param CollectionProcessorInterface $collectionProcessor
+ * @param CollectionFactory $sourceItemCollectionFactory
+ */
+ public function __construct(
+ SourceItemRepositoryInterface $sourceItemRepository,
+ SearchCriteriaBuilder $searchCriteriaBuilder,
+ GetSourcesAssignedToStockOrderedByPriorityInterface $getSourcesAssignedToStockOrderedByPriority,
+ IsSourceItemManagementAllowedForSkuInterface $isSourceItemManagementAllowedForSku,
+ ManageStockCondition $manageStockCondition,
+ CollectionProcessorInterface $collectionProcessor,
+ CollectionFactory $sourceItemCollectionFactory
+ ) {
+ $this->sourceItemRepository = $sourceItemRepository;
+ $this->searchCriteriaBuilder = $searchCriteriaBuilder;
+ $this->getSourcesAssignedToStockOrderedByPriority = $getSourcesAssignedToStockOrderedByPriority;
+ $this->isSourceItemManagementAllowedForSku = $isSourceItemManagementAllowedForSku;
+ $this->manageStockCondition = $manageStockCondition;
+ $this->collectionProcessor = $collectionProcessor;
+ $this->sourceItemCollectionFactory = $sourceItemCollectionFactory;
+ }
+
+ /**
+ * @param array $skuArray
+ * @param int $stockId
+ * @return array
+ * @throws InputException
+ * @throws LocalizedException
+ */
+ public function execute(array $skuArray, int $stockId): array
+ {
+ $result = $this->manageStockCondition->execute($skuArray, $stockId);
+
+ $skusToCheck = [];
+
+ foreach ($result as $sku => $value) {
+ // if value is true, that is a final result; otherwise, proceed with the next checks
+ if (!$value) {
+ $skusToCheck[$sku] = null;
+ }
+ }
+
+ foreach (array_keys($skusToCheck) as $sku) {
+ if (!$this->isSourceItemManagementAllowedForSku->execute((string)$sku)) {
+ // if source item management is not allowed, that is a final result for that sku
+ $result[$sku] = true;
+
+ unset($skusToCheck[$sku]);
+ }
+ }
+
+ $sourceCodes = $this->getSourceCodesAssignedToStock($stockId);
+
+ $searchCriteria = $this->searchCriteriaBuilder
+ ->addFilter(SourceItemInterface::SKU, array_keys($skusToCheck), 'in')
+ ->addFilter(SourceItemInterface::SOURCE_CODE, $sourceCodes, 'in')
+ ->addFilter(SourceItemInterface::STATUS, SourceItemInterface::STATUS_IN_STOCK)
+ ->create();
+
+ /** @var Collection $collection */
+ $collection = $this->sourceItemCollectionFactory->create();
+ $this->collectionProcessor->process($searchCriteria, $collection);
+
+ /** @var SourceItem $item */
+ foreach ($collection as $item) {
+ $result[$item->getSku()] = true;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Provides source codes for certain stock
+ *
+ * @param int $stockId
+ *
+ * @return array
+ * @throws InputException
+ * @throws LocalizedException
+ */
+ protected function getSourceCodesAssignedToStock(int $stockId): array
+ {
+ $sources = $this->getSourcesAssignedToStockOrderedByPriority->execute($stockId);
+ $sourceCodes = [];
+
+ foreach ($sources as $source) {
+ if ($source->isEnabled()) {
+ $sourceCodes[] = $source->getSourceCode();
+ }
+ }
+
+ return $sourceCodes;
+ }
+}
diff --git a/src/Model/Inventory/AreProductsSalableCondition/IsSetInStockStatusForCompositeProductsCondition.php b/src/Model/Inventory/AreProductsSalableCondition/IsSetInStockStatusForCompositeProductsCondition.php
new file mode 100644
index 0000000..beed97a
--- /dev/null
+++ b/src/Model/Inventory/AreProductsSalableCondition/IsSetInStockStatusForCompositeProductsCondition.php
@@ -0,0 +1,67 @@
+
+ * @copyright Copyright (c) 2022 Scandiweb, Inc (https://scandiweb.com)
+ * @license http://opensource.org/licenses/OSL-3.0 The Open Software License 3.0 (OSL-3.0)
+ */
+declare(strict_types=1);
+
+namespace ScandiPWA\Performance\Model\Inventory\AreProductsSalableCondition;
+
+use Magento\InventoryConfigurationApi\Model\IsSourceItemManagementAllowedForSkuInterface;
+use ScandiPWA\Performance\Api\Inventory\AreProductsSalableInterface;
+use ScandiPWA\Performance\Api\Inventory\GetStockItemsConfigurationsInterface;
+
+class IsSetInStockStatusForCompositeProductsCondition implements AreProductsSalableInterface
+{
+ /**
+ * @var GetStockItemsConfigurationsInterface
+ */
+ protected GetStockItemsConfigurationsInterface $getStockItemsConfigurations;
+
+ /**
+ * @var IsSourceItemManagementAllowedForSkuInterface
+ */
+ protected IsSourceItemManagementAllowedForSkuInterface $isSourceItemManagementAllowedForSku;
+
+ /**
+ * @param IsSourceItemManagementAllowedForSkuInterface $isSourceItemManagementAllowedForSku
+ * @param GetStockItemsConfigurationsInterface $getStockItemsConfigurations
+ */
+ public function __construct(
+ IsSourceItemManagementAllowedForSkuInterface $isSourceItemManagementAllowedForSku,
+ GetStockItemsConfigurationsInterface $getStockItemsConfigurations
+ ) {
+ $this->getStockItemsConfigurations = $getStockItemsConfigurations;
+ $this->isSourceItemManagementAllowedForSku = $isSourceItemManagementAllowedForSku;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function execute(array $skuArray, int $stockId): array
+ {
+ $result = [];
+ $skusToCheck = array_flip($skuArray);
+
+ foreach ($skuArray as $sku) {
+ if ($this->isSourceItemManagementAllowedForSku->execute((string)$sku)) {
+ $result[$sku] = true;
+ unset($skusToCheck[$sku]);
+ }
+ }
+
+ $stockItemsConfigurations = $this->getStockItemsConfigurations->execute(array_keys($skusToCheck), $stockId);
+
+ foreach ($stockItemsConfigurations as $sku => $stockItemConfiguration) {
+ if ($stockItemConfiguration) {
+ $result[$sku] = $stockItemConfiguration->getExtensionAttributes()->getIsInStock();
+ } else {
+ $result[$sku] = false;
+ }
+ }
+
+ return $result;
+ }
+}
diff --git a/src/Model/Inventory/AreProductsSalableCondition/ManageStockCondition.php b/src/Model/Inventory/AreProductsSalableCondition/ManageStockCondition.php
new file mode 100644
index 0000000..d15b87b
--- /dev/null
+++ b/src/Model/Inventory/AreProductsSalableCondition/ManageStockCondition.php
@@ -0,0 +1,67 @@
+
+ * @copyright Copyright (c) 2022 Scandiweb, Inc (https://scandiweb.com)
+ * @license http://opensource.org/licenses/OSL-3.0 The Open Software License 3.0 (OSL-3.0)
+ */
+declare(strict_types=1);
+
+namespace ScandiPWA\Performance\Model\Inventory\AreProductsSalableCondition;
+
+use Magento\CatalogInventory\Api\StockConfigurationInterface;
+use ScandiPWA\Performance\Api\Inventory\AreProductsSalableInterface;
+use ScandiPWA\Performance\Api\Inventory\GetStockItemsConfigurationsInterface;
+
+class ManageStockCondition implements AreProductsSalableInterface
+{
+ /**
+ * @var StockConfigurationInterface
+ */
+ protected StockConfigurationInterface $configuration;
+
+ /**
+ * @var GetStockItemsConfigurationsInterface
+ */
+ protected GetStockItemsConfigurationsInterface $getStockItemsConfigurations;
+
+ /**
+ * @param StockConfigurationInterface $configuration
+ * @param GetStockItemsConfigurationsInterface $getStockItemsConfigurations
+ */
+ public function __construct(
+ StockConfigurationInterface $configuration,
+ GetStockItemsConfigurationsInterface $getStockItemsConfigurations
+ ) {
+ $this->getStockItemsConfigurations = $getStockItemsConfigurations;
+ $this->configuration = $configuration;
+ }
+
+ /**
+ * @param array $skuArray
+ * @param int $stockId
+ * @return array
+ */
+ public function execute(array $skuArray, int $stockId): array
+ {
+ $result = [];
+
+ $stockItemConfigurations = $this->getStockItemsConfigurations->execute($skuArray, $stockId);
+
+ foreach ($stockItemConfigurations as $sku => $stockItemConfiguration) {
+ if (!$stockItemConfiguration) {
+ $result[$sku] = false;
+
+ continue;
+ }
+
+ if ($stockItemConfiguration->isUseConfigManageStock()) {
+ $result[$sku] = $this->configuration->getManageStock() !== 1;
+ } else {
+ $result[$sku] = !$stockItemConfiguration->isManageStock();
+ }
+ }
+
+ return $result;
+ }
+}
diff --git a/src/Model/Inventory/AreProductsSalableConditionChain.php b/src/Model/Inventory/AreProductsSalableConditionChain.php
new file mode 100644
index 0000000..f8f1c79
--- /dev/null
+++ b/src/Model/Inventory/AreProductsSalableConditionChain.php
@@ -0,0 +1,145 @@
+
+ * @copyright Copyright (c) 2022 Scandiweb, Inc (https://scandiweb.com)
+ * @license http://opensource.org/licenses/OSL-3.0 The Open Software License 3.0 (OSL-3.0)
+ */
+declare(strict_types=1);
+
+namespace ScandiPWA\Performance\Model\Inventory;
+
+use Magento\Framework\Exception\LocalizedException;
+use ScandiPWA\Performance\Api\Inventory\AreProductsSalableInterface;
+
+class AreProductsSalableConditionChain implements AreProductsSalableInterface
+{
+ /**
+ * @var AreProductsSalableInterface[]
+ */
+ protected array $unrequiredConditions;
+
+ /**
+ * @var AreProductsSalableInterface[]
+ */
+ protected array $requiredConditions;
+
+ /**
+ * @param array $conditions
+ * @throws LocalizedException
+ */
+ public function __construct(
+ array $conditions
+ ) {
+ $this->setConditions($conditions);
+ }
+
+ /**
+ * @param array $conditions
+ * @throws LocalizedException
+ */
+ protected function setConditions(array $conditions)
+ {
+ $this->validateConditions($conditions);
+
+ $unrequiredConditions = array_filter(
+ $conditions,
+ function ($item) {
+ return !isset($item['required']);
+ }
+ );
+
+ $this->unrequiredConditions = array_column($this->sortConditions($unrequiredConditions), 'object');
+
+ $requiredConditions = array_filter(
+ $conditions,
+ function ($item) {
+ return isset($item['required']) && $item['required'];
+ }
+ );
+
+ $this->requiredConditions = array_column($requiredConditions, 'object');
+ }
+
+ /**
+ * @param array $conditions
+ * @throws LocalizedException
+ */
+ protected function validateConditions(array $conditions)
+ {
+ foreach ($conditions as $condition) {
+ if (empty($condition['object'])) {
+ throw new LocalizedException(__('Parameter "object" must be present.'));
+ }
+
+ if (empty($condition['required']) && empty($condition['sort_order'])) {
+ throw new LocalizedException(__('Parameter "sort_order" must be present for unrequired conditions.'));
+ }
+
+ if (!$condition['object'] instanceof AreProductsSalableInterface) {
+ throw new LocalizedException(
+ __('Condition has to implement AreProductsSalableInterface.')
+ );
+ }
+ }
+ }
+
+ /**
+ * @param array $conditions
+ * @return array
+ */
+ protected function sortConditions(array $conditions): array
+ {
+ usort($conditions, function (array $conditionLeft, array $conditionRight) {
+ if ($conditionLeft['sort_order'] == $conditionRight['sort_order']) {
+ return 0;
+ }
+
+ return ($conditionLeft['sort_order'] < $conditionRight['sort_order']) ? -1 : 1;
+ });
+
+ return $conditions;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function execute(array $skuArray, int $stockId): array
+ {
+ $finalResults = [];
+
+ // default value - false for all skus
+ foreach ($skuArray as $sku) {
+ $finalResults[$sku] = false;
+ }
+
+ // if one of the required check fails, mark this result and skip unrequired conditions
+ $skusToCheck = array_flip($skuArray);
+
+ foreach ($this->requiredConditions as $requiredCondition) {
+ $results = $requiredCondition->execute(array_keys($skusToCheck), $stockId);
+
+ foreach ($results as $sku => $result) {
+ if (!$result) {
+ $finalResults[$sku] = $result;
+ unset($skusToCheck[$sku]);
+ }
+ }
+ }
+
+ // if least one of these conditions returns True, final result for that sku can change
+ // (stock not managed, or backorders enabled, or available despite reservations)
+ foreach ($this->unrequiredConditions as $unrequiredCondition) {
+ $results = $unrequiredCondition->execute(array_keys($skusToCheck), $stockId);
+
+ foreach ($results as $sku => $result) {
+ if ($result) {
+ $finalResults[$sku] = $result;
+ unset($skusToCheck[$sku]);
+ }
+ }
+ }
+
+ return $finalResults;
+ }
+}
diff --git a/src/Model/Inventory/GetLegacyStockItems.php b/src/Model/Inventory/GetLegacyStockItems.php
new file mode 100644
index 0000000..82602c0
--- /dev/null
+++ b/src/Model/Inventory/GetLegacyStockItems.php
@@ -0,0 +1,140 @@
+
+ * @copyright Copyright (c) 2022 Scandiweb, Inc (https://scandiweb.com)
+ * @license http://opensource.org/licenses/OSL-3.0 The Open Software License 3.0 (OSL-3.0)
+ */
+declare(strict_types=1);
+
+namespace ScandiPWA\Performance\Model\Inventory;
+
+use Magento\CatalogInventory\Api\Data\StockItemInterface;
+use Magento\CatalogInventory\Api\Data\StockItemInterfaceFactory;
+use Magento\CatalogInventory\Api\StockItemRepositoryInterface;
+use Magento\CatalogInventory\Api\StockItemCriteriaInterfaceFactory;
+use Magento\CatalogInventory\Model\Stock;
+
+class GetLegacyStockItems
+{
+ /**
+ * @var StockItemInterfaceFactory
+ */
+ protected StockItemInterfaceFactory $stockItemFactory;
+
+ /**
+ * @var StockItemCriteriaInterfaceFactory
+ */
+ protected StockItemCriteriaInterfaceFactory $legacyStockItemCriteriaFactory;
+
+ /**
+ * @var StockItemRepositoryInterface
+ */
+ protected StockItemRepositoryInterface $legacyStockItemRepository;
+
+ /**
+ * @var GetProductIdsBySkus
+ */
+ protected GetProductIdsBySkus $getProductIdsBySkus;
+
+ /**
+ * Cached results
+ * @var array
+ */
+ protected array $legacyStockItemsBySku = [];
+
+ /**
+ * @param StockItemInterfaceFactory $stockItemFactory
+ * @param StockItemCriteriaInterfaceFactory $legacyStockItemCriteriaFactory
+ * @param StockItemRepositoryInterface $legacyStockItemRepository
+ * @param GetProductIdsBySkus $getProductIdsBySkus
+ */
+ public function __construct(
+ StockItemInterfaceFactory $stockItemFactory,
+ StockItemCriteriaInterfaceFactory $legacyStockItemCriteriaFactory,
+ StockItemRepositoryInterface $legacyStockItemRepository,
+ GetProductIdsBySkus $getProductIdsBySkus
+ ) {
+ $this->stockItemFactory = $stockItemFactory;
+ $this->legacyStockItemCriteriaFactory = $legacyStockItemCriteriaFactory;
+ $this->legacyStockItemRepository = $legacyStockItemRepository;
+ $this->getProductIdsBySkus = $getProductIdsBySkus;
+ }
+
+ /**
+ * Get legacy stock item entity by sku.
+ *
+ * @param array $skuArray
+ * @return StockItemInterface[]
+ */
+ public function execute(array $skuArray): array
+ {
+ $resultsBySku = [];
+ $loadSkus = [];
+
+ foreach ($skuArray as $sku) {
+ if (isset($this->legacyStockItemsBySku[$sku])) {
+ $resultsBySku[$sku] = $this->legacyStockItemsBySku[$sku];
+ } else {
+ $loadSkus[] = $sku;
+ $resultsBySku[$sku] = null;
+ }
+ }
+
+ if (count($loadSkus)) {
+ $legacyStockItems = $this->getLegacyStockItems($loadSkus);
+
+ foreach ($legacyStockItems as $sku => $legacyStockItem) {
+ $this->legacyStockItemsBySku[$sku] = $legacyStockItem;
+ $resultsBySku[$sku] = $legacyStockItem;
+ }
+ }
+
+ return $resultsBySku;
+ }
+
+ /**
+ * Get legacy stock item entities by skus
+ *
+ * @param array $skuArray
+ * @return StockItemInterface[]
+ */
+ public function getLegacyStockItems(array $skuArray): array
+ {
+ $results = [];
+ $productIds = $this->getProductIdsBySkus->execute($skuArray);
+
+ foreach ($productIds as $sku => $productId) {
+ if ($productId === null) {
+ $stockItem = $this->stockItemFactory->create();
+ // Make possible to Manage Stock for Products removed from Catalog
+ $stockItem->setManageStock(true);
+ $results[$sku] = $stockItem;
+ unset($productIds[$sku]);
+ }
+ }
+
+ $productSkusById = array_flip($productIds);
+
+ $searchCriteria = $this->legacyStockItemCriteriaFactory->create();
+ $searchCriteria->setProductsFilter($productIds);
+
+ // Stock::DEFAULT_STOCK_ID is used until we have proper multi-stock item configuration
+ $searchCriteria->addFilter(StockItemInterface::STOCK_ID, StockItemInterface::STOCK_ID, Stock::DEFAULT_STOCK_ID);
+ $stockItemCollection = $this->legacyStockItemRepository->getList($searchCriteria);
+
+ $stockItems = $stockItemCollection->getItems();
+
+ foreach ($stockItems as $stockItem) {
+ $results[$productSkusById[$stockItem->getProductId()]] = $stockItem;
+ }
+
+ foreach ($skuArray as $sku) {
+ if (!isset($results[$sku])) {
+ $results[$sku] = $this->stockItemFactory->create();
+ }
+ }
+
+ return $results;
+ }
+}
diff --git a/src/Model/Inventory/GetProductIdsBySkus.php b/src/Model/Inventory/GetProductIdsBySkus.php
new file mode 100644
index 0000000..878992f
--- /dev/null
+++ b/src/Model/Inventory/GetProductIdsBySkus.php
@@ -0,0 +1,47 @@
+
+ * @copyright Copyright (c) 2022 Scandiweb, Inc (https://scandiweb.com)
+ * @license http://opensource.org/licenses/OSL-3.0 The Open Software License 3.0 (OSL-3.0)
+ */
+declare(strict_types=1);
+
+namespace ScandiPWA\Performance\Model\Inventory;
+
+use Magento\Catalog\Model\ResourceModel\Product as ProductResourceModel;
+use Magento\InventoryCatalogApi\Model\GetProductIdsBySkusInterface;
+
+class GetProductIdsBySkus implements GetProductIdsBySkusInterface
+{
+ /**
+ * @var ProductResourceModel
+ */
+ protected ProductResourceModel $productResource;
+
+ /**
+ * @param ProductResourceModel $productResource
+ */
+ public function __construct(
+ ProductResourceModel $productResource
+ ) {
+ $this->productResource = $productResource;
+ }
+
+ /**
+ * @param array $skus
+ * @return array
+ */
+ public function execute(array $skus): array
+ {
+ $idsBySkus = $this->productResource->getProductsIdsBySkus($skus);
+ $notFoundSkus = array_diff($skus, array_keys($idsBySkus));
+
+ foreach ($notFoundSkus as $sku) {
+ // Rewrite: as opposed to throwing NoSuchEntityException
+ $idsBySkus[$sku] = null;
+ }
+
+ return $idsBySkus;
+ }
+}
diff --git a/src/Model/Inventory/GetProductIdsBySkusCache.php b/src/Model/Inventory/GetProductIdsBySkusCache.php
new file mode 100644
index 0000000..732ae3f
--- /dev/null
+++ b/src/Model/Inventory/GetProductIdsBySkusCache.php
@@ -0,0 +1,71 @@
+
+ * @copyright Copyright (c) 2022 Scandiweb, Inc (https://scandiweb.com)
+ * @license http://opensource.org/licenses/OSL-3.0 The Open Software License 3.0 (OSL-3.0)
+ */
+declare(strict_types=1);
+
+namespace ScandiPWA\Performance\Model\Inventory;
+
+use Magento\InventoryCatalog\Model\Cache\ProductIdsBySkusStorage;
+use Magento\InventoryCatalogApi\Model\GetProductIdsBySkusInterface;
+
+class GetProductIdsBySkusCache implements GetProductIdsBySkusInterface
+{
+ /**
+ * @var GetProductIdsBySkus
+ */
+ protected GetProductIdsBySkus $getProductIdsBySkus;
+
+ /**
+ * @var ProductIdsBySkusStorage
+ */
+ protected ProductIdsBySkusStorage $cache;
+
+ /**
+ * @param GetProductIdsBySkus $getProductIdsBySkus
+ * @param ProductIdsBySkusStorage $cache
+ */
+ public function __construct(
+ GetProductIdsBySkus $getProductIdsBySkus,
+ ProductIdsBySkusStorage $cache
+ ) {
+ $this->getProductIdsBySkus = $getProductIdsBySkus;
+ $this->cache = $cache;
+ }
+
+ /**
+ * Compared to core M2 - instead of throwing an exception, returns null for when sku does not exist
+ * @param array $skus
+ * @return array
+ */
+ public function execute(array $skus): array
+ {
+ $idsBySkus = [];
+ $loadSkus = [];
+
+ foreach ($skus as $sku) {
+ $id = $this->cache->get((string) $sku);
+
+ if ($id !== null) {
+ $idsBySkus[$sku] = $id;
+ } else {
+ $loadSkus[] = $sku;
+ $idsBySkus[$sku] = null;
+ }
+ }
+
+ if (count($loadSkus)) {
+ $loadedIdsBySkus = $this->getProductIdsBySkus->execute($loadSkus);
+
+ foreach ($loadedIdsBySkus as $sku => $id) {
+ $idsBySkus[$sku] = (int) $id;
+ $this->cache->set((string) $sku, (int) $id);
+ }
+ }
+
+ return $idsBySkus;
+ }
+}
diff --git a/src/Model/Inventory/GetReservationsQuantities.php b/src/Model/Inventory/GetReservationsQuantities.php
new file mode 100644
index 0000000..5b18a6c
--- /dev/null
+++ b/src/Model/Inventory/GetReservationsQuantities.php
@@ -0,0 +1,106 @@
+
+ * @copyright Copyright (c) 2022 Scandiweb, Inc (https://scandiweb.com)
+ * @license http://opensource.org/licenses/OSL-3.0 The Open Software License 3.0 (OSL-3.0)
+ */
+declare(strict_types=1);
+
+namespace ScandiPWA\Performance\Model\Inventory;
+
+use Magento\Framework\App\ResourceConnection;
+use Magento\InventoryReservationsApi\Model\ReservationInterface;
+use ScandiPWA\Performance\Api\Inventory\GetReservationsQuantitiesInterface;
+
+class GetReservationsQuantities implements GetReservationsQuantitiesInterface
+{
+ /**
+ * @var ResourceConnection
+ */
+ protected ResourceConnection $resource;
+ /**
+ * Cached results
+ * @var array
+ */
+ protected array $reservationQuantitiesByStockAndSku = [];
+
+ /**
+ * @param ResourceConnection $resource
+ */
+ public function __construct(
+ ResourceConnection $resource
+ ) {
+ $this->resource = $resource;
+ }
+
+ /**
+ * Get item reservations by sku array and stock
+ * Cache wrapper
+ *
+ * @param array $skuArray
+ * @param int $stockId
+ * @return array
+ */
+ public function execute(array $skuArray, int $stockId): array
+ {
+ $resultsBySku = [];
+ $loadSkus = [];
+
+ foreach ($skuArray as $sku) {
+ if (isset($this->reservationQuantitiesByStockAndSku[$stockId][$sku])) {
+ $resultsBySku[$sku] = $this->reservationQuantitiesByStockAndSku[$stockId][$sku];
+ } else {
+ $loadSkus[] = $sku;
+ $resultsBySku[$sku] = null;
+ }
+ }
+
+ if (count($loadSkus)) {
+ $reservationsDatas = $this->getReservationsData($loadSkus, $stockId);
+
+ foreach ($reservationsDatas as $sku => $reservationsData) {
+ $this->reservationQuantitiesByStockAndSku[$stockId][$sku] = $reservationsData;
+ $resultsBySku[$sku] = $reservationsData;
+ }
+ }
+
+ return $resultsBySku;
+ }
+
+ /**
+ * Get item reservations by skus and stock
+ *
+ * @param array $skuArray
+ * @param int $stockId
+ * @return array
+ */
+ public function getReservationsData(array $skuArray, int $stockId): array
+ {
+ $result = [];
+
+ foreach ($skuArray as $sku) {
+ $result[$sku] = (float)0;
+ }
+
+ $connection = $this->resource->getConnection();
+ $reservationTable = $this->resource->getTableName('inventory_reservation');
+
+ $select = $connection->select()
+ ->from($reservationTable,
+ [
+ ReservationInterface::QUANTITY => 'SUM(' . ReservationInterface::QUANTITY . ')',
+ ReservationInterface::SKU
+ ]
+ )
+ ->where(ReservationInterface::SKU . ' IN (?)', $skuArray)
+ ->where(ReservationInterface::STOCK_ID . ' = ?', $stockId)
+ ->group(ReservationInterface::SKU);
+
+ foreach ($connection->fetchAll($select) as $row) {
+ $result[$row[ReservationInterface::SKU]] = (float)$row[ReservationInterface::QUANTITY];
+ }
+
+ return $result;
+ }
+}
diff --git a/src/Model/Inventory/GetStockItemsConfigurations.php b/src/Model/Inventory/GetStockItemsConfigurations.php
new file mode 100644
index 0000000..b32dee0
--- /dev/null
+++ b/src/Model/Inventory/GetStockItemsConfigurations.php
@@ -0,0 +1,115 @@
+
+ * @copyright Copyright (c) 2022 Scandiweb, Inc (https://scandiweb.com)
+ * @license http://opensource.org/licenses/OSL-3.0 The Open Software License 3.0 (OSL-3.0)
+ */
+declare(strict_types=1);
+
+namespace ScandiPWA\Performance\Model\Inventory;
+
+use Magento\InventoryCatalogApi\Api\DefaultStockProviderInterface;
+use Magento\InventoryCatalogApi\Model\GetProductTypesBySkusInterface;
+use Magento\InventoryConfigurationApi\Model\IsSourceItemManagementAllowedForSkuInterface;
+use Magento\InventoryConfiguration\Model\StockItemConfigurationFactory;
+use ScandiPWA\Performance\Api\Inventory\GetStockItemsConfigurationsInterface;
+use ScandiPWA\Performance\Api\Inventory\AreProductsAssignedToStockInterface;
+
+class GetStockItemsConfigurations implements GetStockItemsConfigurationsInterface
+{
+ /**
+ * @var GetLegacyStockItems
+ */
+ protected GetLegacyStockItems $getLegacyStockItems;
+
+ /**
+ * @var StockItemConfigurationFactory
+ */
+ protected StockItemConfigurationFactory $stockItemConfigurationFactory;
+
+ /**
+ * @var DefaultStockProviderInterface
+ */
+ protected DefaultStockProviderInterface $defaultStockProvider;
+
+ /**
+ * @var IsSourceItemManagementAllowedForSkuInterface
+ */
+ protected IsSourceItemManagementAllowedForSkuInterface $isSourceItemManagementAllowedForSku;
+
+ /**
+ * @var GetProductTypesBySkusInterface
+ */
+ protected GetProductTypesBySkusInterface $getProductTypesBySkus;
+
+ /**
+ * @var AreProductsAssignedToStockInterface
+ */
+ protected AreProductsAssignedToStockInterface $areProductsAssignedToStock;
+
+ /**
+ * @param GetLegacyStockItems $getLegacyStockItems
+ * @param StockItemConfigurationFactory $stockItemConfigurationFactory
+ * @param AreProductsAssignedToStockInterface $areProductsAssignedToStock
+ * @param DefaultStockProviderInterface $defaultStockProvider
+ * @param IsSourceItemManagementAllowedForSkuInterface $isSourceItemManagementAllowedForSku
+ * @param GetProductTypesBySkusInterface $getProductTypesBySkus
+ */
+ public function __construct(
+ GetLegacyStockItems $getLegacyStockItems,
+ StockItemConfigurationFactory $stockItemConfigurationFactory,
+ AreProductsAssignedToStockInterface $areProductsAssignedToStock,
+ DefaultStockProviderInterface $defaultStockProvider,
+ IsSourceItemManagementAllowedForSkuInterface $isSourceItemManagementAllowedForSku,
+ GetProductTypesBySkusInterface $getProductTypesBySkus
+ ) {
+ $this->getLegacyStockItems = $getLegacyStockItems;
+ $this->stockItemConfigurationFactory = $stockItemConfigurationFactory;
+ $this->defaultStockProvider = $defaultStockProvider;
+ $this->isSourceItemManagementAllowedForSku = $isSourceItemManagementAllowedForSku;
+ $this->getProductTypesBySkus = $getProductTypesBySkus;
+ $this->areProductsAssignedToStock = $areProductsAssignedToStock;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function execute(array $skuArray, int $stockId): array
+ {
+ // this will load at once & cache the product types for all skus, is used in multiple operations later
+ $this->getProductTypesBySkus->execute($skuArray);
+ $areProductsAssignedToStock = $this->areProductsAssignedToStock->execute($skuArray, $stockId);
+
+ $result = [];
+ $skusToCheck = array_flip($skuArray);
+
+ foreach ($skuArray as $sku) {
+ if ($this->defaultStockProvider->getId() !== $stockId
+ && true === $this->isSourceItemManagementAllowedForSku->execute((string)$sku)
+ && false === $areProductsAssignedToStock[$sku]) {
+ // used instead of SkuIsNotAssignedToStockException in core
+ $result[$sku] = false;
+
+ unset($skusToCheck[$sku]);
+ }
+ }
+
+ $stockItems = $this->getLegacyStockItems->execute(array_keys($skusToCheck));
+
+ foreach ($stockItems as $sku => $stockItem) {
+ $result[$sku] = $this->stockItemConfigurationFactory->create(
+ [
+ 'stockItem' => $stockItem
+ ]
+ );
+
+ // logic from LoadIsInStockPlugin
+ $extensionAttributes = $result[$sku]->getExtensionAttributes();
+ $extensionAttributes->setIsInStock((bool)(int)$stockItem->getIsInStock());
+ $result[$sku]->setExtensionAttributes($extensionAttributes);
+ }
+
+ return $result;
+ }
+}
diff --git a/src/Model/Inventory/GetStockItemsData.php b/src/Model/Inventory/GetStockItemsData.php
new file mode 100644
index 0000000..bf171d0
--- /dev/null
+++ b/src/Model/Inventory/GetStockItemsData.php
@@ -0,0 +1,278 @@
+
+ * @copyright Copyright (c) 2022 Scandiweb, Inc (https://scandiweb.com)
+ * @license http://opensource.org/licenses/OSL-3.0 The Open Software License 3.0 (OSL-3.0)
+ */
+declare(strict_types=1);
+
+namespace ScandiPWA\Performance\Model\Inventory;
+
+use Exception;
+use Magento\Framework\App\ResourceConnection;
+use Magento\Framework\Exception\LocalizedException;
+use Magento\InventoryCatalogApi\Api\DefaultStockProviderInterface;
+use Magento\InventoryCatalogApi\Model\IsSingleSourceModeInterface;
+use Magento\InventoryConfigurationApi\Model\IsSourceItemManagementAllowedForSkuInterface;
+use Magento\InventoryIndexer\Indexer\IndexStructure;
+use Magento\InventoryIndexer\Model\StockIndexTableNameResolverInterface;
+use ScandiPWA\Performance\Api\Inventory\GetStockItemsDataInterface;
+
+class GetStockItemsData implements GetStockItemsDataInterface
+{
+ /**
+ * @var ResourceConnection
+ */
+ protected ResourceConnection $resource;
+
+ /**
+ * @var StockIndexTableNameResolverInterface
+ */
+ protected StockIndexTableNameResolverInterface $stockIndexTableNameResolver;
+
+ /**
+ * @var DefaultStockProviderInterface
+ */
+ protected DefaultStockProviderInterface $defaultStockProvider;
+
+ /**
+ * @var GetProductIdsBySkus
+ */
+ protected GetProductIdsBySkus $getProductIdsBySkus;
+
+ /**
+ * @var IsSingleSourceModeInterface
+ */
+ protected IsSingleSourceModeInterface $isSingleSourceMode;
+
+ /**
+ * @var IsSourceItemManagementAllowedForSkuInterface
+ */
+ protected IsSourceItemManagementAllowedForSkuInterface $isSourceItemManagementAllowedForSku;
+
+ /**
+ * Cached results
+ * @var array
+ */
+ protected array $stockItemDatasByStockAndSku = [];
+
+ /**
+ * @param ResourceConnection $resource
+ * @param StockIndexTableNameResolverInterface $stockIndexTableNameResolver
+ * @param DefaultStockProviderInterface $defaultStockProvider
+ * @param GetProductIdsBySkus $getProductIdsBySkus
+ * @param IsSingleSourceModeInterface|null $isSingleSourceMode
+ * @param IsSourceItemManagementAllowedForSkuInterface|null $isSourceItemManagementAllowedForSku
+ */
+ public function __construct(
+ ResourceConnection $resource,
+ StockIndexTableNameResolverInterface $stockIndexTableNameResolver,
+ DefaultStockProviderInterface $defaultStockProvider,
+ GetProductIdsBySkus $getProductIdsBySkus,
+ IsSingleSourceModeInterface $isSingleSourceMode,
+ IsSourceItemManagementAllowedForSkuInterface $isSourceItemManagementAllowedForSku
+ ) {
+ $this->resource = $resource;
+ $this->stockIndexTableNameResolver = $stockIndexTableNameResolver;
+ $this->defaultStockProvider = $defaultStockProvider;
+ $this->getProductIdsBySkus = $getProductIdsBySkus;
+ $this->isSingleSourceMode = $isSingleSourceMode;
+ $this->isSourceItemManagementAllowedForSku = $isSourceItemManagementAllowedForSku;
+ }
+
+ /**
+ * Get stock item entities by sku array and stock
+ * Cache wrapper
+ *
+ * @param array $skuArray
+ * @param int $stockId
+ * @return array
+ * @throws LocalizedException
+ */
+ public function execute(array $skuArray, int $stockId): array
+ {
+ $resultsBySku = [];
+ $loadSkus = [];
+
+ foreach ($skuArray as $sku) {
+ if (isset($this->stockItemDatasByStockAndSku[$stockId][$sku])) {
+ $resultsBySku[$sku] = $this->stockItemDatasByStockAndSku[$stockId][$sku];
+ } else {
+ $loadSkus[] = $sku;
+ $resultsBySku[$sku] = null;
+ }
+ }
+
+ if (count($loadSkus)) {
+ $stockItemDatas = $this->getStockItems($loadSkus, $stockId);
+
+ foreach ($stockItemDatas as $sku => $stockItemData) {
+ $this->stockItemDatasByStockAndSku[$stockId][$sku] = $stockItemData;
+ $resultsBySku[$sku] = $stockItemData;
+ }
+ }
+
+ return $resultsBySku;
+ }
+
+ /**
+ * Get stock item entities by skus and stock
+ *
+ * @param array $skuArray
+ * @param int $stockId
+ * @return array
+ */
+ public function getStockItems(array $skuArray, int $stockId): array
+ {
+ $result = [];
+
+ foreach ($skuArray as $sku) {
+ $result[$sku] = null;
+ }
+
+ $connection = $this->resource->getConnection();
+ $select = $connection->select();
+
+ if ($this->defaultStockProvider->getId() === $stockId) {
+ $productIds = $this->getProductIdsBySkus->execute($skuArray);
+
+ foreach ($productIds as $sku => $productId) {
+ if ($productId === null) {
+ $result[$sku] = null;
+ unset($productIds[$sku]);
+ }
+ }
+
+ $productSkusById = array_flip($productIds);
+
+ $select->from(
+ $this->resource->getTableName('cataloginventory_stock_status'),
+ [
+ GetStockItemsDataInterface::QUANTITY => 'qty',
+ GetStockItemsDataInterface::IS_SALABLE => 'stock_status',
+ 'product_id'
+ ]
+ )->where(
+ 'product_id IN (?)',
+ $productIds
+ );
+ } else {
+ $select->from(
+ $this->stockIndexTableNameResolver->execute($stockId),
+ [
+ GetStockItemsDataInterface::QUANTITY => IndexStructure::QUANTITY,
+ GetStockItemsDataInterface::IS_SALABLE => IndexStructure::IS_SALABLE,
+ IndexStructure::SKU
+ ]
+ )->where(
+ IndexStructure::SKU . ' IN (?)',
+ $skuArray
+ );
+ }
+
+ try {
+ foreach ($connection->fetchAll($select) as $row) {
+ if ($this->defaultStockProvider->getId() === $stockId) {
+ $result[$productSkusById[$row['product_id']]] = [
+ GetStockItemsDataInterface::QUANTITY => $row[GetStockItemsDataInterface::QUANTITY],
+ GetStockItemsDataInterface::IS_SALABLE => $row[GetStockItemsDataInterface::IS_SALABLE]
+ ];
+ } else {
+ $result[$row[IndexStructure::SKU]] = [
+ GetStockItemsDataInterface::QUANTITY => $row[GetStockItemsDataInterface::QUANTITY],
+ GetStockItemsDataInterface::IS_SALABLE => $row[GetStockItemsDataInterface::IS_SALABLE]
+ ];
+ }
+ }
+ } catch (Exception $e) {
+ throw new LocalizedException(__('Could not receive Stock Item data'), $e);
+ }
+
+ /**
+ * Fallback to the legacy cataloginventory_stock_item table.
+ * Caused by data absence in legacy cataloginventory_stock_status table
+ * for disabled products assigned to the default stock.
+ */
+ $missingStockItemSkus = [];
+
+ foreach ($skuArray as $sku) {
+ if ($result[$sku] === null) {
+ $missingStockItemSkus[] = $sku;
+ }
+ }
+
+ if (count($missingStockItemSkus)) {
+ $missingStockItemData = $this->getStockItemDataFromStockItemTable($missingStockItemSkus, $stockId);
+
+ foreach ($missingStockItemData as $sku => $row) {
+ $result[$sku] = $row;
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Retrieve stock item data for products assigned to the default stock.
+ *
+ * @param array $skuArray
+ * @param int $stockId
+ * @return array
+ */
+ protected function getStockItemDataFromStockItemTable(array $skuArray, int $stockId): array
+ {
+ $result = [];
+
+ foreach ($skuArray as $sku) {
+ $result[$sku] = null;
+ }
+
+ $skusToCheck = array_flip($skuArray);
+
+ foreach ($skuArray as $sku) {
+ if ($this->defaultStockProvider->getId() !== $stockId
+ || $this->isSingleSourceMode->execute()
+ || !$this->isSourceItemManagementAllowedForSku->execute((string)$sku)
+ ) {
+ $result[$sku] = null;
+ unset($skusToCheck[$sku]);
+ }
+ }
+
+ $productIds = $this->getProductIdsBySkus->execute(array_keys($skusToCheck));
+
+ foreach ($productIds as $sku => $productId) {
+ if ($productId === null) {
+ $result[$sku] = null;
+ unset($skusToCheck[$sku]);
+ unset($productIds[$sku]);
+ }
+ }
+
+ $productSkusById = array_flip($productIds);
+
+ $connection = $this->resource->getConnection();
+ $select = $connection->select();
+ $select->from(
+ $this->resource->getTableName('cataloginventory_stock_item'),
+ [
+ GetStockItemsDataInterface::QUANTITY => 'qty',
+ GetStockItemsDataInterface::IS_SALABLE => 'is_in_stock',
+ 'product_id'
+ ]
+ )->where(
+ 'product_id IN (?)',
+ $productIds
+ );
+
+ foreach ($connection->fetchAll($select) as $row) {
+ $result[$productSkusById[$row['product_id']]] = [
+ GetStockItemsDataInterface::QUANTITY => $row[GetStockItemsDataInterface::QUANTITY],
+ GetStockItemsDataInterface::IS_SALABLE => $row[GetStockItemsDataInterface::IS_SALABLE]
+ ];
+ }
+
+ return $result;
+ }
+}
diff --git a/src/Model/Resolver/Products/DataPostProcessor/Stocks.php b/src/Model/Resolver/Products/DataPostProcessor/Stocks.php
deleted file mode 100644
index 749d4a5..0000000
--- a/src/Model/Resolver/Products/DataPostProcessor/Stocks.php
+++ /dev/null
@@ -1,292 +0,0 @@
-
- * @copyright Copyright (c) 2019 Scandiweb, Ltd (https://scandiweb.com)
- */
-
-declare(strict_types=1);
-
-namespace ScandiPWA\Performance\Model\Resolver\Products\DataPostProcessor;
-
-use Magento\CatalogInventory\Model\Configuration;
-use Magento\Framework\Api\SearchCriteriaBuilder;
-use Magento\Framework\App\Config\ScopeConfigInterface;
-use Magento\InventoryApi\Api\Data\SourceItemInterface;
-use Magento\InventoryApi\Api\SourceItemRepositoryInterface;
-use Magento\InventoryConfigurationApi\Model\IsSourceItemManagementAllowedForProductTypeInterface;
-use Magento\Store\Model\ScopeInterface;
-use ScandiPWA\Performance\Api\ProductsDataPostProcessorInterface;
-use ScandiPWA\Performance\Model\Resolver\ResolveInfoFieldsTrait;
-use Magento\InventoryApi\Api\GetStockSourceLinksInterface;
-use Magento\InventoryApi\Api\Data\StockSourceLinkInterface;
-use Magento\InventoryCatalog\Model\GetStockIdForCurrentWebsite;
-use Magento\InventorySalesApi\Api\GetProductSalableQtyInterface;
-use Magento\InventoryConfigurationApi\Api\GetStockItemConfigurationInterface;
-
-/**
- * Class Images
- * @package ScandiPWA\Performance\Model\Resolver\Products\DataPostProcessor
- */
-class Stocks implements ProductsDataPostProcessorInterface
-{
- use ResolveInfoFieldsTrait;
-
- const ONLY_X_LEFT_IN_STOCK = 'only_x_left_in_stock';
-
- const STOCK_STATUS = 'stock_status';
-
- const SALABLE_QTY = 'salable_qty';
-
- const IN_STOCK = 'IN_STOCK';
-
- const OUT_OF_STOCK = 'OUT_OF_STOCK';
-
- /**
- * @var SourceItemRepositoryInterface
- */
- protected $stockRepository;
-
- /**
- * @var SearchCriteriaBuilder
- */
- protected $searchCriteriaBuilder;
-
- /**
- * @var ScopeConfigInterface
- */
- protected $scopeConfig;
-
- /**
- * @var GetStockSourceLinksInterface
- */
- protected $getStockSourceLinks;
-
- /**
- * @var GetStockIdForCurrentWebsite
- */
- protected $getStockIdForCurrentWebsite;
-
- /**
- * @var GetProductSalableQtyInterface
- */
- protected $getProductSalableQty;
-
- /**
- * @var GetStockItemConfigurationInterface
- */
- protected $getStockItemConfiguration;
-
- /**
- * @var IsSourceItemManagementAllowedForProductTypeInterface
- */
- protected $isSourceItemManagementAllowedForProductType;
-
- /**
- * Stocks constructor.
- * @param SourceItemRepositoryInterface $stockRepository
- * @param SearchCriteriaBuilder $searchCriteriaBuilder
- * @param ScopeConfigInterface $scopeConfig
- */
- public function __construct(
- SourceItemRepositoryInterface $stockRepository,
- SearchCriteriaBuilder $searchCriteriaBuilder,
- ScopeConfigInterface $scopeConfig,
- GetStockSourceLinksInterface $getStockSourceLinks,
- GetStockIdForCurrentWebsite $getStockIdForCurrentWebsite,
- GetStockItemConfigurationInterface $getStockItemConfiguration,
- GetProductSalableQtyInterface $getProductSalableQty,
- IsSourceItemManagementAllowedForProductTypeInterface $isSourceItemManagementAllowedForProductType
- ) {
- $this->searchCriteriaBuilder = $searchCriteriaBuilder;
- $this->stockRepository = $stockRepository;
- $this->scopeConfig = $scopeConfig;
- $this->getStockSourceLinks = $getStockSourceLinks;
- $this->getStockIdForCurrentWebsite = $getStockIdForCurrentWebsite;
- $this->getStockItemConfiguration = $getStockItemConfiguration;
- $this->getProductSalableQty = $getProductSalableQty;
- $this->isSourceItemManagementAllowedForProductType = $isSourceItemManagementAllowedForProductType;
- }
-
- /**
- * @param $node
- * @return string[]
- */
- protected function getFieldContent($node)
- {
- $stocks = [];
- $validFields = [
- self::ONLY_X_LEFT_IN_STOCK,
- self::STOCK_STATUS
- ];
-
- foreach ($node->selectionSet->selections as $selection) {
- if (!isset($selection->name)) {
- continue;
- };
-
- $name = $selection->name->value;
-
- if (in_array($name, $validFields)) {
- $stocks[] = $name;
- }
- }
-
- return $stocks;
- }
-
- /**
- * @inheritDoc
- */
- public function process(
- array $products,
- string $graphqlResolvePath,
- $graphqlResolveInfo,
- ?array $processorOptions = []
- ): callable {
- $productStocks = [];
- $productTypes = [];
-
- $fields = $this->getFieldsFromProductInfo(
- $graphqlResolveInfo,
- $graphqlResolvePath
- );
-
- if (!count($fields)) {
- return function (&$productData) {
- };
- }
-
- $stockId = $this->getStockIdForCurrentWebsite->execute();
-
- if (!$stockId) {
- return function (&$productData) {
- };
- }
-
- foreach ($products as $product) {
- $productTypes[$product->getSku()] = $product->getTypeId();
- }
-
- $productSKUs = array_keys($productTypes);
-
- $thresholdQty = 0;
-
- if (in_array(self::ONLY_X_LEFT_IN_STOCK, $fields)) {
- $thresholdQty = (float) $this->scopeConfig->getValue(
- Configuration::XML_PATH_STOCK_THRESHOLD_QTY,
- ScopeInterface::SCOPE_STORE
- );
- }
-
- $criteria = $this->searchCriteriaBuilder
- ->addFilter(StockSourceLinkInterface::STOCK_ID, $stockId)
- ->create();
-
- $sourceLinks = $this->getStockSourceLinks->execute($criteria)->getItems();
-
- if (!count($sourceLinks)) {
- return function (&$productData) {
- };
- }
-
- $sourceCodes = array_map(function ($sourceLink) {
- return $sourceLink->getSourceCode();
- }, $sourceLinks);
-
- $criteria = $this->searchCriteriaBuilder
- ->addFilter(SourceItemInterface::SKU, $productSKUs, 'in')
- ->addFilter(SourceItemInterface::SOURCE_CODE, $sourceCodes, 'in')
- ->create();
-
- $stockItems = $this->stockRepository->getList($criteria)->getItems();
-
- if (!count($stockItems)) {
- return function (&$productData) {
- };
- }
-
- $formattedStocks = [];
-
- foreach ($stockItems as $stockItem) {
- $leftInStock = null;
- $productSalableQty = null;
- $qty = $stockItem->getQuantity();
- $sku = $stockItem->getSku();
-
- $inStock = $stockItem->getStatus() === SourceItemInterface::STATUS_IN_STOCK;
-
- if (isset($productTypes[$sku])
- && !$this->isSourceItemManagementAllowedForProductType->execute($productTypes[$sku])
- ) {
- $formattedStocks[$sku] = [
- self::STOCK_STATUS => $inStock ? self::IN_STOCK : self::OUT_OF_STOCK,
- ];
-
- continue;
- }
-
- $inStock = $qty > 0;
-
- if ($inStock) {
- $productSalableQty = $this->getProductSalableQty->execute($sku, $stockId);
-
- if ($productSalableQty > 0) {
- $stockItemConfiguration = $this->getStockItemConfiguration->execute($sku, $stockId);
- $minQty = $stockItemConfiguration->getMinQty();
-
- if ($productSalableQty >= $minQty) {
- $stockLeft = $productSalableQty - $minQty;
- $thresholdQty = $stockItemConfiguration->getStockThresholdQty();
-
- if ($thresholdQty != 0) {
- $leftInStock = $stockLeft <= $thresholdQty ? (float)$stockLeft : null;
- }
- } else {
- $inStock = false;
- }
- } else {
- $inStock = false;
- }
-
- }
-
- if (isset($formattedStocks[$sku])
- && $formattedStocks[$sku][self::STOCK_STATUS] == self::IN_STOCK) {
- continue;
- }
-
- $formattedStocks[$sku] = [
- self::STOCK_STATUS => $inStock ? self::IN_STOCK : self::OUT_OF_STOCK,
- self::ONLY_X_LEFT_IN_STOCK => $leftInStock,
- self::SALABLE_QTY => $productSalableQty
- ];
- }
-
- foreach ($products as $product) {
- $productId = $product->getId();
- $productSku = $product->getSku();
-
- if (isset($formattedStocks[$productSku])) {
- $productStocks[$productId] = $formattedStocks[$productSku];
- }
- }
-
- return function (&$productData) use ($productStocks) {
- if (!isset($productData['entity_id'])) {
- return;
- }
-
- $productId = $productData['entity_id'];
-
- if (!isset($productStocks[$productId])) {
- return;
- }
-
- foreach ($productStocks[$productId] as $stockType => $stockData) {
- $productData[$stockType] = $stockData;
- }
- };
- }
-}
diff --git a/src/Model/Resolver/Products/StockItem.php b/src/Model/Resolver/Products/StockItem.php
new file mode 100644
index 0000000..3d34b3d
--- /dev/null
+++ b/src/Model/Resolver/Products/StockItem.php
@@ -0,0 +1,70 @@
+
+ * @copyright Copyright (c) 2022 Scandiweb, Inc (https://scandiweb.com)
+ * @license http://opensource.org/licenses/OSL-3.0 The Open Software License 3.0 (OSL-3.0)
+ */
+declare(strict_types=1);
+
+namespace ScandiPWA\Performance\Model\Resolver\Products;
+
+use Magento\CatalogInventory\Api\Data\StockItemInterface;
+use Magento\Framework\GraphQl\Query\Resolver\BatchServiceContractResolverInterface;
+use Magento\Framework\GraphQl\Query\Resolver\ResolveRequestInterface;
+use ScandiPWA\Performance\Model\Resolver\Products\StockItem\GetStockItem;
+use ScandiPWA\Performance\Model\Resolver\Products\StockItem\ProductCriteria;
+
+class StockItem implements BatchServiceContractResolverInterface
+{
+ /**
+ * @var GetStockItem
+ */
+ protected GetStockItem $getStockItem;
+
+ /**
+ * @param GetStockItem $getStockItem
+ */
+ public function __construct(GetStockItem $getStockItem)
+ {
+ $this->getStockItem = $getStockItem;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getServiceContract(): array
+ {
+ return [GetStockItem::class, 'execute'];
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function convertToServiceArgument(ResolveRequestInterface $request): ProductCriteria
+ {
+ // add criteria to a "shared" queue to load data
+ // this is necessary, because otherwise BatchContractResolverWrapper
+ // is going to make an individual GetStockItem batch per product type, which is unnecessary
+ $this->getStockItem->addSkuToQueue($request->getValue()['model']->getSku());
+
+ // criteria for this particular request
+ return new ProductCriteria($request->getValue()['model']->getSku());
+ }
+
+ /**
+ * @param StockItemInterface $result
+ * @param ResolveRequestInterface $request
+ * @return array
+ */
+ public function convertFromServiceResult(
+ $result,
+ ResolveRequestInterface $request
+ ): array {
+ return [
+ 'min_sale_qty' => $result->getMinSaleQty(),
+ 'max_sale_qty' => $result->getMaxSaleQty(),
+ 'qty_increments' => $result->getQtyIncrements() === false ? 1 : $result->getQtyIncrements()
+ ];
+ }
+}
diff --git a/src/Model/Resolver/Products/StockItem/GetStockItem.php b/src/Model/Resolver/Products/StockItem/GetStockItem.php
new file mode 100644
index 0000000..0cee029
--- /dev/null
+++ b/src/Model/Resolver/Products/StockItem/GetStockItem.php
@@ -0,0 +1,120 @@
+
+ * @copyright Copyright (c) 2022 Scandiweb, Inc (https://scandiweb.com)
+ * @license http://opensource.org/licenses/OSL-3.0 The Open Software License 3.0 (OSL-3.0)
+ */
+declare(strict_types=1);
+
+namespace ScandiPWA\Performance\Model\Resolver\Products\StockItem;
+
+use Magento\CatalogInventory\Api\Data\StockItemInterface;
+use Magento\InventoryCatalog\Model\GetStockIdForCurrentWebsite;
+use ScandiPWA\Performance\Model\Inventory\GetLegacyStockItems;
+
+class GetStockItem
+{
+ /**
+ * @var GetStockIdForCurrentWebsite
+ */
+ protected GetStockIdForCurrentWebsite $getStockIdForCurrentWebsite;
+
+ /**
+ * @var GetLegacyStockItems
+ */
+ protected GetLegacyStockItems $getLegacyStockItems;
+
+ /**
+ * @var array
+ */
+ protected array $unprocessedSkuQueue = [];
+
+ /**
+ * @var StockItemInterface[]
+ */
+ protected array $processedResults = [];
+
+ /**
+ * @param GetStockIdForCurrentWebsite $getStockIdForCurrentWebsite
+ * @param GetLegacyStockItems $getLegacyStockItems
+ */
+ public function __construct(
+ GetStockIdForCurrentWebsite $getStockIdForCurrentWebsite,
+ GetLegacyStockItems $getLegacyStockItems
+ ) {
+ $this->getStockIdForCurrentWebsite = $getStockIdForCurrentWebsite;
+ $this->getLegacyStockItems = $getLegacyStockItems;
+ }
+
+ /**
+ * @param ProductCriteria[] $criteriaList
+ * @return IsProductSalableResultInterface[]
+ */
+ public function execute(array $criteriaList): array
+ {
+ if (count($this->unprocessedSkuQueue)) {
+ $this->processQueue();
+ }
+
+ $skuArray = $finalResults = [];
+
+ foreach ($criteriaList as $productCriteria) {
+ $skuArray[] = $productCriteria->getSku();
+ }
+
+ // mapping a request index to specific sku
+ // single SKU request may have more than one index, in case the criteria for current batch
+ // somehow repeats the same sku multiple times
+ $skuIndexes = [];
+
+ foreach ($skuArray as $index => $sku) {
+ $skuIndexes[$sku][] = $index;
+ }
+
+ foreach ($skuArray as $sku) {
+ foreach ($skuIndexes[$sku] as $index) {
+ $finalResults[$index] = $this->processedResults[$sku];
+ }
+ }
+
+ return $finalResults;
+ }
+
+ /**
+ * @return void
+ */
+ public function processQueue(): void
+ {
+ $skuArray = $this->unprocessedSkuQueue;
+ $stockId = $this->getStockIdForCurrentWebsite->execute();
+
+ // note: getLegacyStockItems caches its results, and is also used in stock_status fetch
+ $results = $this->getLegacyStockItems->execute($skuArray);
+
+ foreach ($results as $sku => $stockItem) {
+ $this->processedResults[$sku] = $stockItem;
+ }
+
+ $this->unprocessedSkuQueue = [];
+ }
+
+ /**
+ * Adds SKU to a queue
+ * Will skip it if already processed
+ * This is implemented, because the execution flow looks like this:
+ * 1. StockStatus::convertToServiceArgument is run many times
+ * but within individual BatchContractResolverWrapper instances per product type
+ * 2. AreProductsSalable::execute is called for one BatchContractResolverWrapper instance & its request at a time
+ * 3. Variants / bundles are creating their own BatchContractResolverWrapper instances and are building arguments
+ * after the parent item requests resolve
+ * This mechanism of adding skus to queue will resolve them for all product types at once, for parent products
+ * @param string $sku
+ */
+ public function addSkuToQueue(string $sku): void
+ {
+ if (!isset($this->processedResults[$sku]) && !in_array($sku, $this->unprocessedSkuQueue)) {
+ $this->unprocessedSkuQueue[] = $sku;
+ }
+ }
+}
diff --git a/src/Model/Resolver/Products/StockItem/ProductCriteria.php b/src/Model/Resolver/Products/StockItem/ProductCriteria.php
new file mode 100644
index 0000000..7c06811
--- /dev/null
+++ b/src/Model/Resolver/Products/StockItem/ProductCriteria.php
@@ -0,0 +1,34 @@
+
+ * @copyright Copyright (c) 2022 Scandiweb, Inc (https://scandiweb.com)
+ * @license http://opensource.org/licenses/OSL-3.0 The Open Software License 3.0 (OSL-3.0)
+ */
+declare(strict_types=1);
+
+namespace ScandiPWA\Performance\Model\Resolver\Products\StockItem;
+
+class ProductCriteria
+{
+ /**
+ * @var string
+ */
+ protected string $sku;
+
+ /**
+ * @param string $sku
+ */
+ public function __construct(string $sku)
+ {
+ $this->sku = $sku;
+ }
+
+ /**
+ * @return string
+ */
+ public function getSku(): string
+ {
+ return $this->sku;
+ }
+}
diff --git a/src/Model/Resolver/Products/StockStatus.php b/src/Model/Resolver/Products/StockStatus.php
new file mode 100644
index 0000000..3dd936b
--- /dev/null
+++ b/src/Model/Resolver/Products/StockStatus.php
@@ -0,0 +1,63 @@
+
+ * @copyright Copyright (c) 2022 Scandiweb, Inc (https://scandiweb.com)
+ * @license http://opensource.org/licenses/OSL-3.0 The Open Software License 3.0 (OSL-3.0)
+ */
+declare(strict_types=1);
+
+namespace ScandiPWA\Performance\Model\Resolver\Products;
+
+use Magento\Framework\GraphQl\Query\Resolver\BatchServiceContractResolverInterface;
+use Magento\Framework\GraphQl\Query\Resolver\ResolveRequestInterface;
+use ScandiPWA\Performance\Model\Resolver\Products\StockStatus\AreProductsSalable;
+use ScandiPWA\Performance\Model\Resolver\Products\StockStatus\ProductCriteria;
+
+class StockStatus implements BatchServiceContractResolverInterface
+{
+ /**
+ * @var AreProductsSalable
+ */
+ protected AreProductsSalable $areProductsSalable;
+
+ /**
+ * @param AreProductsSalable $areProductsSalable
+ */
+ public function __construct(AreProductsSalable $areProductsSalable)
+ {
+ $this->areProductsSalable = $areProductsSalable;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getServiceContract(): array
+ {
+ return [AreProductsSalable::class, 'execute'];
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function convertToServiceArgument(ResolveRequestInterface $request): ProductCriteria
+ {
+ // add criteria to a "shared" queue to load data
+ // this is necessary, because otherwise BatchContractResolverWrapper
+ // is going to make an individual AreProductsSalable batch per product type, which is unnecessary
+ $this->areProductsSalable->addSkuToQueue($request->getValue()['model']->getSku());
+
+ // criteria for this particular request
+ return new ProductCriteria($request->getValue()['model']->getSku());
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function convertFromServiceResult(
+ $result,
+ ResolveRequestInterface $request
+ ): string {
+ return $result->isSalable() ? 'IN_STOCK' : 'OUT_OF_STOCK';
+ }
+}
diff --git a/src/Model/Resolver/Products/StockStatus/AreProductsSalable.php b/src/Model/Resolver/Products/StockStatus/AreProductsSalable.php
new file mode 100644
index 0000000..1819677
--- /dev/null
+++ b/src/Model/Resolver/Products/StockStatus/AreProductsSalable.php
@@ -0,0 +1,134 @@
+
+ * @copyright Copyright (c) 2022 Scandiweb, Inc (https://scandiweb.com)
+ * @license http://opensource.org/licenses/OSL-3.0 The Open Software License 3.0 (OSL-3.0)
+ */
+declare(strict_types=1);
+
+namespace ScandiPWA\Performance\Model\Resolver\Products\StockStatus;
+
+use Magento\InventoryCatalog\Model\GetStockIdForCurrentWebsite;
+use Magento\InventorySalesApi\Api\Data\IsProductSalableResultInterface;
+use Magento\InventorySalesApi\Api\Data\IsProductSalableResultInterfaceFactory;
+use ScandiPWA\Performance\Model\Inventory\AreProductsSalableConditionChain;
+
+class AreProductsSalable
+{
+ /**
+ * @var GetStockIdForCurrentWebsite
+ */
+ protected GetStockIdForCurrentWebsite $getStockIdForCurrentWebsite;
+
+ /**
+ * @var AreProductsSalableConditionChain
+ */
+ protected AreProductsSalableConditionChain $areProductsSalableConditionChain;
+
+ /**
+ * @var IsProductSalableResultInterfaceFactory
+ */
+ protected IsProductSalableResultInterfaceFactory $isProductSalableResultFactory;
+
+ /**
+ * @var array
+ */
+ protected array $unprocessedSkuQueue = [];
+
+ /**
+ * @var IsProductSalableResultInterface[]
+ */
+ protected array $processedResults = [];
+
+ /**
+ * @param GetStockIdForCurrentWebsite $getStockIdForCurrentWebsite
+ * @param AreProductsSalableConditionChain $areProductsSalableConditionChain
+ * @param IsProductSalableResultInterfaceFactory $isProductSalableResultFactory
+ */
+ public function __construct(
+ GetStockIdForCurrentWebsite $getStockIdForCurrentWebsite,
+ AreProductsSalableConditionChain $areProductsSalableConditionChain,
+ IsProductSalableResultInterfaceFactory $isProductSalableResultFactory
+ ) {
+ $this->getStockIdForCurrentWebsite = $getStockIdForCurrentWebsite;
+ $this->areProductsSalableConditionChain = $areProductsSalableConditionChain;
+ $this->isProductSalableResultFactory = $isProductSalableResultFactory;
+ }
+
+ /**
+ * @param ProductCriteria[] $criteriaList
+ * @return IsProductSalableResultInterface[]
+ */
+ public function execute(array $criteriaList): array
+ {
+ if (count($this->unprocessedSkuQueue)) {
+ $this->processQueue();
+ }
+
+ $skuArray = $finalResults = [];
+
+ foreach ($criteriaList as $productCriteria) {
+ $skuArray[] = $productCriteria->getSku();
+ }
+
+ // mapping a request index to specific sku
+ // single SKU request may have more than one index, in case the criteria for current batch
+ // somehow repeats the same sku multiple times
+ $skuIndexes = [];
+
+ foreach ($skuArray as $index => $sku) {
+ $skuIndexes[$sku][] = $index;
+ }
+
+ foreach ($skuArray as $sku) {
+ foreach ($skuIndexes[$sku] as $index) {
+ $finalResults[$index] = $this->processedResults[$sku];
+ }
+ }
+
+ return $finalResults;
+ }
+
+ /**
+ * @return void
+ */
+ public function processQueue(): void
+ {
+ $skuArray = $this->unprocessedSkuQueue;
+
+ $stockId = $this->getStockIdForCurrentWebsite->execute();
+ $areProductsSalable = $this->areProductsSalableConditionChain->execute($skuArray, $stockId);
+
+ foreach ($areProductsSalable as $sku => $isSalable) {
+ $this->processedResults[$sku] = $this->isProductSalableResultFactory->create(
+ [
+ 'sku' => $sku,
+ 'stockId' => $stockId,
+ 'isSalable' => $isSalable,
+ ]
+ );
+ }
+
+ $this->unprocessedSkuQueue = [];
+ }
+
+ /**
+ * Adds SKU to a queue
+ * Will skip it if already processed
+ * This is implemented, because the execution flow looks like this:
+ * 1. StockStatus::convertToServiceArgument is run many times
+ * but within individual BatchContractResolverWrapper instances per product type
+ * 2. AreProductsSalable::execute is called for one BatchContractResolverWrapper instance & its request at a time
+ * 3. Variants / bundles are creating their own BatchContractResolverWrapper instances and are building arguments
+ * after the parent item requests resolve
+ * This mechanism of adding skus to queue will resolve them for all product types at once, for parent products
+ * @param string $sku
+ */
+ public function addSkuToQueue(string $sku): void
+ {
+ if (!isset($this->processedResults[$sku]) && !in_array($sku, $this->unprocessedSkuQueue)) {
+ $this->unprocessedSkuQueue[] = $sku;
+ }
+ }
+}
diff --git a/src/Model/Resolver/Products/StockStatus/ProductCriteria.php b/src/Model/Resolver/Products/StockStatus/ProductCriteria.php
new file mode 100644
index 0000000..3717e94
--- /dev/null
+++ b/src/Model/Resolver/Products/StockStatus/ProductCriteria.php
@@ -0,0 +1,34 @@
+
+ * @copyright Copyright (c) 2022 Scandiweb, Inc (https://scandiweb.com)
+ * @license http://opensource.org/licenses/OSL-3.0 The Open Software License 3.0 (OSL-3.0)
+ */
+declare(strict_types=1);
+
+namespace ScandiPWA\Performance\Model\Resolver\Products\StockStatus;
+
+class ProductCriteria
+{
+ /**
+ * @var string
+ */
+ protected string $sku;
+
+ /**
+ * @param string $sku
+ */
+ public function __construct(string $sku)
+ {
+ $this->sku = $sku;
+ }
+
+ /**
+ * @return string
+ */
+ public function getSku(): string
+ {
+ return $this->sku;
+ }
+}
diff --git a/src/etc/di.xml b/src/etc/di.xml
index 703663d..9678bf6 100644
--- a/src/etc/di.xml
+++ b/src/etc/di.xml
@@ -15,7 +15,6 @@
- ScandiPWA\Performance\Model\Resolver\Products\DataPostProcessor\Attributes
- ScandiPWA\Performance\Model\Resolver\Products\DataPostProcessor\Images
- - ScandiPWA\Performance\Model\Resolver\Products\DataPostProcessor\Stocks
@@ -30,4 +29,50 @@
+
+
+
+
+ -
+
- true
+ -
+ ScandiPWA\Performance\Model\Inventory\AreProductsSalableCondition\IsSetInStockStatusForCompositeProductsCondition
+
+
+ -
+
- true
+ -
+ ScandiPWA\Performance\Model\Inventory\AreProductsSalableCondition\IsAnySourceItemInStockCondition
+
+
+ -
+
- 10
+ -
+ ScandiPWA\Performance\Model\Inventory\AreProductsSalableCondition\BackOrderCondition
+
+
+ -
+
- 20
+ -
+ ScandiPWA\Performance\Model\Inventory\AreProductsSalableCondition\ManageStockCondition
+
+
+ -
+
- 30
+ -
+ ScandiPWA\Performance\Model\Inventory\AreProductsSalableCondition\AreSalableWithReservationsCondition
+
+
+
+
+
+
+
+
+
+
diff --git a/src/etc/schema.graphqls b/src/etc/schema.graphqls
index 436afd4..bea2764 100644
--- a/src/etc/schema.graphqls
+++ b/src/etc/schema.graphqls
@@ -8,11 +8,18 @@ interface ProductInterface {
small_image: OptimizedProductImage @resolver(class: "ScandiPWA\\Performance\\Model\\Resolver\\Value")
thumbnail: OptimizedProductImage @resolver(class: "ScandiPWA\\Performance\\Model\\Resolver\\Value")
only_x_left_in_stock: Float @resolver(class: "ScandiPWA\\Performance\\Model\\Resolver\\Value")
- stock_status: ProductStockStatus @resolver(class: "ScandiPWA\\Performance\\Model\\Resolver\\Value")
+ stock_status: ProductStockStatus @doc(description: "Stock status of the product") @resolver(class: "ScandiPWA\\Performance\\Model\\Resolver\\Products\\StockStatus")
+ stock_item: ProductStockItem @resolver(class: "ScandiPWA\\Performance\\Model\\Resolver\\Products\\StockItem")
salable_qty: Float @resolver(class: "ScandiPWA\\Performance\\Model\\Resolver\\Value")
s_attributes: [AttributeWithValue]
}
+type ProductStockItem {
+ min_sale_qty: Int @doc(description: "Minimal amount of item that can be bought")
+ max_sale_qty: Int @doc(description: "Maximal amount of item that can be bought")
+ qty_increments: Int @doc(description: "Increment for number of items that can be bought")
+}
+
type AttributeWithValue {
attribute_code: String
entity_type: String