diff --git a/CLAUDE.md b/CLAUDE.md index 3ad40a9..f348461 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,9 @@ depends only on the core modules it plugs into. `GraphQlCatalog/Model/ActiveFilters` keeps the filter of every products field by response key and turns it into a selection over the salable variants; `Plugin/Resolver/OptionsSelectionFollowsFilters` (sort order 20) hands core's resolver the uids. A uid the query passes wins for its attribute. +- `GraphQlCatalogInventory`: `ProductInterface.stock_sources`, the source items of the enabled sources + of the website's stock in priority order, from MSI's services in one batch per request. The + quantity is physical: reservations count on the stock only. - `GraphQlCatalogSwatches`: the `swatch` field on aggregation, attribute and selected options. `Model/Resolver/SwatchData` reads the swatch under the key and builds the thumbnail URL of a stored image swatch; `Plugin/Output/*` and `Plugin/Layer/AggregationSwatches` fill the key from diff --git a/README.md b/README.md index f7bbc13..ce52a4c 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ reads the frontend input and the swatches of a filter from the listing query. | `AttributeValueInterface.attribute`, `Aggregation.attribute` | `GraphQlCatalog` | The attribute metadata, as `customAttributeMetadataV2` returns it; null for a category aggregation | | `swatch` on `AggregationOptionInterface`, `CustomAttributeOptionInterface`, `AttributeSelectedOptionInterface` | `GraphQlCatalogSwatches` | The `SwatchDataInterface` of an option of a swatch attribute, or null | | `ConfigurableProduct.configurable_product_options_selection(followFilters)` | `GraphQlCatalogConfigurableProduct` | The selection the filter of the products query names: every super attribute the filter limits to one value, when a salable variant carries all of them | +| `ProductInterface.stock_sources` | `GraphQlCatalogInventory` | The physical quantity, status, pickup flag and location per enabled source of the website's stock | The modules answer from core's resolvers and data providers and need nothing else. [GraphCommerce Catalog Storefront](https://github.com/graphcommerce-org/magento2-GraphCommerce_CatalogStorefront) @@ -21,13 +22,13 @@ requires this package and serves the same fields from its documents. ```sh composer require graphcommerce/magento-graphql-catalog bin/magento module:enable GraphCommerce_GraphQlCatalog GraphCommerce_GraphQlCatalogSwatches \ - GraphCommerce_GraphQlCatalogConfigurableProduct + GraphCommerce_GraphQlCatalogConfigurableProduct GraphCommerce_GraphQlCatalogInventory bin/magento setup:upgrade ``` `GraphCommerce_GraphQlCatalogSwatches` needs Magento_SwatchesGraphQl and -`GraphCommerce_GraphQlCatalogConfigurableProduct` needs Magento_ConfigurableProductGraphQl. A store -without them leaves the module disabled. +`GraphCommerce_GraphQlCatalogConfigurableProduct` needs Magento_ConfigurableProductGraphQl and +`GraphCommerce_GraphQlCatalogInventory` needs MSI. A store without them leaves the module disabled. ## Modules @@ -36,6 +37,7 @@ without them leaves the module disabled. | [`GraphQlCatalog`](src/GraphQlCatalog) | `custom_attributeV2`, the `attribute` of a value and an aggregation, the option swatch key | | [`GraphQlCatalogSwatches`](src/GraphQlCatalogSwatches) | The `swatch` fields from core's swatch helper | | [`GraphQlCatalogConfigurableProduct`](src/GraphQlCatalogConfigurableProduct) | `followFilters` on the configurable option selection | +| [`GraphQlCatalogInventory`](src/GraphQlCatalogInventory) | `stock_sources` from MSI's source items | ## Extension point diff --git a/composer.json b/composer.json index 83c6fe3..e477ff7 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "graphcommerce/magento-graphql-catalog", - "description": "Catalog GraphQL fields for Magento 2 / Mage-OS: one custom attribute by code, the attribute metadata of a value and an aggregation, the swatch of every option, and a configurable option selection that follows the listing filter. One package with every module; each module directory is a package of its own for a subtree split.", + "description": "Catalog GraphQL fields for Magento 2 / Mage-OS: one custom attribute by code, the attribute metadata of a value and an aggregation, the swatch of every option, a configurable option selection that follows the listing filter, and the quantity per stock source. One package with every module; each module directory is a package of its own for a subtree split.", "type": "magento2-module", "license": "MIT", "require": { @@ -14,22 +14,26 @@ "replace": { "graphcommerce/module-graph-ql-catalog": "self.version", "graphcommerce/module-graph-ql-catalog-swatches": "self.version", - "graphcommerce/module-graph-ql-catalog-configurable-product": "self.version" + "graphcommerce/module-graph-ql-catalog-configurable-product": "self.version", + "graphcommerce/module-graph-ql-catalog-inventory": "self.version" }, "suggest": { "magento/module-swatches-graph-ql": "GraphCommerce_GraphQlCatalogSwatches serves the swatch of an aggregation option, an attribute option and a selected option", - "magento/module-configurable-product-graph-ql": "GraphCommerce_GraphQlCatalogConfigurableProduct selects the configurable options the listing filter names" + "magento/module-configurable-product-graph-ql": "GraphCommerce_GraphQlCatalogConfigurableProduct selects the configurable options the listing filter names", + "magento/module-inventory-sales-api": "GraphCommerce_GraphQlCatalogInventory serves the quantity per source of the stock of the website" }, "autoload": { "files": [ "src/GraphQlCatalog/registration.php", "src/GraphQlCatalogSwatches/registration.php", - "src/GraphQlCatalogConfigurableProduct/registration.php" + "src/GraphQlCatalogConfigurableProduct/registration.php", + "src/GraphQlCatalogInventory/registration.php" ], "psr-4": { "GraphCommerce\\GraphQlCatalog\\": "src/GraphQlCatalog/", "GraphCommerce\\GraphQlCatalogSwatches\\": "src/GraphQlCatalogSwatches/", - "GraphCommerce\\GraphQlCatalogConfigurableProduct\\": "src/GraphQlCatalogConfigurableProduct/" + "GraphCommerce\\GraphQlCatalogConfigurableProduct\\": "src/GraphQlCatalogConfigurableProduct/", + "GraphCommerce\\GraphQlCatalogInventory\\": "src/GraphQlCatalogInventory/" } } } diff --git a/dev/tests/bootstrap.php b/dev/tests/bootstrap.php index 89ab1b9..c9a2fe0 100644 --- a/dev/tests/bootstrap.php +++ b/dev/tests/bootstrap.php @@ -20,3 +20,7 @@ foreach ($package['autoload']['psr-4'] as $prefix => $path) { $loader->addPsr4($prefix, dirname(__DIR__, 2) . '/' . $path, true); } +// The interface the DI compile generates: declared here with core's shape when no generated code exists. +if (!interface_exists(\Magento\GraphQl\Model\Query\ContextExtensionInterface::class)) { + require __DIR__ . '/generated/ContextExtensionInterface.php'; +} diff --git a/dev/tests/generated/ContextExtensionInterface.php b/dev/tests/generated/ContextExtensionInterface.php new file mode 100644 index 0000000..ccc559c --- /dev/null +++ b/dev/tests/generated/ContextExtensionInterface.php @@ -0,0 +1,17 @@ +stockByWebsiteId->execute((int)$context->getExtensionAttributes()->getStore()->getWebsiteId())->getStockId(); + $sources = []; + foreach ($this->sourcesOfStock->execute($stockId) as $source) { + if ($source->isEnabled()) { + $sources[$source->getSourceCode()] = $source; + } + } + $skus = array_values(array_unique(array_map(static fn($request) => (string)$request->getValue()['model']->getSku(), $requests))); + $items = []; + if ($sources && $skus) { + $criteria = $this->searchCriteriaBuilder + ->addFilter(SourceItemInterface::SKU, $skus, 'in') + ->addFilter(SourceItemInterface::SOURCE_CODE, array_keys($sources), 'in') + ->create(); + foreach ($this->sourceItemRepository->getList($criteria)->getItems() as $item) { + $items[$item->getSku()][$item->getSourceCode()] = $item; + } + } + $response = new BatchResponse(); + foreach ($requests as $request) { + $productItems = $items[(string)$request->getValue()['model']->getSku()] ?? []; + $rows = []; + foreach ($sources as $code => $source) { + if (!isset($productItems[$code])) { + continue; + } + $extension = $source->getExtensionAttributes(); + $rows[] = [ + 'source_code' => $code, + 'name' => (string)$source->getName(), + 'quantity' => (float)$productItems[$code]->getQuantity(), + 'in_stock' => (int)$productItems[$code]->getStatus() === SourceItemInterface::STATUS_IN_STOCK, + 'is_pickup_location' => $extension && method_exists($extension, 'getIsPickupLocationActive') && $extension->getIsPickupLocationActive(), + 'latitude' => $source->getLatitude() === null ? null : (float)$source->getLatitude(), + 'longitude' => $source->getLongitude() === null ? null : (float)$source->getLongitude(), + 'postcode' => $source->getPostcode(), + 'city' => $source->getCity(), + 'country_code' => $source->getCountryId(), + ]; + } + $response->addResponse($request, $rows); + } + + return $response; + } +} diff --git a/src/GraphQlCatalogInventory/README.md b/src/GraphQlCatalogInventory/README.md new file mode 100644 index 0000000..492d159 --- /dev/null +++ b/src/GraphQlCatalogInventory/README.md @@ -0,0 +1,10 @@ +# GraphCommerce_GraphQlCatalogInventory + +`ProductInterface.stock_sources`: the source items of the product in the enabled sources of the +stock of the website, in source priority order, with the source name, the physical quantity, the +source item status, the in-store pickup flag and the location. Reservations count on the stock +only, so `quantity` is the physical quantity, not the salable quantity. A product without source +items, such as a configurable product, answers an empty list. + +[`Model/Resolver/StockSources`](Model/Resolver/StockSources.php) is a batch resolver: one source +item search for every product of the request. diff --git a/src/GraphQlCatalogInventory/Test/Unit/Model/Resolver/StockSourcesTest.php b/src/GraphQlCatalogInventory/Test/Unit/Model/Resolver/StockSourcesTest.php new file mode 100644 index 0000000..b0a887d --- /dev/null +++ b/src/GraphQlCatalogInventory/Test/Unit/Model/Resolver/StockSourcesTest.php @@ -0,0 +1,91 @@ +createStub(StockInterface::class); + $stock->method('getStockId')->willReturn(2); + $stockByWebsite = $this->createMock(StockByWebsiteIdResolverInterface::class); + $stockByWebsite->expects(self::once())->method('execute')->with(3)->willReturn($stock); + $sources = $this->createStub(GetSourcesAssignedToStockOrderedByPriorityInterface::class); + $sources->method('execute')->willReturn([$this->source('shop', true), $this->source('closed', false), $this->source('warehouse', true)]); + $results = $this->createStub(SourceItemSearchResultsInterface::class); + $results->method('getItems')->willReturn([$this->item('A', 'warehouse', 7, 1), $this->item('A', 'shop', 2, 0), $this->item('B', 'shop', 1, 1)]); + $repository = $this->createMock(SourceItemRepositoryInterface::class); + $repository->expects(self::once())->method('getList')->willReturn($results); + $criteria = $this->createStub(SearchCriteriaBuilder::class); + $criteria->method('addFilter')->willReturnSelf(); + $criteria->method('create')->willReturn($this->createStub(SearchCriteria::class)); + + $requests = [$this->request('A'), $this->request('B'), $this->request('C')]; + $response = (new StockSources($stockByWebsite, $sources, $repository, $criteria))->resolve($this->context(), $this->createStub(Field::class), $requests); + + self::assertSame(['shop', 'warehouse'], array_column($response->findResponseFor($requests[0]), 'source_code')); + self::assertSame([2.0, 7.0], array_column($response->findResponseFor($requests[0]), 'quantity')); + self::assertSame([false, true], array_column($response->findResponseFor($requests[0]), 'in_stock')); + self::assertSame('Shop shop', $response->findResponseFor($requests[1])[0]['name']); + self::assertSame([], $response->findResponseFor($requests[2])); + } + + private function source(string $code, bool $enabled): SourceInterface + { + $source = $this->createStub(SourceInterface::class); + $source->method('getSourceCode')->willReturn($code); + $source->method('isEnabled')->willReturn($enabled); + $source->method('getName')->willReturn('Shop ' . $code); + $source->method('getCountryId')->willReturn('NL'); + return $source; + } + + private function item(string $sku, string $source, float $quantity, int $status): SourceItemInterface + { + $item = $this->createStub(SourceItemInterface::class); + $item->method('getSku')->willReturn($sku); + $item->method('getSourceCode')->willReturn($source); + $item->method('getQuantity')->willReturn($quantity); + $item->method('getStatus')->willReturn($status); + return $item; + } + + private function request(string $sku): BatchRequestItemInterface + { + $product = $this->createStub(Product::class); + $product->method('getSku')->willReturn($sku); + $request = $this->createStub(BatchRequestItemInterface::class); + $request->method('getValue')->willReturn(['model' => $product]); + return $request; + } + + private function context(): ContextInterface + { + $store = $this->createStub(StoreInterface::class); + $store->method('getWebsiteId')->willReturn(3); + $extension = $this->createStub(ContextExtensionInterface::class); + $extension->method('getStore')->willReturn($store); + $context = $this->createStub(ContextInterface::class); + $context->method('getExtensionAttributes')->willReturn($extension); + return $context; + } +} diff --git a/src/GraphQlCatalogInventory/composer.json b/src/GraphQlCatalogInventory/composer.json new file mode 100644 index 0000000..c51c117 --- /dev/null +++ b/src/GraphQlCatalogInventory/composer.json @@ -0,0 +1,21 @@ +{ + "name": "graphcommerce/module-graph-ql-catalog-inventory", + "description": "The physical quantity of a product per source of the website's stock in the catalog GraphQL schema, for any Magento store with MSI", + "type": "magento2-module", + "license": "MIT", + "require": { + "php": "~8.1.0|~8.2.0|~8.3.0|~8.4.0", + "magento/framework": "*", + "magento/module-catalog-graph-ql": "*", + "magento/module-inventory-api": "*", + "magento/module-inventory-sales-api": "*" + }, + "autoload": { + "files": [ + "registration.php" + ], + "psr-4": { + "GraphCommerce\\GraphQlCatalogInventory\\": "" + } + } +} diff --git a/src/GraphQlCatalogInventory/etc/module.xml b/src/GraphQlCatalogInventory/etc/module.xml new file mode 100644 index 0000000..b50f804 --- /dev/null +++ b/src/GraphQlCatalogInventory/etc/module.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/GraphQlCatalogInventory/etc/schema.graphqls b/src/GraphQlCatalogInventory/etc/schema.graphqls new file mode 100644 index 0000000..6f7416e --- /dev/null +++ b/src/GraphQlCatalogInventory/etc/schema.graphqls @@ -0,0 +1,16 @@ +interface ProductInterface { + stock_sources: [ProductStockSource!]! @doc(description: "The quantity of the product in each enabled source of the stock of the website, in source priority order.") @resolver(class: "GraphCommerce\\GraphQlCatalogInventory\\Model\\Resolver\\StockSources") +} + +type ProductStockSource @doc(description: "The physical quantity of a product in one source. Reservations count on the stock only, so the quantity is not the salable quantity.") { + source_code: String! + name: String! + quantity: Float! + in_stock: Boolean! @doc(description: "The source item status.") + is_pickup_location: Boolean! @doc(description: "The source is an active in-store pickup location.") + latitude: Float + longitude: Float + postcode: String + city: String + country_code: String +} diff --git a/src/GraphQlCatalogInventory/registration.php b/src/GraphQlCatalogInventory/registration.php new file mode 100644 index 0000000..e4e3ded --- /dev/null +++ b/src/GraphQlCatalogInventory/registration.php @@ -0,0 +1,6 @@ +