diff --git a/.github/scripts/dist-qa-local-docker.sh b/.github/scripts/dist-qa-local-docker.sh new file mode 100755 index 00000000000..c84e4e26d11 --- /dev/null +++ b/.github/scripts/dist-qa-local-docker.sh @@ -0,0 +1,36 @@ +#!/bin/sh +# Convenience runner for dist-qa-local.sh on a host whose PHP cannot run the tools +# (for example a CLI PHP without ext-xml). It runs the portable script inside a container +# built from a Magento PHP image that provides ext-xml + composer, mounting the working tree +# (git-worktree aware) so git history and change detection work. +# +# Image resolution order: +# 1. $DIST_QA_IMAGE +# 2. the image of a running container whose name/image matches magento-php / php-noxdebug +# +# Usage: +# .github/scripts/dist-qa-local-docker.sh [base-ref] +set -eu + +TOP=$(git rev-parse --show-toplevel) +COMMON=$(cd "$(git rev-parse --git-common-dir)" && pwd -P) + +# Mount a directory containing both the working tree and (for git worktrees) the shared .git. +MOUNT=$(dirname "$TOP") +case "$COMMON/" in + "$MOUNT"/*) : ;; + *) MOUNT="/" ;; +esac + +IMAGE="${DIST_QA_IMAGE:-}" +if [ -z "$IMAGE" ]; then + IMAGE=$(docker ps --format '{{.Image}}' 2>/dev/null | grep -iE 'magento-php|php-noxdebug' | head -n1 || true) +fi +if [ -z "$IMAGE" ]; then + echo "No PHP image found. Set DIST_QA_IMAGE=." >&2 + exit 2 +fi + +echo "Running dist QA in $IMAGE (mount: $MOUNT)" +exec docker run --rm -v "$MOUNT":"$MOUNT" -w "$TOP" "$IMAGE" \ + sh -c 'git config --global --add safe.directory "*" >/dev/null 2>&1; exec sh .github/scripts/dist-qa-local.sh "$@"' _ "$@" diff --git a/.github/scripts/dist-qa-local.sh b/.github/scripts/dist-qa-local.sh new file mode 100755 index 00000000000..e5999f99588 --- /dev/null +++ b/.github/scripts/dist-qa-local.sh @@ -0,0 +1,102 @@ +#!/bin/sh +# Local mirror of .github/workflows/dist-qa.yml. +# +# Runs the SAME php-lint + phpcs (Magento2) + phpmd checks CI runs on a pull request, +# against the PHP files changed on this branch, so a red gate is caught before pushing. +# It installs the same tool versions CI uses (magento/magento-coding-standard:^40, +# phpmd/phpmd:^2.15) into a local, git-ignored .ci-tools directory. +# +# Usage: +# .github/scripts/dist-qa-local.sh [base-ref] +# +# base-ref defaults to the dist-2.4.x branch inferred from the current branch name, +# falling back to dist-2.4.9. Unlike CI (which only sees committed changes), this also +# includes staged, unstaged and untracked PHP files so the check is useful before commit. +set -eu + +cd "$(git rev-parse --show-toplevel)" + +BASE="${1:-}" +if [ -z "$BASE" ]; then + BASE=$(git rev-parse --abbrev-ref HEAD | sed -n 's#.*\(2\.4\.[0-9][0-9]*\).*#dist-\1#p') + [ -z "$BASE" ] && BASE="dist-2.4.9" +fi +if ! git rev-parse --verify --quiet "$BASE" >/dev/null 2>&1; then + echo "Base ref '$BASE' not found. Pass it explicitly: dist-qa-local.sh " >&2 + exit 2 +fi + +echo "Base ref: $BASE" + +CHANGED=$( + { + git diff --name-only --diff-filter=ACMR "$BASE"...HEAD -- '*.php' + git diff --name-only --diff-filter=ACMR -- '*.php' + git diff --name-only --diff-filter=ACMR --cached -- '*.php' + git ls-files --others --exclude-standard -- '*.php' + } 2>/dev/null | sort -u | grep -v '^$' || true +) + +if [ -z "$CHANGED" ]; then + echo "No changed PHP files. Nothing to check." + exit 0 +fi + +echo "Changed PHP files:" +echo "$CHANGED" | sed 's/^/ /' + +if [ ! -x .ci-tools/vendor/bin/phpcs ] || [ ! -x .ci-tools/vendor/bin/phpmd ]; then + echo "Installing QA tools into .ci-tools ..." + mkdir -p .ci-tools + ( + cd .ci-tools + [ -f composer.json ] || composer init --no-interaction --name=jeanmarcos/dist-qa-tools >/dev/null + composer config allow-plugins.dealerdirect/phpcodesniffer-composer-installer true >/dev/null + composer require --no-interaction magento/magento-coding-standard:^40 phpmd/phpmd:^2.15 + ) +fi + +status=0 + +echo +echo "== PHP lint ==" +OLDIFS=$IFS +IFS=' +' +for f in $CHANGED; do + if ! php -l "$f" >/dev/null 2>&1; then + echo " FAIL $f" + php -l "$f" 2>&1 | sed 's/^/ /' + status=1 + fi +done +IFS=$OLDIFS +[ "$status" -eq 0 ] && echo " ok" + +echo +echo "== phpcs (Magento2) ==" +# shellcheck disable=SC2086 +if .ci-tools/vendor/bin/phpcs --standard=Magento2 -p $CHANGED; then + echo " ok" +else + status=1 +fi + +echo +echo "== phpmd (production files only) ==" +PHPMD_LIST=$(echo "$CHANGED" | grep -v '/Test/' | paste -sd, -) +if [ -z "$PHPMD_LIST" ]; then + echo " no non-test PHP files changed; skipping" +elif .ci-tools/vendor/bin/phpmd "$PHPMD_LIST" text .github/phpmd-ruleset.xml; then + echo " ok" +else + status=1 +fi + +echo +if [ "$status" -eq 0 ]; then + echo "dist QA (local): PASS" +else + echo "dist QA (local): FAIL" +fi +exit "$status" diff --git a/.github/workflows/dist-qa.yml b/.github/workflows/dist-qa.yml index 81645d0109b..eba29c18e6d 100644 --- a/.github/workflows/dist-qa.yml +++ b/.github/workflows/dist-qa.yml @@ -73,4 +73,11 @@ jobs: - name: phpmd if: steps.changed.outputs.count != '0' run: | - .ci-tools/vendor/bin/phpmd "$(paste -sd, changed.txt)" github .github/phpmd-ruleset.xml + # Mirror Magento's static suite: mess-detection targets production code, not tests + # (test doubles inflate coupling by design). + grep -v '/Test/' changed.txt > phpmd.txt || true + if [ -s phpmd.txt ]; then + .ci-tools/vendor/bin/phpmd "$(paste -sd, phpmd.txt)" github .github/phpmd-ruleset.xml + else + echo "No non-test PHP files changed; skipping phpmd." + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000000..7310dc1f7f5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# Local QA tool install created by .github/scripts/dist-qa-local.sh (mirrors the CI's ephemeral .ci-tools). +/.ci-tools/ diff --git a/DISTRIBUTION.md b/DISTRIBUTION.md index ac1d237ef0c..53f45e91f0b 100644 --- a/DISTRIBUTION.md +++ b/DISTRIBUTION.md @@ -77,7 +77,10 @@ This mirrors the target Magento version and does not collide with Adobe's own Configuration > Catalog > Inventory > Storefront Stock Visualizer) renders a traffic-light level (server-side, no quantity exposed) or the exact salable quantity over a cacheable AJAX fragment, aggregate or broken down per source - (source-reservation aware). A dedicated cache tag keeps the panel fresh on both + (source-reservation aware). Composite products resolve their availability by + type — the selected configurable variant, the sellable bundle count, a + per-component breakdown, or an aggregate in-stock status — each selectable in + the admin. A dedicated cache tag keeps the panel fresh on both demand (reservation) and supply (source-item) changes; the purge runs synchronously or over a database-backed queue. Notes: - Run `bin/magento setup:upgrade` (registers the per-product attributes and the @@ -92,6 +95,7 @@ This mirrors the target Magento version and does not collide with Adobe's own ## License -Redistributed under **AFL-3.0**. Original Adobe copyright and license notices -are retained in every source file, as required. See [`LICENSE_AFL.txt`](LICENSE_AFL.txt) +Redistributed under **AFL-3.0**. Files derived from upstream Magento retain +Adobe's original copyright and license notices; files original to this fork carry +their own copyright under **OSL-3.0 / AFL-3.0**. See [`LICENSE_AFL.txt`](LICENSE_AFL.txt) and [`NOTICE`](NOTICE). diff --git a/InventoryStockVisualizer/Api/Data/ChildViewInterface.php b/InventoryStockVisualizer/Api/Data/ChildViewInterface.php new file mode 100644 index 00000000000..f6ffd80bf8e --- /dev/null +++ b/InventoryStockVisualizer/Api/Data/ChildViewInterface.php @@ -0,0 +1,45 @@ + 0; for composite types + * (configurable/grouped/bundle) it is the aggregated index salability, since their + * salable quantity is undefined at the parent level. + * + * @return bool + */ + public function isSalable(): bool; + + /** + * Whether the view carries only an aggregate salable/not-salable status. + * + * True for composite parents, whose salable quantity and per-source breakdown are + * not meaningful: the presentation must render the status word only, never a number + * and never per-source rows. + * + * @return bool + */ + public function isAggregateOnly(): bool; + + /** + * Per-child availability rows for a composite parent (children display mode). + * + * Empty unless the composite type is configured to show a per-component breakdown. + * + * @return \Magento\InventoryStockVisualizer\Api\Data\ChildViewInterface[] + */ + public function getChildren(): array; } diff --git a/InventoryStockVisualizer/Api/GetStockViewInterface.php b/InventoryStockVisualizer/Api/GetStockViewInterface.php index c98ef745af7..352597d5112 100644 --- a/InventoryStockVisualizer/Api/GetStockViewInterface.php +++ b/InventoryStockVisualizer/Api/GetStockViewInterface.php @@ -19,9 +19,15 @@ interface GetStockViewInterface /** * Build the availability view (salable quantity and per-source breakdown) for a SKU on a stock. * + * The optional product type id lets the caller skip a product load. When null it is + * resolved from the SKU. Composite types (configurable/grouped/bundle) yield an + * aggregate-only view (status without a quantity or per-source breakdown), since their + * salable quantity is undefined at the parent level. + * * @param string $sku * @param int $stockId + * @param string|null $typeId * @return \Magento\InventoryStockVisualizer\Api\Data\StockViewInterface */ - public function execute(string $sku, int $stockId): StockViewInterface; + public function execute(string $sku, int $stockId, ?string $typeId = null): StockViewInterface; } diff --git a/InventoryStockVisualizer/Block/Product/AvailabilityData.php b/InventoryStockVisualizer/Block/Product/AvailabilityData.php new file mode 100644 index 00000000000..b38b4c8b5b4 --- /dev/null +++ b/InventoryStockVisualizer/Block/Product/AvailabilityData.php @@ -0,0 +1,114 @@ +getStockIdForCurrentWebsite->execute(); + } catch (\Throwable $e) { + return null; + } + } + + /** + * Effective display config (per-product override merged over store defaults). + * + * @param ProductInterface|null $product + * @return DisplayConfig + */ + public function displayConfig(?ProductInterface $product): DisplayConfig + { + return $this->resolveDisplayConfig->forProduct($product); + } + + /** + * Availability view for the SKU in the stock, typed so composite products resolve by type. + * + * @param string $sku + * @param int $stockId + * @param string|null $typeId + * @return StockViewInterface + */ + public function view(string $sku, int $stockId, ?string $typeId): StockViewInterface + { + return $this->getStockView->execute($sku, $stockId, $typeId); + } + + /** + * Per-source scaffold rows (labels only) for the stock. + * + * @param int $stockId + * @return array + */ + public function enabledSources(int $stockId): array + { + return $this->getEnabledSources->execute($stockId); + } + + /** + * Resolve a quantity to its coarse level given the display config. + * + * @param float $qty + * @param DisplayConfig $displayConfig + * @return string + */ + public function resolveLevel(float $qty, DisplayConfig $displayConfig): string + { + return $this->levelResolver->resolve($qty, $displayConfig); + } + + /** + * Availability-bar fill percentage for a level. + * + * @param string $level + * @return int + */ + public function fillPercent(string $level): int + { + return $this->levelResolver->fillPercent($level); + } +} diff --git a/InventoryStockVisualizer/Block/Product/StockVisualizer.php b/InventoryStockVisualizer/Block/Product/StockVisualizer.php index 65fe6508f74..66919b82ac9 100644 --- a/InventoryStockVisualizer/Block/Product/StockVisualizer.php +++ b/InventoryStockVisualizer/Block/Product/StockVisualizer.php @@ -13,15 +13,10 @@ use Magento\Framework\Serialize\Serializer\Json; use Magento\Framework\View\Element\Template; use Magento\Framework\View\Element\Template\Context; -use Magento\InventoryCatalog\Model\GetStockIdForCurrentWebsite; -use Magento\InventoryStockVisualizer\Api\GetStockViewInterface; use Magento\InventoryStockVisualizer\Model\Cache\CacheTag; use Magento\InventoryStockVisualizer\Model\Config; use Magento\InventoryStockVisualizer\Model\DisplayConfig; -use Magento\InventoryStockVisualizer\Model\GetEnabledSources; use Magento\InventoryStockVisualizer\Model\Level; -use Magento\InventoryStockVisualizer\Model\LevelResolver; -use Magento\InventoryStockVisualizer\Model\ResolveDisplayConfig; /** * Product-page "Availability" panel. @@ -53,23 +48,15 @@ class StockVisualizer extends Template implements IdentityInterface * @param Registry $registry * @param Config $config * @param Json $json - * @param ResolveDisplayConfig $resolveDisplayConfig - * @param GetStockIdForCurrentWebsite $getStockIdForCurrentWebsite - * @param GetStockViewInterface $getStockView - * @param GetEnabledSources $getEnabledSources - * @param LevelResolver $levelResolver - * @param array $data + * @param AvailabilityData $availabilityData + * @param array $data */ public function __construct( Context $context, private readonly Registry $registry, private readonly Config $config, private readonly Json $json, - private readonly ResolveDisplayConfig $resolveDisplayConfig, - private readonly GetStockIdForCurrentWebsite $getStockIdForCurrentWebsite, - private readonly GetStockViewInterface $getStockView, - private readonly GetEnabledSources $getEnabledSources, - private readonly LevelResolver $levelResolver, + private readonly AvailabilityData $availabilityData, array $data = [] ) { parent::__construct($context, $data); @@ -136,7 +123,45 @@ public function getPanelTitle(): string */ public function getAggregateLevel(): string { - return $this->levelResolver->resolve($this->getView()->getSalableQty(), $this->getDisplayConfig()); + $view = $this->getView(); + if ($view->isAggregateOnly()) { + return $view->isSalable() ? Level::HIGH : Level::OUT; + } + + return $this->availabilityData->resolveLevel($view->getSalableQty(), $this->getDisplayConfig()); + } + + /** + * Whether the panel shows only an aggregate salable/not-salable status. + * + * True for composite types (configurable/grouped/bundle): no quantity number, no + * per-source breakdown and no AJAX fetch — just the in-stock/out-of-stock word. + * + * @return bool + */ + public function isAggregateStatusOnly(): bool + { + return $this->getView()->isAggregateOnly(); + } + + /** + * Child structure scaffold (sku and label) for the composite children fragment. + * + * Only the stable structure is server-rendered; the volatile per-child stock arrives over AJAX. + * + * @return array + */ + public function getChildScaffold(): array + { + $rows = []; + foreach ($this->getView()->getChildren() as $child) { + $rows[] = [ + 'sku' => $child->getSku(), + 'label' => $child->getLabel(), + ]; + } + + return $rows; } /** @@ -150,7 +175,7 @@ public function getAggregateLevel(): string */ public function getQuantityStatusLevel(): string { - return $this->getView()->getSalableQty() > 0.0 ? Level::HIGH : Level::OUT; + return $this->getView()->isSalable() ? Level::HIGH : Level::OUT; } /** @@ -169,7 +194,7 @@ public function getLevelSources(): array } $rows[] = [ 'name' => (string) $source->getName(), - 'level' => $this->levelResolver->resolve($qty, $this->getDisplayConfig()), + 'level' => $this->availabilityData->resolveLevel($qty, $this->getDisplayConfig()), ]; } @@ -183,7 +208,7 @@ public function getLevelSources(): array */ public function getScaffoldSources(): array { - return $this->getEnabledSources->execute((int) $this->getStockId()); + return $this->availabilityData->enabledSources((int) $this->getStockId()); } /** @@ -197,23 +222,174 @@ public function showSourceLabels(): bool } /** - * Full data-mage-init payload for quantity mode (keyed by the widget name). + * The interactive strategy for the current product. + * + * Returns '' when the panel is fully server-rendered (level / aggregate-status / children / + * grouped-sets) and needs no client component. * * @return string */ - public function getWidgetConfig(): string + public function getComponentKind(): string { - $product = $this->getProduct(); + if ($this->isVariantMode()) { + return 'variant'; + } + if ($this->isBundleMaxMode()) { + return 'bundleMax'; + } + if ($this->getCompositeMode() === Config::COMPOSITE_MODE_CHILDREN) { + return 'children'; + } + if ($this->isAggregateStatusOnly()) { + return ''; + } + if ($this->isLevelMode()) { + return ''; + } + + return 'quantity'; + } + + /** + * Mount payload for the Knockout availability component. + * + * The x-magento-init config seeds the component observables with the server-rendered + * state, so hydration produces no visible change. + * + * @return string + */ + public function getInitJson(): string + { + $kind = $this->getComponentKind(); + if ($kind === '') { + return ''; + } return $this->json->serialize([ - 'stockVisualizer' => [ - 'mode' => $this->config->getMode(), + '*' => [ + 'Magento_Ui/js/core/app' => [ + 'components' => [ + 'stockVisualizer' => $this->componentConfig($kind), + ], + ], + ], + ]); + } + + /** + * Component config plus initial observable seeds for the given strategy. + * + * @param string $kind + * @return array + */ + private function componentConfig(string $kind): array + { + $product = $this->getProduct(); + $sku = $product ? (string) $product->getSku() : ''; + $onDemand = $this->isOnDemand(); + $perSource = $this->isPerSource(); + $config = [ + 'component' => 'Magento_InventoryStockVisualizer/js/view/availability', + 'kind' => $kind, + 'mode' => $this->config->getMode(), + 'sku' => $sku, + 'levelDisplay' => $this->isLevelMode(), + 'configVersion' => $this->config->getVersion(), + ]; + + if ($kind === 'quantity') { + $level = $this->getQuantityStatusLevel(); + $config += [ 'scope' => $this->config->getScope(), - 'sku' => $product ? (string) $product->getSku() : '', + 'ajaxUrl' => $this->getUrl('inventory_stockviz/product/view'), + 'perSource' => $perSource, 'hideEmptySources' => $this->config->hideEmptySources(), + 'showSourceLabels' => $this->showSourceLabels(), + 'sourceScaffold' => $perSource ? array_values($this->getScaffoldSources()) : [], + 'sourcesVisible' => $perSource && !$onDemand, + 'loading' => !$onDemand, + 'showPrompt' => false, + 'showCta' => $onDemand, + ]; + } elseif ($kind === 'variant') { + $level = $this->getAggregateLevel(); + $config += [ 'ajaxUrl' => $this->getUrl('inventory_stockviz/product/view'), - ], - ]); + 'perSource' => $perSource, + 'hideEmptySources' => $this->config->hideEmptySources(), + 'showSourceLabels' => $this->showSourceLabels(), + 'sourceScaffold' => $perSource ? array_values($this->getScaffoldSources()) : [], + 'loading' => false, + 'showPrompt' => true, + 'showCta' => false, + ]; + } elseif ($kind === 'children') { + $level = $this->getAggregateLevel(); + $config += [ + 'ajaxUrl' => $this->getUrl('inventory_stockviz/product/children'), + 'childScaffold' => $this->getChildScaffold(), + 'childrenVisible' => !$onDemand, + 'loading' => false, + 'showPrompt' => false, + 'showCta' => $onDemand, + ]; + } else { + $level = $this->getAggregateLevel(); + $config += [ + 'ajaxUrl' => $this->getUrl('inventory_stockviz/product/bundleMax'), + 'loading' => !$onDemand, + 'showPrompt' => false, + 'showCta' => $onDemand, + ]; + } + + $config['statusLevel'] = $level; + $config['statusWord'] = $this->levelLabel($level); + + return $config; + } + + /** + * Configured composite display mode for the current product type, or '' for stockable types. + * + * @return string + */ + public function getCompositeMode(): string + { + $product = $this->getProduct(); + if ($product === null) { + return ''; + } + switch ($product->getTypeId()) { + case 'configurable': + return $this->config->getConfigurableMode(); + case 'bundle': + return $this->config->getBundleMode(); + case 'grouped': + return $this->config->getGroupedMode(); + default: + return ''; + } + } + + /** + * Whether the configurable variant-driven mode is active. + * + * @return bool + */ + public function isVariantMode(): bool + { + return $this->getCompositeMode() === Config::COMPOSITE_MODE_VARIANT; + } + + /** + * Whether the bundle sellable-count mode is active. + * + * @return bool + */ + public function isBundleMaxMode(): bool + { + return $this->getCompositeMode() === Config::COMPOSITE_MODE_MAX; } /** @@ -235,7 +411,7 @@ public function levelClass(string $level): string */ public function levelFill(string $level): int { - return $this->levelResolver->fillPercent($level); + return $this->availabilityData->fillPercent($level); } /** @@ -299,11 +475,7 @@ private function getStockId(): ?int { if (!$this->stockResolved) { $this->stockResolved = true; - try { - $this->stockId = (int) $this->getStockIdForCurrentWebsite->execute(); - } catch (\Throwable $e) { - $this->stockId = null; - } + $this->stockId = $this->availabilityData->resolveStockId(); } return $this->stockId; @@ -317,7 +489,7 @@ private function getStockId(): ?int private function getDisplayConfig(): DisplayConfig { if ($this->displayConfig === null) { - $this->displayConfig = $this->resolveDisplayConfig->forProduct($this->getProduct()); + $this->displayConfig = $this->availabilityData->displayConfig($this->getProduct()); } return $this->displayConfig; @@ -331,9 +503,11 @@ private function getDisplayConfig(): DisplayConfig private function getView(): \Magento\InventoryStockVisualizer\Api\Data\StockViewInterface { if ($this->view === null) { - $this->view = $this->getStockView->execute( - (string) $this->getProduct()->getSku(), - (int) $this->getStockId() + $product = $this->getProduct(); + $this->view = $this->availabilityData->view( + (string) $product->getSku(), + (int) $this->getStockId(), + $product ? $product->getTypeId() : null ); } diff --git a/InventoryStockVisualizer/Controller/FragmentResponder.php b/InventoryStockVisualizer/Controller/FragmentResponder.php new file mode 100644 index 00000000000..4cfbef4cdc9 --- /dev/null +++ b/InventoryStockVisualizer/Controller/FragmentResponder.php @@ -0,0 +1,153 @@ +jsonFactory->create(); + } + + /** + * Set the payload and mark the response non-cacheable. + * + * @param Json $result + * @param mixed $data + * @return Json + */ + public function uncacheable(Json $result, $data): Json + { + $result->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0', true); + $result->setHeader('Pragma', 'no-cache', true); + + return $result->setData(['data' => $data]); + } + + /** + * Set the payload and apply public cache headers with one purge tag per product id. + * + * @param Json $result + * @param int[] $productIds + * @param mixed $data + * @return Json + */ + public function cacheable(Json $result, array $productIds, $data): Json + { + $ttl = $this->config->getTtl() ?: (int) $this->scopeConfig->getValue(PageCacheConfig::XML_PAGECACHE_TTL); + $ttl = $ttl > 0 ? $ttl : self::DEFAULT_TTL; + + $tags = []; + foreach ($productIds as $productId) { + $tags[] = CacheTag::CACHE_TAG . '_' . (int) $productId; + } + + $result->setHeader('X-Magento-Tags', implode(',', $tags), true); + $result->setHeader('Cache-Control', 'public, max-age=' . $ttl . ', s-maxage=' . $ttl, true); + $result->setHeader('Pragma', 'cache', true); + + return $result->setData(['data' => $data]); + } + + /** + * Resolve the real product id for a single SKU, or 0 when it cannot be resolved. + * + * @param string $sku + * @return int + */ + public function resolveProductId(string $sku): int + { + $ids = $this->resolveProductIds([$sku]); + + return $ids[0] ?? 0; + } + + /** + * Resolve the real product ids for the given SKUs, or an empty array on failure. + * + * @param string[] $skus + * @return int[] + */ + public function resolveProductIds(array $skus): array + { + if ($skus === []) { + return []; + } + + try { + $map = $this->getProductIdsBySkus->execute($skus); + } catch (\Throwable $e) { + return []; + } + + return array_values(array_map('intval', $map)); + } + + /** + * Resolve a SKU from a product id (composite selection sends the chosen child id), or ''. + * + * @param int $productId + * @return string + */ + public function resolveSkuFromProductId(int $productId): string + { + if ($productId <= 0) { + return ''; + } + + try { + $skus = $this->getSkusByProductIds->execute([$productId]); + } catch (\Throwable $e) { + return ''; + } + + return (string) ($skus[$productId] ?? ''); + } +} diff --git a/InventoryStockVisualizer/Controller/Product/BundleMax.php b/InventoryStockVisualizer/Controller/Product/BundleMax.php new file mode 100644 index 00000000000..2ab9f6daab3 --- /dev/null +++ b/InventoryStockVisualizer/Controller/Product/BundleMax.php @@ -0,0 +1,121 @@ +responder->create(); + $sku = (string) $this->request->getParam('sku'); + $selections = $this->parseSelections($this->request->getParam('selections')); + + if (!$this->config->isEnabled() || $sku === '') { + return $this->responder->uncacheable($result, null); + } + + try { + $stockId = $this->getStockIdForCurrentWebsite->execute(); + $bundleMax = $this->getBundleMaxSellable->execute($sku, $selections, $stockId); + } catch (\Throwable $e) { + return $this->responder->uncacheable($result, null); + } + + $max = $bundleMax->getMax(); + $productIds = $bundleMax->getProductIds(); + $payload = $this->payload($sku, $max); + + if ($max === null || $productIds === []) { + return $this->responder->uncacheable($result, $payload); + } + + return $this->responder->cacheable($result, $productIds, $payload); + } + + /** + * Project the sellable count for the client. + * + * The exact count in quantity display, or a coarse level (never the number) in level display. + * + * @param string $sku + * @param int|null $max + * @return array|null + */ + private function payload(string $sku, ?int $max): ?array + { + if ($max === null) { + return null; + } + if ($this->config->getDisplayType() === Config::DISPLAY_TYPE_LEVEL) { + return [ + 'level' => $this->levelResolver->resolve((float) $max, $this->resolveDisplayConfig->forSku($sku)), + 'salable' => $max > 0, + ]; + } + + return ['max' => $max]; + } + + /** + * Parse the chosen selections (selection id => customer qty) from the request. + * + * @param mixed $raw + * @return array + */ + private function parseSelections($raw): array + { + if (is_string($raw)) { + $decoded = json_decode($raw, true); + $raw = is_array($decoded) ? $decoded : []; + } + + return is_array($raw) ? $raw : []; + } +} diff --git a/InventoryStockVisualizer/Controller/Product/Children.php b/InventoryStockVisualizer/Controller/Product/Children.php new file mode 100644 index 00000000000..9813e795b98 --- /dev/null +++ b/InventoryStockVisualizer/Controller/Product/Children.php @@ -0,0 +1,134 @@ +responder->create(); + $sku = (string) $this->request->getParam('sku'); + if ($sku === '') { + $sku = $this->responder->resolveSkuFromProductId((int) $this->request->getParam('product_id')); + } + + if (!$this->config->isEnabled() || $sku === '') { + return $this->responder->uncacheable($result, null); + } + + try { + $stockId = $this->getStockIdForCurrentWebsite->execute(); + $view = $this->getStockView->execute($sku, $stockId); + } catch (\Throwable $e) { + return $this->responder->uncacheable($result, null); + } + + if (!$view->isAggregateOnly()) { + return $this->responder->uncacheable($result, null); + } + + $data = $this->stockViewSerializer->serializeChildren($view); + $sets = $this->resolveSets($sku, $stockId); + if ($sets !== null) { + $data['sets'] = $sets; + } + + $productIds = $this->responder->resolveProductIds($this->childSkus($data['children'])); + if ($productIds === []) { + return $this->responder->uncacheable($result, $data); + } + + return $this->responder->cacheable($result, $productIds, $data); + } + + /** + * Maximum complete grouped sets, when the calculator applies to this product, else null. + * + * @param string $sku + * @param int $stockId + * @return int|null + */ + private function resolveSets(string $sku, int $stockId): ?int + { + if (!$this->config->isGroupedSetsCalculatorEnabled() + || $this->config->getGroupedMode() !== Config::COMPOSITE_MODE_CHILDREN + ) { + return null; + } + + $sets = $this->getGroupedSetsMax->execute($sku, $stockId); + if ($sets === null) { + return null; + } + + // Level display exposes no exact numbers, so the set count collapses to a coarse flag. + if ($this->config->getDisplayType() === Config::DISPLAY_TYPE_LEVEL) { + return $sets > 0 ? 1 : 0; + } + + return $sets; + } + + /** + * Collect the child SKUs from the serialized children rows. + * + * @param array $children + * @return string[] + */ + private function childSkus(array $children): array + { + $skus = []; + foreach ($children as $child) { + $skus[] = $child['sku']; + } + + return $skus; + } +} diff --git a/InventoryStockVisualizer/Controller/Product/View.php b/InventoryStockVisualizer/Controller/Product/View.php index 366ecfaef93..656590bd96c 100644 --- a/InventoryStockVisualizer/Controller/Product/View.php +++ b/InventoryStockVisualizer/Controller/Product/View.php @@ -8,50 +8,34 @@ namespace Magento\InventoryStockVisualizer\Controller\Product; use Magento\Framework\App\Action\HttpGetActionInterface; -use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Framework\App\RequestInterface; use Magento\Framework\Controller\Result\Json; -use Magento\Framework\Controller\Result\JsonFactory; use Magento\InventoryCatalog\Model\GetStockIdForCurrentWebsite; -use Magento\InventoryCatalogApi\Model\GetProductIdsBySkusInterface; use Magento\InventoryStockVisualizer\Api\GetStockViewInterface; -use Magento\InventoryStockVisualizer\Model\Cache\CacheTag; +use Magento\InventoryStockVisualizer\Controller\FragmentResponder; use Magento\InventoryStockVisualizer\Model\Config; -use Magento\InventoryStockVisualizer\Model\ResolveDisplayConfig; use Magento\InventoryStockVisualizer\Model\StockViewSerializer; -use Magento\PageCache\Model\Config as PageCacheConfig; /** * Return the quantity availability of a product as a cacheable, tag-purgeable JSON fragment. */ class View implements HttpGetActionInterface { - /** - * Default public lifetime when neither the feature nor the FPC define one. - */ - private const DEFAULT_TTL = 86400; - /** * @param RequestInterface $request - * @param JsonFactory $jsonFactory * @param Config $config * @param GetStockViewInterface $getStockView * @param StockViewSerializer $stockViewSerializer * @param GetStockIdForCurrentWebsite $getStockIdForCurrentWebsite - * @param GetProductIdsBySkusInterface $getProductIdsBySkus - * @param ResolveDisplayConfig $resolveDisplayConfig - * @param ScopeConfigInterface $scopeConfig + * @param FragmentResponder $responder */ public function __construct( private readonly RequestInterface $request, - private readonly JsonFactory $jsonFactory, private readonly Config $config, private readonly GetStockViewInterface $getStockView, private readonly StockViewSerializer $stockViewSerializer, private readonly GetStockIdForCurrentWebsite $getStockIdForCurrentWebsite, - private readonly GetProductIdsBySkusInterface $getProductIdsBySkus, - private readonly ResolveDisplayConfig $resolveDisplayConfig, - private readonly ScopeConfigInterface $scopeConfig + private readonly FragmentResponder $responder ) { } @@ -66,76 +50,29 @@ public function __construct( */ public function execute(): Json { - $result = $this->jsonFactory->create(); + $result = $this->responder->create(); $sku = (string) $this->request->getParam('sku'); - - if (!$this->config->isEnabled() || $sku === '') { - return $this->uncacheable($result)->setData(['data' => null]); + if ($sku === '') { + $sku = $this->responder->resolveSkuFromProductId((int) $this->request->getParam('product_id')); } - if ($this->resolveDisplayConfig->forSku($sku)->isLevel()) { - return $this->uncacheable($result)->setData(['data' => null]); + if (!$this->config->isEnabled() || $sku === '') { + return $this->responder->uncacheable($result, null); } try { $stockId = $this->getStockIdForCurrentWebsite->execute(); $view = $this->getStockView->execute($sku, $stockId); $data = $this->stockViewSerializer->serialize($view); - $productId = $this->resolveProductId($sku); + $productId = $this->responder->resolveProductId($sku); } catch (\Throwable $e) { - return $this->uncacheable($result)->setData(['data' => null]); + return $this->responder->uncacheable($result, null); } if ($productId <= 0) { - return $this->uncacheable($result)->setData(['data' => null]); + return $this->responder->uncacheable($result, null); } - return $this->cacheable($result, $productId)->setData(['data' => $data]); - } - - /** - * Resolve the real product id for a SKU, or 0 when it cannot be resolved. - * - * @param string $sku - * @return int - */ - private function resolveProductId(string $sku): int - { - $ids = $this->getProductIdsBySkus->execute([$sku]); - - return (int) ($ids[$sku] ?? 0); - } - - /** - * Apply public cache headers and the dedicated purge tag. - * - * @param Json $result - * @param int $productId - * @return Json - */ - private function cacheable(Json $result, int $productId): Json - { - $ttl = $this->config->getTtl() ?: (int) $this->scopeConfig->getValue(PageCacheConfig::XML_PAGECACHE_TTL); - $ttl = $ttl > 0 ? $ttl : self::DEFAULT_TTL; - - $result->setHeader('X-Magento-Tags', CacheTag::CACHE_TAG . '_' . $productId, true); - $result->setHeader('Cache-Control', 'public, max-age=' . $ttl . ', s-maxage=' . $ttl, true); - $result->setHeader('Pragma', 'cache', true); - - return $result; - } - - /** - * Mark the response as non-cacheable. - * - * @param Json $result - * @return Json - */ - private function uncacheable(Json $result): Json - { - $result->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0', true); - $result->setHeader('Pragma', 'no-cache', true); - - return $result; + return $this->responder->cacheable($result, [$productId], $data); } } diff --git a/InventoryStockVisualizer/Model/Availability/BundleMaxResult.php b/InventoryStockVisualizer/Model/Availability/BundleMaxResult.php new file mode 100644 index 00000000000..0609138753a --- /dev/null +++ b/InventoryStockVisualizer/Model/Availability/BundleMaxResult.php @@ -0,0 +1,47 @@ +max; + } + + /** + * Child product ids whose stock bounds the count. + * + * @return int[] + */ + public function getProductIds(): array + { + return $this->productIds; + } +} diff --git a/InventoryStockVisualizer/Model/Availability/CompositeViewBuilder.php b/InventoryStockVisualizer/Model/Availability/CompositeViewBuilder.php new file mode 100644 index 00000000000..f028148717a --- /dev/null +++ b/InventoryStockVisualizer/Model/Availability/CompositeViewBuilder.php @@ -0,0 +1,183 @@ +getCompositeMode($typeId) === Config::COMPOSITE_MODE_CHILDREN) { + $view = $this->buildChildrenView($sku, $stockId, $slrEnabled); + if ($view !== null) { + return $view; + } + } + + return $this->buildAggregateView($sku, $stockId, $slrEnabled); + } + + /** + * Configured composite display mode for the product type. + * + * @param string $typeId + * @return string + */ + private function getCompositeMode(string $typeId): string + { + switch ($typeId) { + case 'configurable': + return $this->config->getConfigurableMode(); + case 'bundle': + return $this->config->getBundleMode(); + case 'grouped': + return $this->config->getGroupedMode(); + default: + return Config::COMPOSITE_MODE_STATUS; + } + } + + /** + * Build a per-child availability view for a composite parent, or null when it has no children. + * + * Each child is an ordinary stockable SKU, so its salable quantity comes from the usual + * quantity API; the parent status reflects whether any child is salable. + * + * @param string $sku + * @param int $stockId + * @param bool $slrEnabled + * @return StockViewInterface|null + */ + private function buildChildrenView(string $sku, int $stockId, bool $slrEnabled): ?StockViewInterface + { + $rows = $this->getCompositeChildren->execute($sku); + if (!$rows) { + return null; + } + + $children = []; + $anySalable = false; + foreach ($rows as $row) { + try { + $qty = (float) $this->getProductSalableQty->execute($row['sku'], $stockId); + } catch (LocalizedException $e) { + $qty = 0.0; + } + $salable = $qty > 0.0; + $anySalable = $anySalable || $salable; + $children[] = $this->childViewFactory->create([ + 'sku' => $row['sku'], + 'label' => $row['label'], + 'qty' => $qty, + 'salable' => $salable, + ]); + } + + return $this->createView($sku, $stockId, $slrEnabled, $anySalable, $children); + } + + /** + * Build an aggregate-only view (salable status from the index, no quantity or sources). + * + * @param string $sku + * @param int $stockId + * @param bool $slrEnabled + * @return StockViewInterface + */ + private function buildAggregateView(string $sku, int $stockId, bool $slrEnabled): StockViewInterface + { + $data = $this->getStockItemData->execute($sku, $stockId); + $salable = $data !== null && (bool) ($data[GetStockItemDataInterface::IS_SALABLE] ?? false); + + return $this->createView($sku, $stockId, $slrEnabled, $salable, []); + } + + /** + * Assemble an aggregate-only stock view and announce it for extension. + * + * @param string $sku + * @param int $stockId + * @param bool $slrEnabled + * @param bool $salable + * @param array $children + * @return StockViewInterface + */ + private function createView( + string $sku, + int $stockId, + bool $slrEnabled, + bool $salable, + array $children + ): StockViewInterface { + /** @var StockViewInterface $view */ + $view = $this->stockViewFactory->create([ + 'sku' => $sku, + 'stockId' => $stockId, + 'salableQty' => 0.0, + 'sourceReservationsEnabled' => $slrEnabled, + 'sources' => [], + 'salable' => $salable, + 'aggregateOnly' => true, + 'children' => $children, + ]); + + $this->eventManager->dispatch( + 'inventory_stock_visualizer_view_load_after', + ['stock_view' => $view, 'sku' => $sku, 'stock_id' => $stockId] + ); + + return $view; + } +} diff --git a/InventoryStockVisualizer/Model/Availability/GetBundleMaxSellable.php b/InventoryStockVisualizer/Model/Availability/GetBundleMaxSellable.php new file mode 100644 index 00000000000..68d8bd58394 --- /dev/null +++ b/InventoryStockVisualizer/Model/Availability/GetBundleMaxSellable.php @@ -0,0 +1,179 @@ + $selectedQtyBySelectionId chosen selection id => customer qty + * @param int $stockId + * @return BundleMaxResult + */ + public function execute(string $sku, array $selectedQtyBySelectionId, int $stockId): BundleMaxResult + { + $product = $this->loadBundleProduct($sku); + if ($product === null) { + return new BundleMaxResult(null, []); + } + + $type = $product->getTypeInstance(); + $optionIds = $type->getOptionsIds($product); + if (!$optionIds) { + return new BundleMaxResult(null, []); + } + + $evaluation = $this->evaluateSelections($type, $product, $optionIds, $selectedQtyBySelectionId, $stockId); + if (!$this->hasAllRequiredOptions($type, $product, $evaluation['chosenOptionIds'])) { + return new BundleMaxResult(null, []); + } + + return new BundleMaxResult($evaluation['max'], $evaluation['productIds']); + } + + /** + * Load the SKU and return it only when it is a bundle product, otherwise null. + * + * @param string $sku + * @return ProductInterface|null + */ + private function loadBundleProduct(string $sku): ?ProductInterface + { + try { + $product = $this->productRepository->get($sku); + } catch (LocalizedException $e) { + return null; + } + + return $product->getTypeInstance() instanceof BundleType ? $product : null; + } + + /** + * Fold the chosen selections into the sellable cap, the touched option ids and product ids. + * + * @param BundleType $type + * @param ProductInterface $product + * @param int[] $optionIds + * @param array $selectedQtyBySelectionId + * @param int $stockId + * @return array{max: int|null, chosenOptionIds: array, productIds: int[]} + */ + private function evaluateSelections( + BundleType $type, + ProductInterface $product, + array $optionIds, + array $selectedQtyBySelectionId, + int $stockId + ): array { + $chosenOptionIds = []; + $productIds = []; + $max = null; + foreach ($type->getSelectionsCollection($optionIds, $product) as $selection) { + $evaluated = $this->evaluateSelection($selection, $selectedQtyBySelectionId, $stockId); + if ($evaluated === null) { + continue; + } + $chosenOptionIds[$evaluated['optionId']] = true; + if ($evaluated['cap'] === null) { + continue; + } + if ($evaluated['productId'] > 0) { + $productIds[$evaluated['productId']] = true; + } + $max = $max === null ? $evaluated['cap'] : min($max, $evaluated['cap']); + } + + return ['max' => $max, 'chosenOptionIds' => $chosenOptionIds, 'productIds' => array_keys($productIds)]; + } + + /** + * Evaluate one selection: null when not chosen, else its option id, sellable cap and product id. + * + * The cap is null when the selection is chosen but its per-bundle quantity is not positive, so + * the option still counts as chosen without bounding the sellable count. + * + * @param \Magento\Bundle\Model\Selection $selection + * @param array $selectedQtyBySelectionId + * @param int $stockId + * @return array{optionId: int, cap: int|null, productId: int}|null + */ + private function evaluateSelection($selection, array $selectedQtyBySelectionId, int $stockId): ?array + { + $selectionId = (int) $selection->getSelectionId(); + if (!array_key_exists($selectionId, $selectedQtyBySelectionId)) { + return null; + } + + $optionId = (int) $selection->getOptionId(); + $perBundleQty = $selection->getSelectionCanChangeQty() + ? max(1.0, (float) $selectedQtyBySelectionId[$selectionId]) + : (float) $selection->getSelectionQty(); + if ($perBundleQty <= 0.0) { + return ['optionId' => $optionId, 'cap' => null, 'productId' => 0]; + } + + try { + $childSalable = (float) $this->getProductSalableQty->execute((string) $selection->getSku(), $stockId); + } catch (LocalizedException $e) { + $childSalable = 0.0; + } + + return [ + 'optionId' => $optionId, + 'cap' => (int) floor($childSalable / $perBundleQty), + 'productId' => (int) $selection->getProductId(), + ]; + } + + /** + * Whether every required bundle option has a chosen selection. + * + * @param BundleType $type + * @param ProductInterface $product + * @param array $chosenOptionIds + * @return bool + */ + private function hasAllRequiredOptions(BundleType $type, ProductInterface $product, array $chosenOptionIds): bool + { + foreach ($type->getOptionsCollection($product) as $option) { + if ($option->getRequired() && !isset($chosenOptionIds[(int) $option->getOptionId()])) { + return false; + } + } + + return true; + } +} diff --git a/InventoryStockVisualizer/Model/Availability/GetCompositeChildren.php b/InventoryStockVisualizer/Model/Availability/GetCompositeChildren.php new file mode 100644 index 00000000000..25e15d4fe3c --- /dev/null +++ b/InventoryStockVisualizer/Model/Availability/GetCompositeChildren.php @@ -0,0 +1,91 @@ + + */ + public function execute(string $sku): array + { + try { + $product = $this->productRepository->get($sku); + } catch (LocalizedException $e) { + return []; + } + + $type = $product->getTypeInstance(); + if ($type instanceof Configurable) { + $children = $type->getUsedProducts($product); + } elseif ($type instanceof Grouped) { + $children = $type->getAssociatedProducts($product); + } elseif ($type instanceof BundleType) { + $children = $this->getBundleSelections($type, $product); + } else { + return []; + } + + $rows = []; + $seen = []; + foreach ($children as $child) { + $childSku = (string) $child->getSku(); + if ($childSku === '' || isset($seen[$childSku])) { + continue; + } + $seen[$childSku] = true; + $rows[] = [ + 'sku' => $childSku, + 'label' => (string) ($child->getName() ?: $childSku), + ]; + } + + return $rows; + } + + /** + * Selectable products across all bundle options. + * + * @param BundleType $type + * @param ProductInterface $product + * @return \Magento\Framework\DataObject[] + */ + private function getBundleSelections(BundleType $type, ProductInterface $product): array + { + $optionIds = $type->getOptionsIds($product); + if (!$optionIds) { + return []; + } + + return iterator_to_array($type->getSelectionsCollection($optionIds, $product)); + } +} diff --git a/InventoryStockVisualizer/Model/Availability/GetGroupedSetsMax.php b/InventoryStockVisualizer/Model/Availability/GetGroupedSetsMax.php new file mode 100644 index 00000000000..432d00aa7a9 --- /dev/null +++ b/InventoryStockVisualizer/Model/Availability/GetGroupedSetsMax.php @@ -0,0 +1,111 @@ +productRepository->get($sku); + } catch (LocalizedException $e) { + return null; + } + + if ($product->getTypeId() !== Grouped::TYPE_CODE) { + return null; + } + + $childIds = []; + foreach ($product->getTypeInstance()->getChildrenIds((int) $product->getId()) as $group) { + foreach ($group as $childId) { + $childIds[] = (int) $childId; + } + } + if (!$childIds) { + return null; + } + + $recipeBySku = $this->getRecipeBySku($product); + + $max = null; + foreach ($this->getSkusByProductIds->execute($childIds) as $childSku) { + $childSku = (string) $childSku; + $recipeQty = $recipeBySku[$childSku] ?? 1.0; + + try { + $childSalable = (float) $this->getProductSalableQty->execute($childSku, $stockId); + } catch (LocalizedException $e) { + $childSalable = 0.0; + } + + $cap = (int) floor(max(0.0, $childSalable) / $recipeQty); + $max = $max === null ? $cap : min($max, $cap); + } + + return $max; + } + + /** + * Per-component default quantity (the recipe), keyed by child SKU; 0 or missing falls back to 1. + * + * @param \Magento\Catalog\Api\Data\ProductInterface $product + * @return array + */ + private function getRecipeBySku($product): array + { + $recipe = []; + foreach ($product->getProductLinks() as $link) { + if ($link->getLinkType() !== 'associated') { + continue; + } + $extension = $link->getExtensionAttributes(); + $qty = $extension !== null ? (float) $extension->getQty() : 0.0; + $recipe[(string) $link->getLinkedProductSku()] = $qty > 0.0 ? $qty : 1.0; + } + + return $recipe; + } +} diff --git a/InventoryStockVisualizer/Model/Availability/SourceViewBuilder.php b/InventoryStockVisualizer/Model/Availability/SourceViewBuilder.php new file mode 100644 index 00000000000..342e1ce8e7e --- /dev/null +++ b/InventoryStockVisualizer/Model/Availability/SourceViewBuilder.php @@ -0,0 +1,75 @@ +getSourcesAssignedToStock->execute($stockId) as $source) { + if ($source->isEnabled()) { + $enabledSources[(string) $source->getSourceCode()] = $source; + } + } + if (!$enabledSources) { + return []; + } + + $sourceCodes = array_keys($enabledSources); + $physical = $this->getSourceItemQuantity->execute([$sku], $sourceCodes); + $reservations = $slrEnabled ? $this->getSourceReservations->execute([$sku], $sourceCodes) : []; + + $rows = []; + foreach ($enabledSources as $sourceCode => $source) { + $available = ($physical[$sourceCode][$sku] ?? 0.0) + ($reservations[$sourceCode][$sku] ?? 0.0); + $rows[] = $this->sourceViewFactory->create([ + 'sourceCode' => (string) $sourceCode, + 'qty' => max(0.0, $available), + 'name' => $source->getName() ?: (string) $sourceCode, + ]); + } + + return $rows; + } +} diff --git a/InventoryStockVisualizer/Model/Cache/DispatchPurge.php b/InventoryStockVisualizer/Model/Cache/DispatchPurge.php index 1f7f2a3b81a..32d83eefdd2 100644 --- a/InventoryStockVisualizer/Model/Cache/DispatchPurge.php +++ b/InventoryStockVisualizer/Model/Cache/DispatchPurge.php @@ -11,10 +11,9 @@ use Magento\Framework\Indexer\IndexerRegistry; use Magento\Framework\MessageQueue\PublisherInterface; use Magento\InventoryIndexer\Indexer\InventoryIndexer; -use Magento\InventoryStockVisualizer\Model\Config; /** - * Route a purge to the synchronous flush or the coalescing queue, following the configured strategy. + * Route a purge to the synchronous flush or the coalescing queue, following the inventory indexer. * * Under on-save indexing the fragment must refresh immediately, so the flush runs inline. Under * scheduled indexing the site already runs background workers, so the purge is offloaded to a queue @@ -35,14 +34,12 @@ class DispatchPurge private const GUARD_TTL = 60; /** - * @param Config $config * @param IndexerRegistry $indexerRegistry * @param PublisherInterface $publisher * @param CacheInterface $cache * @param PurgeBySkus $purgeBySkus */ public function __construct( - private readonly Config $config, private readonly IndexerRegistry $indexerRegistry, private readonly PublisherInterface $publisher, private readonly CacheInterface $cache, @@ -51,6 +48,8 @@ public function __construct( } /** + * Flush the stock visualizer cache for the given SKUs, synchronously or via the async queue. + * * @param string[] $skus * @return void */ @@ -73,20 +72,14 @@ public function execute(array $skus): void } /** - * Whether purges should be offloaded to the queue rather than flushed inline. + * Whether purges should be offloaded to the queue rather than flushed inline. This follows the + * inventory indexer: scheduled indexing already runs background workers, so purges are queued; + * on-save indexing needs the fragment fresh immediately, so they flush inline. * * @return bool */ private function isAsync(): bool { - $mode = $this->config->getAsyncPurge(); - if ($mode === Config::ASYNC_PURGE_ON) { - return true; - } - if ($mode === Config::ASYNC_PURGE_OFF) { - return false; - } - try { return $this->indexerRegistry->get(InventoryIndexer::INDEXER_ID)->isScheduled(); } catch (\Throwable $e) { diff --git a/InventoryStockVisualizer/Model/Cache/SourceItemDeltaBuilder.php b/InventoryStockVisualizer/Model/Cache/SourceItemDeltaBuilder.php index 35835022c33..fcd6a4db8c4 100644 --- a/InventoryStockVisualizer/Model/Cache/SourceItemDeltaBuilder.php +++ b/InventoryStockVisualizer/Model/Cache/SourceItemDeltaBuilder.php @@ -27,12 +27,34 @@ public function __construct( } /** + * Build the grouped deltas the decider expects from the saved or deleted source items. + * * @param SourceItemInterface[] $sourceItems items being written (or removed) - * @param array $snapshot old quantity keyed by "sku|source" + * @param array $snapshot old quantity keyed by "sku|source" * @param bool $removed whether the items are being deleted (new quantity is zero) * @return array}>> */ public function build(array $sourceItems, array $snapshot, bool $removed = false): array + { + $collected = $this->collectDeltas($sourceItems, $snapshot, $removed); + if (!$collected['bySkuSource']) { + return []; + } + + $stockIdsBySource = $this->resolveStockIdsBySourceCodes->execute(array_keys($collected['sources'])); + + return $this->expandToStocks($collected['bySkuSource'], $stockIdsBySource); + } + + /** + * Net each written item's quantity against the snapshot into a sku => source => delta map. + * + * @param SourceItemInterface[] $sourceItems + * @param array $snapshot + * @param bool $removed + * @return array{bySkuSource: array>, sources: array} + */ + private function collectDeltas(array $sourceItems, array $snapshot, bool $removed): array { $bySkuSource = []; $sources = []; @@ -43,20 +65,26 @@ public function build(array $sourceItems, array $snapshot, bool $removed = false continue; } $new = $removed ? 0.0 : (float) $item->getQuantity(); - $old = $snapshot[$sku . '|' . $source] ?? 0.0; - $delta = $new - $old; + $delta = $new - ($snapshot[$sku . '|' . $source] ?? 0.0); if ($delta === 0.0) { continue; } $bySkuSource[$sku][$source] = $delta; $sources[$source] = true; } - if (!$bySkuSource) { - return []; - } - $stockIdsBySource = $this->resolveStockIdsBySourceCodes->execute(array_keys($sources)); + return ['bySkuSource' => $bySkuSource, 'sources' => $sources]; + } + /** + * Expand each source delta to every stock the source is linked to. + * + * @param array> $bySkuSource + * @param array $stockIdsBySource + * @return array}>> + */ + private function expandToStocks(array $bySkuSource, array $stockIdsBySource): array + { $deltas = []; foreach ($bySkuSource as $sku => $sourceDeltas) { foreach ($sourceDeltas as $source => $delta) { diff --git a/InventoryStockVisualizer/Model/Config.php b/InventoryStockVisualizer/Model/Config.php index 69cf368b7d9..5ded88daab2 100644 --- a/InventoryStockVisualizer/Model/Config.php +++ b/InventoryStockVisualizer/Model/Config.php @@ -27,15 +27,20 @@ class Config public const XML_PATH_TTL = 'cataloginventory/stock_visualizer/ttl'; public const XML_PATH_SHOW_SOURCE_LABELS = 'cataloginventory/stock_visualizer/show_source_labels'; public const XML_PATH_HIDE_EMPTY_SOURCES = 'cataloginventory/stock_visualizer/hide_empty_sources'; - public const XML_PATH_ASYNC_PURGE = 'cataloginventory/stock_visualizer/async_purge'; + public const XML_PATH_CONFIGURABLE_MODE = 'cataloginventory/stock_visualizer/composite_configurable_mode'; + public const XML_PATH_BUNDLE_MODE = 'cataloginventory/stock_visualizer/composite_bundle_mode'; + public const XML_PATH_GROUPED_MODE = 'cataloginventory/stock_visualizer/composite_grouped_mode'; + public const XML_PATH_GROUPED_SETS_CALCULATOR = + 'cataloginventory/stock_visualizer/composite_grouped_sets_calculator'; + + public const COMPOSITE_MODE_STATUS = 'status'; + public const COMPOSITE_MODE_CHILDREN = 'children'; + public const COMPOSITE_MODE_VARIANT = 'variant'; + public const COMPOSITE_MODE_MAX = 'max'; public const MODE_INSTANT = 'instant'; public const MODE_ON_DEMAND = 'on_demand'; - public const ASYNC_PURGE_AUTO = 'auto'; - public const ASYNC_PURGE_ON = 'on'; - public const ASYNC_PURGE_OFF = 'off'; - public const DISPLAY_TYPE_QUANTITY = 'quantity'; public const DISPLAY_TYPE_LEVEL = 'level'; @@ -173,18 +178,82 @@ public function hideEmptySources($store = null): bool } /** - * Cache-purge delivery strategy: auto (async only under scheduled indexing), on, or off. + * Availability display mode for configurable products. * - * The store is irrelevant for the write-path decision, so this is read on the default scope. + * @param int|string|null $store + * @return string + */ + public function getConfigurableMode($store = null): string + { + return (string) $this->scopeConfig->getValue( + self::XML_PATH_CONFIGURABLE_MODE, + ScopeInterface::SCOPE_STORE, + $store + ) ?: self::COMPOSITE_MODE_VARIANT; + } + + /** + * Availability display mode for bundle products. + * + * @param int|string|null $store + * @return string + */ + public function getBundleMode($store = null): string + { + return (string) $this->scopeConfig->getValue(self::XML_PATH_BUNDLE_MODE, ScopeInterface::SCOPE_STORE, $store) + ?: self::COMPOSITE_MODE_MAX; + } + + /** + * Availability display mode for grouped products. * + * @param int|string|null $store * @return string */ - public function getAsyncPurge(): string + public function getGroupedMode($store = null): string { - $value = (string) $this->scopeConfig->getValue(self::XML_PATH_ASYNC_PURGE); + return (string) $this->scopeConfig->getValue(self::XML_PATH_GROUPED_MODE, ScopeInterface::SCOPE_STORE, $store) + ?: self::COMPOSITE_MODE_CHILDREN; + } - return in_array($value, [self::ASYNC_PURGE_ON, self::ASYNC_PURGE_OFF], true) - ? $value - : self::ASYNC_PURGE_AUTO; + /** + * Whether the grouped "complete sets" calculator is shown alongside the per-component list. + * + * @param int|string|null $store + * @return bool + */ + public function isGroupedSetsCalculatorEnabled($store = null): bool + { + return $this->scopeConfig->isSetFlag( + self::XML_PATH_GROUPED_SETS_CALCULATOR, + ScopeInterface::SCOPE_STORE, + $store + ); + } + + /** + * Short fingerprint of the display configuration that shapes the AJAX fragments. + * + * The storefront appends it to the fragment request, so changing any of these settings mints a + * fresh cache key and the new output is served immediately instead of waiting for a tag purge. + * + * @param int|string|null $store + * @return string + */ + public function getVersion($store = null): string + { + return substr(hash('sha256', implode('|', [ + $this->getDisplayType($store), + $this->getScope($store), + $this->getLevelBasis($store), + (string) $this->getLevelHigh($store), + (string) $this->getLevelLow($store), + $this->showSourceLabels($store) ? '1' : '0', + $this->hideEmptySources($store) ? '1' : '0', + $this->getConfigurableMode($store), + $this->getBundleMode($store), + $this->getGroupedMode($store), + $this->isGroupedSetsCalculatorEnabled($store) ? '1' : '0', + ])), 0, 12); } } diff --git a/InventoryStockVisualizer/Model/Config/Source/BundleMode.php b/InventoryStockVisualizer/Model/Config/Source/BundleMode.php new file mode 100644 index 00000000000..ffa777881dc --- /dev/null +++ b/InventoryStockVisualizer/Model/Config/Source/BundleMode.php @@ -0,0 +1,40 @@ +> + */ + public function toOptionArray(): array + { + return [ + [ + 'value' => Config::COMPOSITE_MODE_MAX, + 'label' => __('Sellable bundles (how many of the current selection)') + ], + [ + 'value' => Config::COMPOSITE_MODE_CHILDREN, + 'label' => __('Per component (each selection stock)') + ], + [ + 'value' => Config::COMPOSITE_MODE_STATUS, + 'label' => __('Aggregate status (in stock / out of stock)') + ], + ]; + } +} diff --git a/InventoryStockVisualizer/Model/Config/Source/AsyncPurge.php b/InventoryStockVisualizer/Model/Config/Source/CompositeMode.php similarity index 54% rename from InventoryStockVisualizer/Model/Config/Source/AsyncPurge.php rename to InventoryStockVisualizer/Model/Config/Source/CompositeMode.php index 52af5c13e74..e094f22da44 100644 --- a/InventoryStockVisualizer/Model/Config/Source/AsyncPurge.php +++ b/InventoryStockVisualizer/Model/Config/Source/CompositeMode.php @@ -11,9 +11,9 @@ use Magento\InventoryStockVisualizer\Model\Config; /** - * Cache-purge delivery-strategy options for the stock visualizer. + * Availability display modes for composite product types (configurable/grouped/bundle). */ -class AsyncPurge implements OptionSourceInterface +class CompositeMode implements OptionSourceInterface { /** * @inheritdoc @@ -23,9 +23,8 @@ class AsyncPurge implements OptionSourceInterface public function toOptionArray(): array { return [ - ['value' => Config::ASYNC_PURGE_AUTO, 'label' => __('Auto (async under scheduled indexing)')], - ['value' => Config::ASYNC_PURGE_ON, 'label' => __('Always async (queue)')], - ['value' => Config::ASYNC_PURGE_OFF, 'label' => __('Always synchronous')], + ['value' => Config::COMPOSITE_MODE_CHILDREN, 'label' => __('Per component (children stock)')], + ['value' => Config::COMPOSITE_MODE_STATUS, 'label' => __('Aggregate status (in stock / out of stock)')], ]; } } diff --git a/InventoryStockVisualizer/Model/Config/Source/ConfigurableMode.php b/InventoryStockVisualizer/Model/Config/Source/ConfigurableMode.php new file mode 100644 index 00000000000..611e04cba4b --- /dev/null +++ b/InventoryStockVisualizer/Model/Config/Source/ConfigurableMode.php @@ -0,0 +1,40 @@ +> + */ + public function toOptionArray(): array + { + return [ + [ + 'value' => Config::COMPOSITE_MODE_VARIANT, + 'label' => __('Selected variant (choose options to see stock)') + ], + [ + 'value' => Config::COMPOSITE_MODE_CHILDREN, + 'label' => __('Per component (all variants stock)') + ], + [ + 'value' => Config::COMPOSITE_MODE_STATUS, + 'label' => __('Aggregate status (in stock / out of stock)') + ], + ]; + } +} diff --git a/InventoryStockVisualizer/Model/Data/ChildView.php b/InventoryStockVisualizer/Model/Data/ChildView.php new file mode 100644 index 00000000000..3728075342f --- /dev/null +++ b/InventoryStockVisualizer/Model/Data/ChildView.php @@ -0,0 +1,62 @@ +sku; + } + + /** + * @inheritdoc + */ + public function getLabel(): string + { + return $this->label; + } + + /** + * @inheritdoc + */ + public function getQty(): float + { + return $this->qty; + } + + /** + * @inheritdoc + */ + public function isSalable(): bool + { + return $this->salable; + } +} diff --git a/InventoryStockVisualizer/Model/Data/StockView.php b/InventoryStockVisualizer/Model/Data/StockView.php index 5b8798a9d49..15996dfb90c 100644 --- a/InventoryStockVisualizer/Model/Data/StockView.php +++ b/InventoryStockVisualizer/Model/Data/StockView.php @@ -39,25 +39,49 @@ class StockView implements StockViewInterface */ private $sources; + /** + * @var bool + */ + private $salable; + + /** + * @var bool + */ + private $aggregateOnly; + + /** + * @var \Magento\InventoryStockVisualizer\Api\Data\ChildViewInterface[] + */ + private $children; + /** * @param string $sku * @param int $stockId * @param float $salableQty * @param bool $sourceReservationsEnabled * @param \Magento\InventoryStockVisualizer\Api\Data\SourceViewInterface[] $sources + * @param bool|null $salable + * @param bool $aggregateOnly + * @param \Magento\InventoryStockVisualizer\Api\Data\ChildViewInterface[] $children */ public function __construct( string $sku, int $stockId, float $salableQty, bool $sourceReservationsEnabled, - array $sources = [] + array $sources = [], + ?bool $salable = null, + bool $aggregateOnly = false, + array $children = [] ) { $this->sku = $sku; $this->stockId = $stockId; $this->salableQty = $salableQty; $this->sourceReservationsEnabled = $sourceReservationsEnabled; $this->sources = $sources; + $this->salable = $salable ?? ($salableQty > 0.0); + $this->aggregateOnly = $aggregateOnly; + $this->children = $children; } /** @@ -107,4 +131,28 @@ public function isSourceReservationsEnabled(): bool { return $this->sourceReservationsEnabled; } + + /** + * @inheritdoc + */ + public function isSalable(): bool + { + return $this->salable; + } + + /** + * @inheritdoc + */ + public function isAggregateOnly(): bool + { + return $this->aggregateOnly; + } + + /** + * @inheritdoc + */ + public function getChildren(): array + { + return $this->children; + } } diff --git a/InventoryStockVisualizer/Model/GetStockView.php b/InventoryStockVisualizer/Model/GetStockView.php index 4876efcfe36..b6320989a22 100644 --- a/InventoryStockVisualizer/Model/GetStockView.php +++ b/InventoryStockVisualizer/Model/GetStockView.php @@ -7,54 +7,62 @@ namespace Magento\InventoryStockVisualizer\Model; +use Magento\Catalog\Api\ProductRepositoryInterface; use Magento\Framework\Event\ManagerInterface as EventManagerInterface; use Magento\Framework\Exception\LocalizedException; -use Magento\InventoryApi\Api\GetSourcesAssignedToStockOrderedByPriorityInterface; +use Magento\InventoryConfigurationApi\Model\IsSourceItemManagementAllowedForProductTypeInterface; use Magento\InventoryReservationsApi\Model\SourceReservationsConfig; -use Magento\InventorySales\Model\ResourceModel\SourceReservation\GetReservationsQuantityBySkusAndSources; -use Magento\InventorySales\Model\ResourceModel\SourceReservation\GetSourceItemQuantityBySkusAndSources; use Magento\InventorySalesApi\Api\GetProductSalableQtyInterface; -use Magento\InventoryStockVisualizer\Api\Data\SourceViewInterface; -use Magento\InventoryStockVisualizer\Api\Data\SourceViewInterfaceFactory; use Magento\InventoryStockVisualizer\Api\Data\StockViewInterface; use Magento\InventoryStockVisualizer\Api\Data\StockViewInterfaceFactory; use Magento\InventoryStockVisualizer\Api\GetStockViewInterface; +use Magento\InventoryStockVisualizer\Model\Availability\CompositeViewBuilder; +use Magento\InventoryStockVisualizer\Model\Availability\SourceViewBuilder; /** * Default availability-quantity provider. + * + * Routes by product type: stockable products resolve their exact salable quantity (and, when + * per-source scope is on, the per-source breakdown); composite products delegate to the + * composite view builder for a per-child or aggregate-status view. */ class GetStockView implements GetStockViewInterface { /** * @param GetProductSalableQtyInterface $getProductSalableQty - * @param GetSourcesAssignedToStockOrderedByPriorityInterface $getSourcesAssignedToStock - * @param GetSourceItemQuantityBySkusAndSources $getSourceItemQuantity - * @param GetReservationsQuantityBySkusAndSources $getSourceReservations * @param SourceReservationsConfig $sourceReservationsConfig * @param Config $config * @param StockViewInterfaceFactory $stockViewFactory - * @param SourceViewInterfaceFactory $sourceViewFactory * @param EventManagerInterface $eventManager + * @param IsSourceItemManagementAllowedForProductTypeInterface $isSourceItemManagementAllowed + * @param ProductRepositoryInterface $productRepository + * @param SourceViewBuilder $sourceViewBuilder + * @param CompositeViewBuilder $compositeViewBuilder */ public function __construct( private readonly GetProductSalableQtyInterface $getProductSalableQty, - private readonly GetSourcesAssignedToStockOrderedByPriorityInterface $getSourcesAssignedToStock, - private readonly GetSourceItemQuantityBySkusAndSources $getSourceItemQuantity, - private readonly GetReservationsQuantityBySkusAndSources $getSourceReservations, private readonly SourceReservationsConfig $sourceReservationsConfig, private readonly Config $config, private readonly StockViewInterfaceFactory $stockViewFactory, - private readonly SourceViewInterfaceFactory $sourceViewFactory, - private readonly EventManagerInterface $eventManager + private readonly EventManagerInterface $eventManager, + private readonly IsSourceItemManagementAllowedForProductTypeInterface $isSourceItemManagementAllowed, + private readonly ProductRepositoryInterface $productRepository, + private readonly SourceViewBuilder $sourceViewBuilder, + private readonly CompositeViewBuilder $compositeViewBuilder ) { } /** * @inheritdoc */ - public function execute(string $sku, int $stockId): StockViewInterface + public function execute(string $sku, int $stockId, ?string $typeId = null): StockViewInterface { $slrEnabled = $this->sourceReservationsConfig->isEnabled(); + $typeId = $this->resolveTypeId($sku, $typeId); + + if ($typeId !== null && !$this->isSourceItemManagementAllowed->execute($typeId)) { + return $this->compositeViewBuilder->build($sku, $stockId, $typeId, $slrEnabled); + } try { $salableQty = (float) $this->getProductSalableQty->execute($sku, $stockId); @@ -63,7 +71,7 @@ public function execute(string $sku, int $stockId): StockViewInterface } $sources = $this->config->getScope() === Config::SCOPE_PER_SOURCE - ? $this->buildSources($sku, $stockId, $slrEnabled) + ? $this->sourceViewBuilder->build($sku, $stockId, $slrEnabled) : []; /** @var StockViewInterface $view */ @@ -84,39 +92,26 @@ public function execute(string $sku, int $stockId): StockViewInterface } /** - * Build the per-source availability rows for the stock (all enabled sources). + * Resolve the product type id, loading the product only when the caller did not pass it. + * + * The storefront block passes the type id from the current product to skip a load; on + * the AJAX/API path it is null and resolved from the SKU. An unresolvable SKU yields null, + * which routes to the stockable path (degrades to qty 0). * * @param string $sku - * @param int $stockId - * @param bool $slrEnabled - * @return SourceViewInterface[] + * @param string|null $typeId + * @return string|null */ - private function buildSources(string $sku, int $stockId, bool $slrEnabled): array + private function resolveTypeId(string $sku, ?string $typeId): ?string { - $enabledSources = []; - foreach ($this->getSourcesAssignedToStock->execute($stockId) as $source) { - if ($source->isEnabled()) { - $enabledSources[(string) $source->getSourceCode()] = $source; - } - } - if (!$enabledSources) { - return []; + if ($typeId !== null) { + return $typeId; } - $sourceCodes = array_keys($enabledSources); - $physical = $this->getSourceItemQuantity->execute([$sku], $sourceCodes); - $reservations = $slrEnabled ? $this->getSourceReservations->execute([$sku], $sourceCodes) : []; - - $rows = []; - foreach ($enabledSources as $sourceCode => $source) { - $available = ($physical[$sourceCode][$sku] ?? 0.0) + ($reservations[$sourceCode][$sku] ?? 0.0); - $rows[] = $this->sourceViewFactory->create([ - 'sourceCode' => (string) $sourceCode, - 'qty' => max(0.0, $available), - 'name' => $source->getName() ?: (string) $sourceCode, - ]); + try { + return $this->productRepository->get($sku)->getTypeId(); + } catch (LocalizedException $e) { + return null; } - - return $rows; } } diff --git a/InventoryStockVisualizer/Model/StockViewSerializer.php b/InventoryStockVisualizer/Model/StockViewSerializer.php index a02519e6997..1534624b8ff 100644 --- a/InventoryStockVisualizer/Model/StockViewSerializer.php +++ b/InventoryStockVisualizer/Model/StockViewSerializer.php @@ -10,27 +10,43 @@ use Magento\InventoryStockVisualizer\Api\Data\StockViewInterface; /** - * Project an availability view into the minimal quantity payload for the AJAX - * fragment: the aggregate quantity plus, in per-source scope, a compact - * source_code => qty map. Source names/labels are server-rendered, not sent here. + * Project an availability view into the minimal AJAX payload. In quantity display it carries the + * exact numbers; in level display it resolves them to coarse levels server-side, so exact + * quantities are never exposed to the client regardless of product type. */ class StockViewSerializer { /** * @param Config $config + * @param LevelResolver $levelResolver + * @param ResolveDisplayConfig $resolveDisplayConfig */ - public function __construct(private readonly Config $config) - { + public function __construct( + private readonly Config $config, + private readonly LevelResolver $levelResolver, + private readonly ResolveDisplayConfig $resolveDisplayConfig + ) { } /** * Project an availability view into the minimal AJAX payload. * + * Composite (aggregate-only) views carry no meaningful quantity or per-source breakdown, so + * only the salable status is sent. In level display the number is replaced by a coarse level. + * * @param StockViewInterface $view - * @return array{qty: float, sources?: array} + * @return array */ public function serialize(StockViewInterface $view): array { + if ($view->isAggregateOnly()) { + return ['aggregateOnly' => true, 'salable' => $view->isSalable()]; + } + + if ($this->config->getDisplayType() === Config::DISPLAY_TYPE_LEVEL) { + return $this->serializeLevel($view); + } + $data = ['qty' => $view->getSalableQty()]; if ($this->config->getScope() === Config::SCOPE_PER_SOURCE) { @@ -43,4 +59,63 @@ public function serialize(StockViewInterface $view): array return $data; } + + /** + * Project the per-child breakdown of a composite view for the children fragment: the + * aggregate salable status plus one row per child. The volatile child quantities live only + * in this cacheable fragment; in level display each child carries a coarse level, not a number. + * + * @param StockViewInterface $view + * @return array{salable: bool, children: array>} + */ + public function serializeChildren(StockViewInterface $view): array + { + $level = $this->config->getDisplayType() === Config::DISPLAY_TYPE_LEVEL; + $children = []; + foreach ($view->getChildren() as $child) { + $row = [ + 'sku' => $child->getSku(), + 'label' => $child->getLabel(), + 'salable' => $child->isSalable(), + ]; + if ($level) { + $row['level'] = $this->levelResolver->resolve( + $child->getQty(), + $this->resolveDisplayConfig->forSku($child->getSku()) + ); + } else { + $row['qty'] = $child->getQty(); + } + $children[] = $row; + } + + return ['salable' => $view->isSalable(), 'children' => $children]; + } + + /** + * Coarse-level projection of a quantity view: aggregate level plus an optional per-source map. + * + * The per-source scope adds a source_code => level map. No exact quantity leaves the server. + * + * @param StockViewInterface $view + * @return array + */ + private function serializeLevel(StockViewInterface $view): array + { + $displayConfig = $this->resolveDisplayConfig->forSku($view->getSku()); + $data = [ + 'level' => $this->levelResolver->resolve($view->getSalableQty(), $displayConfig), + 'salable' => $view->getSalableQty() > 0.0, + ]; + + if ($this->config->getScope() === Config::SCOPE_PER_SOURCE) { + $sources = []; + foreach ($view->getSources() as $source) { + $sources[$source->getSourceCode()] = $this->levelResolver->resolve($source->getQty(), $displayConfig); + } + $data['sources'] = $sources; + } + + return $data; + } } diff --git a/InventoryStockVisualizer/README.md b/InventoryStockVisualizer/README.md index b28f409b7fc..2e4de8e5e59 100644 --- a/InventoryStockVisualizer/README.md +++ b/InventoryStockVisualizer/README.md @@ -11,43 +11,85 @@ describes the MSI (Multi-Source Inventory) project in more detail. ## What it shows -The panel is fully configuration-driven under *Stores > Configuration > Catalog > -Inventory > Storefront Stock Visualizer*, and every setting can be overridden -per product through a dedicated *Stock Visualizer* attribute group. +The panel is configuration-driven under *Stores > Configuration > Catalog > +Inventory > Storefront Stock Visualizer*. The general settings sit at the top +level; two subsections group the rest — **Per-source breakdown** (single-SKU +scope options) and **Composite product types** (per-type display modes). The +display type and the level thresholds can be overridden per product through a +dedicated *Stock Visualizer* attribute group. - **Display type** — `level` renders a traffic-light state (high / medium / low / - out) **server-side inside the cached page, with no AJAX and no quantity - exposed**; `quantity` renders the exact salable number, fetched over a - cacheable AJAX fragment. -- **Scope** — `aggregate` shows a single availability for the product; `per_source` - breaks it down per source. Per-source availability is **source-reservation - aware**: it nets the physical source quantity against that source's reservation - balance, and degrades to the physical quantity when source-level reservations - are off. -- **Delivery mode** (quantity only) — `on_demand` fetches the number on a button - click; `instant` fetches it on page load. Level display always renders on load. + out) with **no exact quantity exposed**; `quantity` renders the exact salable + number. Display type is **coarse across every product type**: in level mode no + type ever reveals a number, while the per-type mode below still defines the + *structure* (aggregate, per-component, or selected variant). Level per-component + rows render a colour-coded availability bar instead of a count. +- **Scope** (single-SKU) — `aggregate` shows a single availability; `per_source` + breaks it down per source. Applies to simple / virtual / downloadable products + and to the **selected configurable variant**; composite aggregate and + per-component displays are always aggregated. Per-source availability is + **source-reservation aware**: it nets the physical source quantity against that + source's reservation balance, and degrades to the physical quantity when + source-level reservations are off. +- **Delivery mode** — for availability fetched over AJAX (exact quantity, and the + interactive composite types), `on_demand` fetches on a button click and + `instant` fetches on page load. Server-rendered availability always shows on + load. Availability is computed server-side against the **current website's stock** (resolved through the sales channel), so one website's cached fragment can never stand in for another's. +## Composite product types + +Simple, virtual and downloadable products use the single-SKU quantity/level path +above. Each composite type has its own display mode (store-scoped), because a +`GetProductSalableQtyInterface` read is undefined for a type that does not manage +its own stock — the module reads the aggregate salable status and the child +breakdown through the type-aware inventory services instead. + +| Type | Modes (default first) | Interactive? | What it shows | +|---------------|----------------------------------|---------------|-------------------------------------------------------------------------------| +| Configurable | `variant` · `children` · `status`| `variant` yes | `variant`: the selected option combination's availability (with per-source when enabled); `children`: every variant's availability; `status`: one in-stock / out-of-stock word. | +| Bundle | `max` · `children` · `status` | `max` yes | `max`: how many of the current selection can be ordered, recomputed as options and quantities change; `children`: each selection's stock; `status`: aggregate word. | +| Grouped | `children` · `status` | no | `children`: each associated product's stock, plus an optional **complete-sets calculator** (how many full sets can be assembled); `status`: aggregate word. | + +- The interactive modes read the live selection from the **native** storefront + widgets — the swatch/dropdown configurable resolves the chosen child, and the + bundle reads the `priceBundle` option config — so the panel never reconstructs a + selection from the DOM. The client sends the child product id (never a SKU); the + server resolves it. +- **Native badge de-duplication** — the module removes the core availability + badges for simple, virtual, grouped, configurable, bundle and downloadable + products, so the panel never sits next to a duplicate or contradictory core + stock badge. Options, swatches and links (rendered by separate blocks) are left + untouched. + ## Configuration All settings live under `cataloginventory/stock_visualizer`. -| Setting | Default | Purpose | -|-----------------------|--------------|----------------------------------------------------------------| -| `enabled` | `0` | Show the panel on the product page. | -| `display_type` | `level` | `level` (server-rendered semaphore) or `quantity` (AJAX number).| -| `scope` | `aggregate` | `aggregate` or `per_source` breakdown. | -| `mode` | `on_demand` | `on_demand` (fetch on click) or `instant` (fetch on load). | -| `ttl` | `0` | Public-cache lifetime of the quantity fragment; `0` = tag purge only. | -| `level_basis` | `quantity` | Compare the salable qty to absolute thresholds or to a per-product full qty. | -| `level_high` | `10` | At or above this the level is high (green). | -| `level_low` | `3` | At or above this (and below high) the level is medium; below it is low. | -| `show_source_labels` | `1` | Show the source name on each per-source row. | -| `hide_empty_sources` | `1` | Omit out-of-stock sources from the per-source breakdown. | -| `async_purge` | `auto` | Cache-purge strategy: `auto` / `on` / `off` (see below). | +| Setting | Default | Purpose | +|-------------------------------------|-------------|----------------------------------------------------------------| +| `enabled` | `0` | Show the panel on the product page. | +| `display_type` | `level` | `level` (semaphore) or `quantity` (exact number). Coarse across all types. | +| `mode` | `on_demand` | `on_demand` (fetch on click) or `instant` (fetch on load) for AJAX availability. | +| `ttl` | `0` | Public-cache lifetime of the AJAX fragments; `0` = tag purge only. | +| `level_basis` | `quantity` | Compare the salable qty to absolute thresholds or to a per-product full qty. | +| `level_high` | `10` | At or above this the level is high (green). | +| `level_low` | `3` | At or above this (and below high) the level is medium; below it is low. | +| `scope` | `aggregate` | Single-SKU breakdown: `aggregate` or `per_source`. | +| `show_source_labels` | `1` | Show the source name on each per-source row. | +| `hide_empty_sources` | `1` | Omit out-of-stock sources from the per-source breakdown. | +| `composite_configurable_mode` | `variant` | Configurable display: `variant` / `children` / `status`. | +| `composite_bundle_mode` | `max` | Bundle display: `max` / `children` / `status`. | +| `composite_grouped_mode` | `children` | Grouped display: `children` / `status`. | +| `composite_grouped_sets_calculator` | `0` | Show the complete-sets calculator (grouped `children` only). | + +Per-product overrides (attribute group *Stock Visualizer*) cover the display type +and the level thresholds: `stockviz_display_type`, `stockviz_level_basis`, +`stockviz_level_high`, `stockviz_level_low`, `stockviz_full_qty` (the percentage +reference). Scope, delivery mode and the composite modes are store-scoped only. ## Cache invalidation @@ -86,14 +128,20 @@ flowchart LR queue. The consumer clears the guard first and then flushes live state, so the last write wins. -The purge strategy is selected by `async_purge`: +The purge strategy **follows the inventory indexer automatically** — there is no +separate setting to keep in sync. When the *Inventory* index runs **on schedule**, +purges offload to the queue; when it runs **on save (realtime)**, purges flush +synchronously. The queue path uses a **database-backed** message queue (no +RabbitMQ required). -- `auto` (default) — offload to the queue **only when inventory indexing runs on - schedule**; otherwise flush synchronously. -- `on` — always offload to the queue. -- `off` — always flush synchronously. +### Configuration changes show immediately -The queue path uses a **database-backed** message queue (no RabbitMQ required). +Tag purges track stock changes, not configuration changes, and the AJAX fragments +are publicly cacheable. To keep a settings change from being masked by a cached +fragment, every fragment request carries a short **config fingerprint** (`_cv`) +derived from the display settings (`Config::getVersion()`). Changing any display +setting mints a fresh cache key, so the new output is served on the next load +without a manual cache flush. ## Operational requirements @@ -108,9 +156,19 @@ The queue path uses a **database-backed** message queue (no RabbitMQ required). Public service contracts live in this module's `Api` namespace: -- `GetStockViewInterface` builds the availability view for a SKU in a stock. +- `GetStockViewInterface` builds the availability view for a SKU in a stock, + optionally typed so composite products resolve their aggregate and child + breakdown instead of an (undefined) direct salable quantity. - `Api\Data\StockViewInterface` / `Api\Data\SourceViewInterface` carry the - aggregate and per-source availability. + aggregate and per-source availability; `Api\Data\ChildViewInterface` carries one + composite child's label and availability. + +The AJAX fragments are served by controllers under the `inventory_stockviz` +front-name — `product/view` (single SKU or configurable variant), `product/children` +(composite child breakdown and the grouped sets calculator) and `product/bundleMax` +(sellable count for a bundle selection). Composite reads go through the +`Model\Availability` services (`GetCompositeChildren`, `GetGroupedSetsMax`, +`GetBundleMaxSellable`), each independently testable. The default `GetStockViewInterface` implementation can be swapped through a DI `preference` to change how availability is computed without touching the panel, diff --git a/InventoryStockVisualizer/Test/Unit/Controller/Product/ViewTest.php b/InventoryStockVisualizer/Test/Unit/Controller/Product/ViewTest.php index e877661e4e3..ce9d08cd09e 100644 --- a/InventoryStockVisualizer/Test/Unit/Controller/Product/ViewTest.php +++ b/InventoryStockVisualizer/Test/Unit/Controller/Product/ViewTest.php @@ -13,8 +13,10 @@ use Magento\Framework\Controller\Result\JsonFactory; use Magento\InventoryCatalog\Model\GetStockIdForCurrentWebsite; use Magento\InventoryCatalogApi\Model\GetProductIdsBySkusInterface; +use Magento\InventoryCatalogApi\Model\GetSkusByProductIdsInterface; use Magento\InventoryStockVisualizer\Api\Data\StockViewInterface; use Magento\InventoryStockVisualizer\Api\GetStockViewInterface; +use Magento\InventoryStockVisualizer\Controller\FragmentResponder; use Magento\InventoryStockVisualizer\Controller\Product\View; use Magento\InventoryStockVisualizer\Model\Config; use Magento\InventoryStockVisualizer\Model\DisplayConfig; @@ -62,6 +64,11 @@ class ViewTest extends TestCase */ private $getProductIdsBySkus; + /** + * @var GetSkusByProductIdsInterface|MockObject + */ + private $getSkusByProductIds; + /** * @var ResolveDisplayConfig|MockObject */ @@ -106,6 +113,7 @@ protected function setUp(): void $this->getProductIdsBySkus = $this->createMock(GetProductIdsBySkusInterface::class); $this->resolveDisplayConfig = $this->createMock(ResolveDisplayConfig::class); $this->scopeConfig = $this->createMock(ScopeConfigInterface::class); + $this->getSkusByProductIds = $this->createMock(GetSkusByProductIdsInterface::class); $this->result = $this->createMock(Json::class); $this->result->method('setHeader')->willReturnCallback( @@ -126,16 +134,21 @@ function ($data): Json { $jsonFactory = $this->createMock(JsonFactory::class); $jsonFactory->method('create')->willReturn($this->result); + $responder = new FragmentResponder( + $jsonFactory, + $this->scopeConfig, + $this->config, + $this->getProductIdsBySkus, + $this->getSkusByProductIds + ); + $this->controller = new View( $this->request, - $jsonFactory, $this->config, $this->getStockView, $this->serializer, $this->getStockId, - $this->getProductIdsBySkus, - $this->resolveDisplayConfig, - $this->scopeConfig + $responder ); } @@ -173,25 +186,6 @@ public function testBlankSkuReturnsUncacheableNull(): void $this->assertArrayNotHasKey('X-Magento-Tags', $this->headers); } - /** - * Level mode never computes or leaks the quantity through this endpoint. - * - * @return void - */ - public function testLevelModeNeverLeaksQuantity(): void - { - $this->request->method('getParam')->willReturn(self::SKU); - $this->config->method('isEnabled')->willReturn(true); - $this->resolveDisplayConfig->method('forSku')->willReturn($this->displayConfig(Config::DISPLAY_TYPE_LEVEL)); - $this->getStockView->expects($this->never())->method('execute'); - - $this->controller->execute(); - - $this->assertSame(['data' => null], $this->data); - $this->assertStringContainsString('no-store', $this->headers['Cache-Control']); - $this->assertArrayNotHasKey('X-Magento-Tags', $this->headers); - } - /** * Quantity mode emits the payload, the SKU-resolved purge tag and public cache headers. * diff --git a/InventoryStockVisualizer/Test/Unit/Model/Availability/GetBundleMaxSellableTest.php b/InventoryStockVisualizer/Test/Unit/Model/Availability/GetBundleMaxSellableTest.php new file mode 100644 index 00000000000..8d8e888569b --- /dev/null +++ b/InventoryStockVisualizer/Test/Unit/Model/Availability/GetBundleMaxSellableTest.php @@ -0,0 +1,194 @@ +productRepository = $this->createMock(ProductRepositoryInterface::class); + $this->getProductSalableQty = $this->createMock(GetProductSalableQtyInterface::class); + $this->type = $this->createMock(BundleType::class); + + $product = $this->createMock(Product::class); + $product->method('getTypeInstance')->willReturn($this->type); + $this->productRepository->method('get')->willReturn($product); + $this->type->method('getOptionsIds')->willReturn([1, 2]); + + $this->model = new GetBundleMaxSellable($this->productRepository, $this->getProductSalableQty); + } + + /** + * One required option: max is floor(childSalable / perBundleQty). + * + * @return void + */ + public function testSingleRequiredOption(): void + { + $this->givenOptions([1 => true]); + $this->givenSelections([ + ['id' => 11, 'option' => 1, 'sku' => 'A', 'qty' => 2.0, 'changeable' => false], + ]); + $this->getProductSalableQty->method('execute')->willReturn(10.0); + + $result = $this->model->execute('BUNDLE-1', [11 => 1], self::STOCK_ID); + $this->assertSame(5, $result->getMax()); + $this->assertSame([1011], $result->getProductIds()); + } + + /** + * The result is the minimum across all chosen selections/options. + * + * @return void + */ + public function testMinAcrossOptions(): void + { + $this->givenOptions([1 => true, 2 => true]); + $this->givenSelections([ + ['id' => 11, 'option' => 1, 'sku' => 'A', 'qty' => 1.0, 'changeable' => false], + ['id' => 21, 'option' => 2, 'sku' => 'B', 'qty' => 2.0, 'changeable' => false], + ]); + $this->getProductSalableQty->method('execute')->willReturnMap([ + ['A', self::STOCK_ID, 9.0], + ['B', self::STOCK_ID, 8.0], + ]); + + // A: floor(9/1)=9 ; B: floor(8/2)=4 => min 4 + $result = $this->model->execute('BUNDLE-1', [11 => 1, 21 => 1], self::STOCK_ID); + $this->assertSame(4, $result->getMax()); + $this->assertSame([1011, 1021], $result->getProductIds()); + } + + /** + * A required option with no chosen selection yields null (prompt to finish selecting). + * + * @return void + */ + public function testRequiredOptionUnselectedReturnsNull(): void + { + $this->givenOptions([1 => true, 2 => true]); + $this->givenSelections([ + ['id' => 11, 'option' => 1, 'sku' => 'A', 'qty' => 1.0, 'changeable' => false], + ['id' => 21, 'option' => 2, 'sku' => 'B', 'qty' => 1.0, 'changeable' => false], + ]); + $this->getProductSalableQty->method('execute')->willReturn(10.0); + + // Only option 1 chosen; option 2 is required but unchosen. + $result = $this->model->execute('BUNDLE-1', [11 => 1], self::STOCK_ID); + $this->assertNull($result->getMax()); + $this->assertSame([], $result->getProductIds()); + } + + /** + * Changeable qty uses the customer's per-bundle quantity. + * + * @return void + */ + public function testChangeableQtyUsesCustomerQty(): void + { + $this->givenOptions([1 => true]); + $this->givenSelections([ + ['id' => 11, 'option' => 1, 'sku' => 'A', 'qty' => 1.0, 'changeable' => true], + ]); + $this->getProductSalableQty->method('execute')->willReturn(12.0); + + // customer qty 3 => floor(12/3)=4 + $this->assertSame(4, $this->model->execute('BUNDLE-1', [11 => 3], self::STOCK_ID)->getMax()); + } + + /** + * An optional, unselected option does not constrain the result. + * + * @return void + */ + public function testOptionalUnselectedIgnored(): void + { + $this->givenOptions([1 => true, 2 => false]); + $this->givenSelections([ + ['id' => 11, 'option' => 1, 'sku' => 'A', 'qty' => 1.0, 'changeable' => false], + ['id' => 21, 'option' => 2, 'sku' => 'B', 'qty' => 1.0, 'changeable' => false], + ]); + $this->getProductSalableQty->method('execute')->willReturn(7.0); + + // Option 2 optional and not chosen; only option 1 constrains => 7 + $result = $this->model->execute('BUNDLE-1', [11 => 1], self::STOCK_ID); + $this->assertSame(7, $result->getMax()); + $this->assertSame([1011], $result->getProductIds()); + } + + /** + * @param array $requiredByOptionId + * @return void + */ + private function givenOptions(array $requiredByOptionId): void + { + $options = []; + foreach ($requiredByOptionId as $optionId => $required) { + $options[] = new DataObject(['option_id' => $optionId, 'required' => $required]); + } + $this->type->method('getOptionsCollection')->willReturn($options); + } + + /** + * @param array $selections + * @return void + */ + private function givenSelections(array $selections): void + { + $rows = []; + foreach ($selections as $s) { + $rows[] = new DataObject([ + 'selection_id' => $s['id'], + 'option_id' => $s['option'], + 'product_id' => $s['pid'] ?? $s['id'] + 1000, + 'sku' => $s['sku'], + 'selection_qty' => $s['qty'], + 'selection_can_change_qty' => $s['changeable'], + ]); + } + $this->type->method('getSelectionsCollection')->willReturn($rows); + } +} diff --git a/InventoryStockVisualizer/Test/Unit/Model/Availability/GetCompositeChildrenTest.php b/InventoryStockVisualizer/Test/Unit/Model/Availability/GetCompositeChildrenTest.php new file mode 100644 index 00000000000..d49e7ca5623 --- /dev/null +++ b/InventoryStockVisualizer/Test/Unit/Model/Availability/GetCompositeChildrenTest.php @@ -0,0 +1,121 @@ +productRepository = $this->createMock(ProductRepositoryInterface::class); + $this->model = new GetCompositeChildren($this->productRepository); + } + + /** + * An unknown SKU yields no children instead of throwing. + * + * @return void + */ + public function testUnknownSkuReturnsEmpty(): void + { + $this->productRepository->method('get')->willThrowException(new NoSuchEntityException()); + + $this->assertSame([], $this->model->execute('missing')); + } + + /** + * A non-composite type yields no children. + * + * @return void + */ + public function testNonCompositeReturnsEmpty(): void + { + $this->productRepository->method('get')->willReturn($this->parentWithType(Simple::class)); + + $this->assertSame([], $this->model->execute('SIMPLE-1')); + } + + /** + * Configurable children are mapped to sku/label rows and de-duplicated by SKU. + * + * @return void + */ + public function testConfigurableChildrenMappedAndDeduped(): void + { + $type = $this->createMock(Configurable::class); + $type->method('getUsedProducts')->willReturn([ + $this->child('VAR-1', 'Variant One'), + $this->child('VAR-2', null), + $this->child('VAR-1', 'Duplicate'), + ]); + $this->productRepository->method('get')->willReturn($this->parentWithType($type)); + + $rows = $this->model->execute('CONF-1'); + + $this->assertSame( + [ + ['sku' => 'VAR-1', 'label' => 'Variant One'], + ['sku' => 'VAR-2', 'label' => 'VAR-2'], + ], + $rows + ); + } + + /** + * @param object|string $type A type-instance mock or a class name to mock. + * @return ProductInterface|MockObject + */ + private function parentWithType($type) + { + $typeInstance = is_string($type) ? $this->createMock($type) : $type; + $product = $this->createMock(Product::class); + $product->method('getTypeInstance')->willReturn($typeInstance); + + return $product; + } + + /** + * @param string $sku + * @param string|null $name + * @return Product|MockObject + */ + private function child(string $sku, ?string $name) + { + $child = $this->createMock(Product::class); + $child->method('getSku')->willReturn($sku); + $child->method('getName')->willReturn($name); + + return $child; + } +} diff --git a/InventoryStockVisualizer/Test/Unit/Model/Availability/GetGroupedSetsMaxTest.php b/InventoryStockVisualizer/Test/Unit/Model/Availability/GetGroupedSetsMaxTest.php new file mode 100644 index 00000000000..33bfa52695b --- /dev/null +++ b/InventoryStockVisualizer/Test/Unit/Model/Availability/GetGroupedSetsMaxTest.php @@ -0,0 +1,179 @@ +productRepository = $this->createMock(ProductRepositoryInterface::class); + $this->getProductSalableQty = $this->createMock(GetProductSalableQtyInterface::class); + $this->getSkusByProductIds = $this->createMock(GetSkusByProductIdsInterface::class); + $this->type = $this->createMock(Grouped::class); + $this->model = new GetGroupedSetsMax( + $this->productRepository, + $this->getProductSalableQty, + $this->getSkusByProductIds + ); + } + + /** + * A non-grouped product yields null. + * + * @return void + */ + public function testNonGroupedReturnsNull(): void + { + $this->givenProduct('simple', [], []); + + $this->assertNull($this->model->execute('SKU', self::STOCK_ID)); + } + + /** + * A grouped product with no children yields null. + * + * @return void + */ + public function testNoChildrenReturnsNull(): void + { + $this->givenProduct(Grouped::TYPE_CODE, [], []); + + $this->assertNull($this->model->execute('GRP', self::STOCK_ID)); + } + + /** + * Max sets is the minimum of floor(childSalable / recipeQty) across components. + * + * @return void + */ + public function testMinAcrossComponents(): void + { + $this->givenProduct( + Grouped::TYPE_CODE, + [6 => 'A', 7 => 'B'], + ['A' => 2.0, 'B' => 1.0] + ); + $this->getProductSalableQty->method('execute')->willReturnMap([ + ['A', self::STOCK_ID, 30.0], + ['B', self::STOCK_ID, 8.0], + ]); + + // A: floor(30/2)=15 ; B: floor(8/1)=8 => min 8 + $this->assertSame(8, $this->model->execute('GRP', self::STOCK_ID)); + } + + /** + * An out-of-stock component (absent from the in-stock link recipe) still zeroes the sets. + * + * @return void + */ + public function testOutOfStockComponentZeroesSets(): void + { + // Child C (id 8) is out of stock: it is in the full children list but NOT in the link recipe. + $this->givenProduct( + Grouped::TYPE_CODE, + [6 => 'A', 8 => 'C'], + ['A' => 1.0] + ); + $this->getProductSalableQty->method('execute')->willReturnMap([ + ['A', self::STOCK_ID, 20.0], + ['C', self::STOCK_ID, 0.0], + ]); + + $this->assertSame(0, $this->model->execute('GRP', self::STOCK_ID)); + } + + /** + * A zero default quantity falls back to a recipe of one. + * + * @return void + */ + public function testDefaultQtyFallsBackToOne(): void + { + $this->givenProduct( + Grouped::TYPE_CODE, + [6 => 'A'], + ['A' => 0.0] + ); + $this->getProductSalableQty->method('execute')->willReturn(5.0); + + $this->assertSame(5, $this->model->execute('GRP', self::STOCK_ID)); + } + + /** + * @param string $typeId + * @param array $skusById full child id => sku (the unfiltered children list) + * @param array $recipeBySku in-stock link recipe (sku => default qty) + * @return void + */ + private function givenProduct(string $typeId, array $skusById, array $recipeBySku): void + { + $links = []; + foreach ($recipeBySku as $sku => $qty) { + $links[] = new DataObject([ + 'link_type' => 'associated', + 'linked_product_sku' => $sku, + 'extension_attributes' => new DataObject(['qty' => $qty]), + ]); + } + + $this->type->method('getChildrenIds')->willReturn($skusById ? [3 => array_keys($skusById)] : []); + $this->getSkusByProductIds->method('execute')->willReturn($skusById); + + $product = $this->createMock(Product::class); + $product->method('getTypeId')->willReturn($typeId); + $product->method('getId')->willReturn(1); + $product->method('getTypeInstance')->willReturn($this->type); + $product->method('getProductLinks')->willReturn($links); + $this->productRepository->method('get')->willReturn($product); + } +} diff --git a/InventoryStockVisualizer/Test/Unit/Model/Cache/DispatchPurgeTest.php b/InventoryStockVisualizer/Test/Unit/Model/Cache/DispatchPurgeTest.php index 210afad70d5..7e31e9b26aa 100644 --- a/InventoryStockVisualizer/Test/Unit/Model/Cache/DispatchPurgeTest.php +++ b/InventoryStockVisualizer/Test/Unit/Model/Cache/DispatchPurgeTest.php @@ -13,7 +13,6 @@ use Magento\Framework\MessageQueue\PublisherInterface; use Magento\InventoryStockVisualizer\Model\Cache\DispatchPurge; use Magento\InventoryStockVisualizer\Model\Cache\PurgeBySkus; -use Magento\InventoryStockVisualizer\Model\Config; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -22,11 +21,6 @@ */ class DispatchPurgeTest extends TestCase { - /** - * @var Config|MockObject - */ - private $config; - /** * @var IndexerRegistry|MockObject */ @@ -58,14 +52,12 @@ class DispatchPurgeTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->config = $this->createMock(Config::class); $this->indexerRegistry = $this->createMock(IndexerRegistry::class); $this->publisher = $this->createMock(PublisherInterface::class); $this->cache = $this->createMock(CacheInterface::class); $this->purgeBySkus = $this->createMock(PurgeBySkus::class); $this->model = new DispatchPurge( - $this->config, $this->indexerRegistry, $this->publisher, $this->cache, @@ -87,43 +79,26 @@ public function testEmptyDoesNothing(): void } /** - * async_purge=off flushes inline regardless of the indexer mode. - * - * @return void - */ - public function testForcedSyncFlushesInline(): void - { - $this->config->method('getAsyncPurge')->willReturn(Config::ASYNC_PURGE_OFF); - $this->indexerRegistry->expects($this->never())->method('get'); - $this->purgeBySkus->expects($this->once())->method('execute')->with(['SKU-1']); - $this->publisher->expects($this->never())->method('publish'); - - $this->model->execute(['SKU-1', 'SKU-1']); - } - - /** - * auto strategy with on-save indexing flushes inline. + * On-save indexing flushes the fragment inline and de-duplicates the SKUs. * * @return void */ - public function testAutoOnSaveFlushesInline(): void + public function testOnSaveIndexingFlushesInline(): void { - $this->config->method('getAsyncPurge')->willReturn(Config::ASYNC_PURGE_AUTO); $this->indexerRegistry->method('get')->willReturn($this->indexer(false)); $this->purgeBySkus->expects($this->once())->method('execute')->with(['SKU-1']); $this->publisher->expects($this->never())->method('publish'); - $this->model->execute(['SKU-1']); + $this->model->execute(['SKU-1', 'SKU-1']); } /** - * auto strategy with scheduled indexing publishes and sets the coalescing guard. + * Scheduled indexing publishes and sets the coalescing guard. * * @return void */ - public function testAutoScheduledPublishesAndGuards(): void + public function testScheduledIndexingPublishesAndGuards(): void { - $this->config->method('getAsyncPurge')->willReturn(Config::ASYNC_PURGE_AUTO); $this->indexerRegistry->method('get')->willReturn($this->indexer(true)); $this->cache->method('load')->willReturn(false); $this->purgeBySkus->expects($this->never())->method('execute'); @@ -142,7 +117,7 @@ public function testAutoScheduledPublishesAndGuards(): void */ public function testPendingGuardCoalesces(): void { - $this->config->method('getAsyncPurge')->willReturn(Config::ASYNC_PURGE_ON); + $this->indexerRegistry->method('get')->willReturn($this->indexer(true)); $this->cache->method('load')->willReturn('1'); $this->publisher->expects($this->never())->method('publish'); $this->cache->expects($this->never())->method('save'); @@ -150,6 +125,20 @@ public function testPendingGuardCoalesces(): void $this->model->execute(['SKU-1']); } + /** + * An indexer lookup failure degrades to an inline flush rather than dropping the purge. + * + * @return void + */ + public function testIndexerFailureFlushesInline(): void + { + $this->indexerRegistry->method('get')->willThrowException(new \RuntimeException('boom')); + $this->purgeBySkus->expects($this->once())->method('execute')->with(['SKU-1']); + $this->publisher->expects($this->never())->method('publish'); + + $this->model->execute(['SKU-1']); + } + /** * @param bool $scheduled * @return IndexerInterface|MockObject diff --git a/InventoryStockVisualizer/Test/Unit/Model/ConfigTest.php b/InventoryStockVisualizer/Test/Unit/Model/ConfigTest.php index f022650ba35..9fbcf8fa644 100644 --- a/InventoryStockVisualizer/Test/Unit/Model/ConfigTest.php +++ b/InventoryStockVisualizer/Test/Unit/Model/ConfigTest.php @@ -9,6 +9,7 @@ use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\InventoryStockVisualizer\Model\Config; +use Magento\Store\Model\ScopeInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -48,6 +49,19 @@ public function testIsEnabledReadsFlag(): void $this->assertTrue($this->config->isEnabled()); } + /** + * The grouped sets calculator reads the config flag on the store scope. + * + * @return void + */ + public function testGroupedSetsCalculatorReadsFlag(): void + { + $this->scopeConfig->method('isSetFlag') + ->with(Config::XML_PATH_GROUPED_SETS_CALCULATOR, ScopeInterface::SCOPE_STORE, null) + ->willReturn(true); + $this->assertTrue($this->config->isGroupedSetsCalculatorEnabled()); + } + /** * Display type falls back to level. * diff --git a/InventoryStockVisualizer/Test/Unit/Model/GetStockViewTest.php b/InventoryStockVisualizer/Test/Unit/Model/GetStockViewTest.php index c45d09818dc..fc3b55183e7 100644 --- a/InventoryStockVisualizer/Test/Unit/Model/GetStockViewTest.php +++ b/InventoryStockVisualizer/Test/Unit/Model/GetStockViewTest.php @@ -7,16 +7,24 @@ namespace Magento\InventoryStockVisualizer\Test\Unit\Model; +use Magento\Catalog\Api\ProductRepositoryInterface; use Magento\Framework\Event\ManagerInterface as EventManagerInterface; use Magento\InventoryApi\Api\Data\SourceInterface; use Magento\InventoryApi\Api\GetSourcesAssignedToStockOrderedByPriorityInterface; +use Magento\InventoryConfigurationApi\Model\IsSourceItemManagementAllowedForProductTypeInterface; use Magento\InventoryReservationsApi\Model\SourceReservationsConfig; use Magento\InventorySales\Model\ResourceModel\SourceReservation\GetReservationsQuantityBySkusAndSources; use Magento\InventorySales\Model\ResourceModel\SourceReservation\GetSourceItemQuantityBySkusAndSources; use Magento\InventorySalesApi\Api\GetProductSalableQtyInterface; +use Magento\InventorySalesApi\Model\GetStockItemDataInterface; +use Magento\InventoryStockVisualizer\Api\Data\ChildViewInterfaceFactory; use Magento\InventoryStockVisualizer\Api\Data\SourceViewInterfaceFactory; use Magento\InventoryStockVisualizer\Api\Data\StockViewInterfaceFactory; +use Magento\InventoryStockVisualizer\Model\Availability\CompositeViewBuilder; +use Magento\InventoryStockVisualizer\Model\Availability\GetCompositeChildren; +use Magento\InventoryStockVisualizer\Model\Availability\SourceViewBuilder; use Magento\InventoryStockVisualizer\Model\Config; +use Magento\InventoryStockVisualizer\Model\Data\ChildView; use Magento\InventoryStockVisualizer\Model\Data\SourceView; use Magento\InventoryStockVisualizer\Model\Data\StockView; use Magento\InventoryStockVisualizer\Model\GetStockView; @@ -61,6 +69,26 @@ class GetStockViewTest extends TestCase */ private $config; + /** + * @var IsSourceItemManagementAllowedForProductTypeInterface|MockObject + */ + private $isSourceItemManagementAllowed; + + /** + * @var GetStockItemDataInterface|MockObject + */ + private $getStockItemData; + + /** + * @var ProductRepositoryInterface|MockObject + */ + private $productRepository; + + /** + * @var GetCompositeChildren|MockObject + */ + private $getCompositeChildren; + /** * @var GetStockView */ @@ -80,6 +108,22 @@ protected function setUp(): void $this->getSourceReservations = $this->createMock(GetReservationsQuantityBySkusAndSources::class); $this->sourceReservationsConfig = $this->createMock(SourceReservationsConfig::class); $this->config = $this->createMock(Config::class); + $this->isSourceItemManagementAllowed = $this->createMock( + IsSourceItemManagementAllowedForProductTypeInterface::class + ); + $this->getStockItemData = $this->createMock(GetStockItemDataInterface::class); + $this->productRepository = $this->createMock(ProductRepositoryInterface::class); + $this->getCompositeChildren = $this->createMock(GetCompositeChildren::class); + + // Composite types are not source-item managed; everything else behaves as stockable. + $composite = ['configurable', 'grouped', 'bundle']; + $this->isSourceItemManagementAllowed->method('execute')->willReturnCallback( + static fn (string $type): bool => !in_array($type, $composite, true) + ); + // When no type id is passed, resolution loads the product; default to a stockable type. + $product = $this->createMock(\Magento\Catalog\Api\Data\ProductInterface::class); + $product->method('getTypeId')->willReturn('simple'); + $this->productRepository->method('get')->willReturn($product); $stockViewFactory = $this->createMock(StockViewInterfaceFactory::class); $stockViewFactory->method('create')->willReturnCallback( @@ -88,24 +132,53 @@ protected function setUp(): void $args['stockId'], $args['salableQty'], $args['sourceReservationsEnabled'], - $args['sources'] + $args['sources'] ?? [], + $args['salable'] ?? null, + $args['aggregateOnly'] ?? false, + $args['children'] ?? [] ) ); $sourceViewFactory = $this->createMock(SourceViewInterfaceFactory::class); $sourceViewFactory->method('create')->willReturnCallback( static fn (array $args): SourceView => new SourceView($args['sourceCode'], $args['qty'], $args['name']) ); + $childViewFactory = $this->createMock(ChildViewInterfaceFactory::class); + $childViewFactory->method('create')->willReturnCallback( + static fn (array $args): ChildView => new ChildView( + $args['sku'], + $args['label'], + $args['qty'], + $args['salable'] + ) + ); - $this->model = new GetStockView( - $this->getProductSalableQty, + $eventManager = $this->createMock(EventManagerInterface::class); + $sourceViewBuilder = new SourceViewBuilder( $this->getSourcesAssignedToStock, $this->getSourceItemQuantity, $this->getSourceReservations, + $sourceViewFactory + ); + $compositeViewBuilder = new CompositeViewBuilder( + $this->getCompositeChildren, + $this->getProductSalableQty, + $this->getStockItemData, + $stockViewFactory, + $childViewFactory, + $eventManager, + $this->config + ); + + $this->model = new GetStockView( + $this->getProductSalableQty, $this->sourceReservationsConfig, $this->config, $stockViewFactory, - $sourceViewFactory, - $this->createMock(EventManagerInterface::class) + $eventManager, + $this->isSourceItemManagementAllowed, + $this->productRepository, + $sourceViewBuilder, + $compositeViewBuilder ); } @@ -181,6 +254,129 @@ public function testPerSourceIgnoresReservationsWhenSlrDisabled(): void $this->assertSame(6.0, $sources[0]->getQty()); } + /** + * A composite type yields an aggregate-only salable view read from the index, never the qty API. + * + * @return void + */ + public function testCompositeSalableFromIndex(): void + { + $this->sourceReservationsConfig->method('isEnabled')->willReturn(true); + $this->getProductSalableQty->expects($this->never())->method('execute'); + $this->getStockItemData->expects($this->once()) + ->method('execute') + ->with(self::SKU, self::STOCK_ID) + ->willReturn([GetStockItemDataInterface::QUANTITY => 7.0, GetStockItemDataInterface::IS_SALABLE => 1]); + + $view = $this->model->execute(self::SKU, self::STOCK_ID, 'configurable'); + + $this->assertTrue($view->isAggregateOnly()); + $this->assertTrue($view->isSalable()); + $this->assertSame([], $view->getSources()); + $this->assertSame(0.0, $view->getSalableQty()); + } + + /** + * A composite type not salable in the index is reported out of stock. + * + * @return void + */ + public function testCompositeOutOfStockFromIndex(): void + { + $this->getStockItemData->method('execute') + ->willReturn([GetStockItemDataInterface::QUANTITY => 0.0, GetStockItemDataInterface::IS_SALABLE => 0]); + + $view = $this->model->execute(self::SKU, self::STOCK_ID, 'bundle'); + + $this->assertTrue($view->isAggregateOnly()); + $this->assertFalse($view->isSalable()); + } + + /** + * A composite with no index row (null) is out of stock without throwing. + * + * @return void + */ + public function testCompositeMissingIndexRowIsOutOfStock(): void + { + $this->getStockItemData->method('execute')->willReturn(null); + + $view = $this->model->execute(self::SKU, self::STOCK_ID, 'grouped'); + + $this->assertTrue($view->isAggregateOnly()); + $this->assertFalse($view->isSalable()); + } + + /** + * Children mode lists each child's salable quantity and reflects overall salability. + * + * @return void + */ + public function testChildrenModeListsChildren(): void + { + $this->config->method('getConfigurableMode')->willReturn(Config::COMPOSITE_MODE_CHILDREN); + $this->getStockItemData->expects($this->never())->method('execute'); + $this->getCompositeChildren->method('execute')->willReturn([ + ['sku' => 'VAR-1', 'label' => 'Variant 1'], + ['sku' => 'VAR-2', 'label' => 'Variant 2'], + ]); + $this->getProductSalableQty->method('execute')->willReturnMap([ + ['VAR-1', self::STOCK_ID, 5.0], + ['VAR-2', self::STOCK_ID, 0.0], + ]); + + $view = $this->model->execute(self::SKU, self::STOCK_ID, 'configurable'); + + $this->assertTrue($view->isAggregateOnly()); + $this->assertTrue($view->isSalable()); + $children = $view->getChildren(); + $this->assertCount(2, $children); + $this->assertSame('VAR-1', $children[0]->getSku()); + $this->assertSame('Variant 1', $children[0]->getLabel()); + $this->assertSame(5.0, $children[0]->getQty()); + $this->assertTrue($children[0]->isSalable()); + $this->assertSame(0.0, $children[1]->getQty()); + $this->assertFalse($children[1]->isSalable()); + } + + /** + * Children mode falls back to the aggregate status when the parent has no children. + * + * @return void + */ + public function testChildrenModeFallsBackToStatusWhenEmpty(): void + { + $this->config->method('getBundleMode')->willReturn(Config::COMPOSITE_MODE_CHILDREN); + $this->getCompositeChildren->method('execute')->willReturn([]); + $this->getStockItemData->method('execute') + ->willReturn([GetStockItemDataInterface::IS_SALABLE => 1]); + + $view = $this->model->execute(self::SKU, self::STOCK_ID, 'bundle'); + + $this->assertTrue($view->isAggregateOnly()); + $this->assertTrue($view->isSalable()); + $this->assertSame([], $view->getChildren()); + } + + /** + * A stockable type keeps the quantity path and never reads the aggregate index. + * + * @return void + */ + public function testStockableTypeUsesQtyPathNotIndex(): void + { + $this->config->method('getScope')->willReturn(Config::SCOPE_AGGREGATE); + $this->sourceReservationsConfig->method('isEnabled')->willReturn(false); + $this->getStockItemData->expects($this->never())->method('execute'); + $this->getProductSalableQty->method('execute')->willReturn(9.0); + + $view = $this->model->execute(self::SKU, self::STOCK_ID, 'simple'); + + $this->assertFalse($view->isAggregateOnly()); + $this->assertTrue($view->isSalable()); + $this->assertSame(9.0, $view->getSalableQty()); + } + /** * @param string $code * @param string|null $name diff --git a/InventoryStockVisualizer/Test/Unit/Model/StockViewSerializerTest.php b/InventoryStockVisualizer/Test/Unit/Model/StockViewSerializerTest.php index 20b181f5437..5c9650893d7 100644 --- a/InventoryStockVisualizer/Test/Unit/Model/StockViewSerializerTest.php +++ b/InventoryStockVisualizer/Test/Unit/Model/StockViewSerializerTest.php @@ -8,8 +8,12 @@ namespace Magento\InventoryStockVisualizer\Test\Unit\Model; use Magento\InventoryStockVisualizer\Model\Config; +use Magento\InventoryStockVisualizer\Model\Data\ChildView; +use Magento\InventoryStockVisualizer\Model\DisplayConfig; use Magento\InventoryStockVisualizer\Model\Data\SourceView; use Magento\InventoryStockVisualizer\Model\Data\StockView; +use Magento\InventoryStockVisualizer\Model\LevelResolver; +use Magento\InventoryStockVisualizer\Model\ResolveDisplayConfig; use Magento\InventoryStockVisualizer\Model\StockViewSerializer; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -24,6 +28,16 @@ class StockViewSerializerTest extends TestCase */ private $config; + /** + * @var LevelResolver|MockObject + */ + private $levelResolver; + + /** + * @var ResolveDisplayConfig|MockObject + */ + private $resolveDisplayConfig; + /** * @inheritdoc */ @@ -31,6 +45,17 @@ protected function setUp(): void { parent::setUp(); $this->config = $this->createMock(Config::class); + $this->levelResolver = $this->createMock(LevelResolver::class); + $this->resolveDisplayConfig = $this->createMock(ResolveDisplayConfig::class); + $this->config->method('getDisplayType')->willReturn(Config::DISPLAY_TYPE_QUANTITY); + } + + /** + * @return StockViewSerializer + */ + private function serializer(): StockViewSerializer + { + return new StockViewSerializer($this->config, $this->levelResolver, $this->resolveDisplayConfig); } /** @@ -43,7 +68,7 @@ public function testAggregatePayload(): void $this->config->method('getScope')->willReturn(Config::SCOPE_AGGREGATE); $view = new StockView('SKU-1', 2, 15.0, true, []); - $this->assertSame(['qty' => 15.0], (new StockViewSerializer($this->config))->serialize($view)); + $this->assertSame(['qty' => 15.0], $this->serializer()->serialize($view)); } /** @@ -61,7 +86,63 @@ public function testPerSourcePayload(): void $this->assertSame( ['qty' => 15.0, 'sources' => ['slr_a' => 5.0, 'slr_b' => 10.0]], - (new StockViewSerializer($this->config))->serialize($view) + $this->serializer()->serialize($view) + ); + } + + /** + * The children fragment carries the aggregate status plus one row per child. + * + * @return void + */ + public function testChildrenPayload(): void + { + $view = new StockView('GRP-1', 2, 0.0, true, [], true, true, [ + new ChildView('CH-A', 'Item A', 30.0, true), + new ChildView('CH-B', 'Item B', 0.0, false), + ]); + + $this->assertSame( + [ + 'salable' => true, + 'children' => [ + ['sku' => 'CH-A', 'label' => 'Item A', 'salable' => true, 'qty' => 30.0], + ['sku' => 'CH-B', 'label' => 'Item B', 'salable' => false, 'qty' => 0.0], + ], + ], + $this->serializer()->serializeChildren($view) + ); + } + + /** + * Level display resolves the quantity to a coarse level server-side and never emits a number. + * + * @return void + */ + public function testLevelPayloadExposesNoQuantity(): void + { + $config = $this->createMock(Config::class); + $config->method('getDisplayType')->willReturn(Config::DISPLAY_TYPE_LEVEL); + $config->method('getScope')->willReturn(Config::SCOPE_PER_SOURCE); + $displayConfig = $this->createMock(DisplayConfig::class); + $this->resolveDisplayConfig->method('forSku')->willReturn($displayConfig); + $this->levelResolver->method('resolve')->willReturnMap([ + [15.0, $displayConfig, 'high'], + [5.0, $displayConfig, 'high'], + [0.0, $displayConfig, 'out'], + ]); + $view = new StockView('SKU-1', 2, 15.0, true, [ + new SourceView('slr_a', 5.0, 'Source A'), + new SourceView('slr_b', 0.0, 'Source B'), + ]); + + $payload = (new StockViewSerializer($config, $this->levelResolver, $this->resolveDisplayConfig)) + ->serialize($view); + + $this->assertSame( + ['level' => 'high', 'salable' => true, 'sources' => ['slr_a' => 'high', 'slr_b' => 'out']], + $payload ); + $this->assertArrayNotHasKey('qty', $payload); } } diff --git a/InventoryStockVisualizer/etc/adminhtml/system.xml b/InventoryStockVisualizer/etc/adminhtml/system.xml index 4aaa3bf739c..653dcf9b8b6 100644 --- a/InventoryStockVisualizer/etc/adminhtml/system.xml +++ b/InventoryStockVisualizer/etc/adminhtml/system.xml @@ -22,40 +22,30 @@ showInWebsite="1" showInStore="1" canRestore="1"> Magento\InventoryStockVisualizer\Model\Config\Source\DisplayType - Level shows a semaphore and is rendered server-side (no quantity exposed, no AJAX); - exact quantity shows the number and is fetched over a cacheable AJAX request. - - 1 - - - - - Magento\InventoryStockVisualizer\Model\Config\Source\Scope - Aggregate shows a single availability; per source breaks it down by source. + Level shows a coarse semaphore and never exposes an exact quantity; exact quantity + shows the number. Applies to every product type, on top of the per-type display below. 1 - + Magento\InventoryStockVisualizer\Model\Config\Source\Mode - On demand fetches the quantity on a button click; instant fetches it on page load. - Level display is always rendered on page load. + For availability fetched over AJAX (exact quantity, and interactive composite types): + on demand fetches on a button click; instant fetches on page load. Server-rendered + availability is always shown on page load. 1 - quantity - + validate-digits - Public-cache lifetime for the quantity fragment. 0 relies on tag purge only. + Public-cache lifetime for the AJAX availability fragments. 0 relies on tag purge only. 1 - quantity level - - - Magento\Config\Model\Config\Source\Yesno - Display the source name for each per-source row. - - 1 - per_source - - - - - Magento\Config\Model\Config\Source\Yesno - Omit out-of-stock sources from the per-source breakdown. - - 1 - per_source - - - - - Magento\InventoryStockVisualizer\Model\Config\Source\AsyncPurge - How the fragment cache is purged after stock changes. Auto offloads to the queue only when inventory indexing runs on schedule; the queue path needs a running consumer (inventory.stockvisualizer.purge). - - 1 - - + + + Applies to single-SKU availability: simple, virtual, downloadable, and the selected + configurable variant. Composite aggregate and per-component displays are unaffected. + + + cataloginventory/stock_visualizer/scope + Magento\InventoryStockVisualizer\Model\Config\Source\Scope + Aggregate shows a single availability; per source breaks it down by source. + + + + cataloginventory/stock_visualizer/show_source_labels + Magento\Config\Model\Config\Source\Yesno + Display the source name for each per-source row. + + per_source + + + + + cataloginventory/stock_visualizer/hide_empty_sources + Magento\Config\Model\Config\Source\Yesno + Omit out-of-stock sources from the per-source breakdown. + + per_source + + + + + + + + cataloginventory/stock_visualizer/composite_configurable_mode + Magento\InventoryStockVisualizer\Model\Config\Source\ConfigurableMode + Selected variant fetches the chosen variant's availability; per component lists + every variant; aggregate status shows a single in-stock/out-of-stock word. + + + + cataloginventory/stock_visualizer/composite_bundle_mode + Magento\InventoryStockVisualizer\Model\Config\Source\BundleMode + Sellable bundles computes how many of the current selection can be ordered; + per component lists each selection's stock. + + + + cataloginventory/stock_visualizer/composite_grouped_mode + Magento\InventoryStockVisualizer\Model\Config\Source\CompositeMode + Per component lists each associated product's stock; aggregate status shows a + single in-stock/out-of-stock word. + + + + cataloginventory/stock_visualizer/composite_grouped_sets_calculator + Magento\Config\Model\Config\Source\Yesno + Show how many complete sets can be assembled, alongside the per-component list + (grouped "Per component" display only). + + children + + + diff --git a/InventoryStockVisualizer/etc/config.xml b/InventoryStockVisualizer/etc/config.xml index 4412f0aba69..6d7d5fb74b3 100644 --- a/InventoryStockVisualizer/etc/config.xml +++ b/InventoryStockVisualizer/etc/config.xml @@ -20,7 +20,10 @@ 3 1 1 - auto + variant + max + children + 0 diff --git a/InventoryStockVisualizer/etc/di.xml b/InventoryStockVisualizer/etc/di.xml index a030b5d2978..2ad0a69a4d6 100644 --- a/InventoryStockVisualizer/etc/di.xml +++ b/InventoryStockVisualizer/etc/di.xml @@ -12,6 +12,8 @@ type="Magento\InventoryStockVisualizer\Model\Data\StockView"/> + diff --git a/InventoryStockVisualizer/etc/module.xml b/InventoryStockVisualizer/etc/module.xml index bb7ca235469..7ee8ba3d579 100644 --- a/InventoryStockVisualizer/etc/module.xml +++ b/InventoryStockVisualizer/etc/module.xml @@ -15,7 +15,12 @@ + + + + + diff --git a/InventoryStockVisualizer/view/frontend/layout/catalog_product_view.xml b/InventoryStockVisualizer/view/frontend/layout/catalog_product_view.xml index 82653589e2f..2e1b19986c8 100644 --- a/InventoryStockVisualizer/view/frontend/layout/catalog_product_view.xml +++ b/InventoryStockVisualizer/view/frontend/layout/catalog_product_view.xml @@ -16,5 +16,8 @@ + + + diff --git a/InventoryStockVisualizer/view/frontend/requirejs-config.js b/InventoryStockVisualizer/view/frontend/requirejs-config.js deleted file mode 100644 index bc069c7327f..00000000000 --- a/InventoryStockVisualizer/view/frontend/requirejs-config.js +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Copyright 2026 Jeanmarcos Juarez - * SPDX-License-Identifier: OSL-3.0 OR AFL-3.0 - */ - -var config = { - map: { - '*': { - stockVisualizer: 'Magento_InventoryStockVisualizer/js/stock-visualizer' - } - } -}; diff --git a/InventoryStockVisualizer/view/frontend/templates/product/view/stock-visualizer.phtml b/InventoryStockVisualizer/view/frontend/templates/product/view/stock-visualizer.phtml index 628bbb1d1f9..acce0e88787 100644 --- a/InventoryStockVisualizer/view/frontend/templates/product/view/stock-visualizer.phtml +++ b/InventoryStockVisualizer/view/frontend/templates/product/view/stock-visualizer.phtml @@ -10,37 +10,127 @@ $pin = ''; + ?> isEnabled()): ?> + isAggregateStatusOnly(); ?> isLevelMode(); ?> - isOnDemand(); ?> -
- data-mage-init='escapeHtmlAttr($block->getWidgetConfig()) ?>' - > -
+ isVariantMode(); ?> + isBundleMaxMode(); ?> + + + getComponentKind(); ?> + + + isOnDemand(); ?> + + isPerSource(); ?> + + + + + + + + + + + + + + + +
data-bind="scope: 'stockVisualizer'"> +
data-bind="css: { 'sv-flush': flush }"> escapeHtml($block->getPanelTitle()) ?> - - getAggregateLevel(); ?> - + + getAggregateLevel(); ?> + data-bind="css: statusClass"> - escapeHtml($block->levelLabel($aggregateLevel)) ?> + data-bind="text: statusWord">escapeHtml($block->levelLabel($level)) ?> + + style="display: none" + data-bind="visible: showCount"> + + style="display: none" + data-bind="text: count, visible: !loading()"> + + - getQuantityStatusLevel(); ?> - + getQuantityStatusLevel(); ?> + - escapeHtml($block->levelLabel($status)) ?> -
- isPerSource()): ?> - + - - diff --git a/InventoryStockVisualizer/view/frontend/web/css/source/_module.less b/InventoryStockVisualizer/view/frontend/web/css/source/_module.less index 2a360178acc..56447702890 100644 --- a/InventoryStockVisualizer/view/frontend/web/css/source/_module.less +++ b/InventoryStockVisualizer/view/frontend/web/css/source/_module.less @@ -102,6 +102,10 @@ content: '\00B7\00A0'; color: @sv__muted; } + + &:empty { + display: none; + } } .sva-body { @@ -110,7 +114,37 @@ padding: @indent__xs @indent__s @indent__s; } + .sva-head.sv-flush { + border-bottom: 0; + } + + .sv-hint { + padding: @indent__s; + color: @sv__muted; + } + + .sv-children { + padding-bottom: 0; + + .sv-src { + padding: @indent__s 0; + border-top-color: @sv__border; + } + } + + &.sv-mode-quantity .sv-children .sv-src-top { + margin-bottom: 0; + } + + .sv-sets { + margin: 0 @indent__s; + padding: @indent__s 0; + border-top: 1px solid @sv__border; + color: @sv__muted; + } + .sv-src { + margin: 0; padding: @indent__xs 0; border-top: 1px solid @sv__head-bg; max-height: 12em; @@ -192,7 +226,7 @@ transition: width .4s ease; } - &.sv-loading .sv-meter { + .sv-loading .sv-meter { background-image: linear-gradient( 90deg, @sv__skeleton-bg 25%, @@ -203,7 +237,7 @@ animation: sv-shimmer 1.2s ease infinite; } - &.sv-loading .sv-meter > i { + .sv-loading .sv-meter > i { opacity: 0; } @@ -291,7 +325,7 @@ transition: none; } - &.sv-loading .sv-meter { + .sv-loading .sv-meter { animation: none; } } diff --git a/InventoryStockVisualizer/view/frontend/web/js/stock-visualizer.js b/InventoryStockVisualizer/view/frontend/web/js/stock-visualizer.js deleted file mode 100644 index 377a50f970b..00000000000 --- a/InventoryStockVisualizer/view/frontend/web/js/stock-visualizer.js +++ /dev/null @@ -1,188 +0,0 @@ -/** - * Copyright 2026 Jeanmarcos Juarez - * SPDX-License-Identifier: OSL-3.0 OR AFL-3.0 - */ - -define([ - 'jquery', - 'mage/translate', - 'jquery-ui-modules/widget' -], function ($, $t) { - 'use strict'; - - $.widget('mage.stockVisualizer', { - options: { - mode: 'on_demand', - scope: 'aggregate', - sku: '', - hideEmptySources: true, - ajaxUrl: '' - }, - - /** - * Boot the quantity widget. In instant mode the values load on page load, so a - * skeleton bridges the fetch. In on-demand mode the initial state (status word plus - * the call-to-action) is already rendered server-side with the volatile numbers - * hidden, so the widget only wires the button and never flashes a skeleton before - * it mounts. - * - * @private - */ - _create: function () { - this.$deferred = this.element.find('[data-sv-agg], .sva-body'); - - if (this.options.mode === 'instant') { - this._fetch(); - } else { - this._bindCta(); - } - }, - - /** - * Wire the server-rendered call-to-action. The button, the status word and the - * hidden volatile content all come from the cached HTML, so there is nothing to - * create or hide here; the fetch runs on click and the values are revealed already - * filled. - * - * @private - */ - _bindCta: function () { - var self = this, - button = this.element.find('[data-sv-cta]'); - - this.$cta = button; - button.on('click', function () { - button.prop('disabled', true).addClass('sv-cta-loading').text($t('Checking availability…')); - self._fetch(); - }); - }, - - /** - * Fetch the minimal quantity payload. `cache:true` avoids the global - * ajaxSetup({cache:false}) buster so the shared cache can serve repeats. - * Only the SKU is sent; the server resolves stock and product id from context. - * - * @private - */ - _fetch: function () { - var self = this; - - this.element.addClass('sv-loading'); - $.ajax({ - url: this.options.ajaxUrl, - type: 'GET', - dataType: 'json', - cache: true, - data: { - sku: this.options.sku - } - }).done(function (response) { - self._fill(response && response.data ? response.data : null); - }).fail(function () { - self._fill(null); - }); - }, - - /** - * Fill the volatile numbers onto the server-rendered scaffold and reconcile the - * cached status pill with the live salable quantity. The in-stock/out-of-stock - * word is rendered by PHP and cached; on a failed fetch it is left untouched. - * - * @param {Object|null} data - * @private - */ - _fill: function (data) { - var status = this.element.find('[data-sv-status]'); - - this.element.removeClass('sv-loading'); - - if (!data) { - if (this.$cta) { - this.$cta.prop('disabled', false).removeClass('sv-cta-loading').text($t('Check availability')); - } else { - status.find('[data-sv-agg]').empty(); - this.element.find('[data-sv-value]').empty(); - } - - return; - } - - var salable = data.qty > 0; - - status.removeClass('level-high level-out') - .addClass(salable ? 'level-high' : 'level-out'); - status.find('.sv-word').text(salable ? $t('In stock') : $t('Out of stock')); - status.find('[data-sv-agg]').text(this._formatQty(data.qty)); - - if (this.options.scope === 'per_source') { - this._fillSources(data.sources || {}); - } - - this._revealDeferred(); - }, - - /** - * Reveal the now-filled numbers and drop the call-to-action (on-demand only). - * In instant mode there is no call-to-action and the values are already visible. - * - * @private - */ - _revealDeferred: function () { - if (this.$cta) { - this.$deferred.show(); - this.$cta.remove(); - this.$cta = null; - } - }, - - /** - * Fill per-source value cells and size each meter as a share of the total. - * Empty sources are collapsed; when the whole breakdown would be empty, a single - * note is shown so the panel is never an empty frame under the aggregate. - * - * @param {Object} sources - * @private - */ - _fillSources: function (sources) { - var self = this, - total = 0, - visible = 0; - - $.each(sources, function (code, qty) { - total += qty > 0 ? qty : 0; - }); - - this.element.find('[data-sv-source]').each(function () { - var row = $(this), - code = row.attr('data-sv-source'), - qty = typeof sources[code] !== 'undefined' ? sources[code] : 0, - width = total > 0 && qty > 0 ? Math.round(qty / total * 100) : 0; - - row.find('[data-sv-value]').text(self._formatQty(qty)); - row.find('[data-sv-meter]').css('width', width + '%'); - - if (self.options.hideEmptySources && qty <= 0) { - row.addClass('sv-collapsed'); - } else { - row.removeClass('sv-collapsed'); - visible++; - } - }); - - this.element.find('[data-sv-empty]').toggleClass('sv-collapsed', visible > 0); - }, - - /** - * Format a quantity for display. - * - * @param {Number} qty - * @return {String} - * @private - */ - _formatQty: function (qty) { - return (Math.round(qty * 100) / 100).toString(); - } - }); - - return $.mage.stockVisualizer; -}); diff --git a/InventoryStockVisualizer/view/frontend/web/js/view/availability.js b/InventoryStockVisualizer/view/frontend/web/js/view/availability.js new file mode 100644 index 00000000000..d01a4c4293e --- /dev/null +++ b/InventoryStockVisualizer/view/frontend/web/js/view/availability.js @@ -0,0 +1,830 @@ +/** + * Copyright 2026 Jeanmarcos Juarez + * SPDX-License-Identifier: OSL-3.0 OR AFL-3.0 + */ + +define([ + 'uiElement', + 'jquery', + 'knockoutjs/knockout', + 'mage/translate' +], function (Element, $, ko, $t) { + 'use strict'; + + var RECOMPUTE_DELAY = 60; + + var LEVEL_FILL = { high: 100, medium: 60, low: 30, out: 0 }; + + return Element.extend({ + defaults: { + kind: 'quantity', + mode: 'instant', + scope: 'aggregate', + perSource: false, + hideEmptySources: true, + showSourceLabels: false, + ajaxUrl: '', + sku: '', + configVersion: '', + sourceScaffold: [], + childScaffold: [], + statusLevel: 'out', + statusWord: '', + loading: false, + count: '', + showPrompt: false, + showCta: false, + showEmptyNote: false, + sourcesVisible: false, + childrenVisible: false, + setsText: '', + sourceRows: [], + variantRows: [], + childRows: [] + }, + + /** + * @inheritdoc + */ + initialize: function () { + this._super(); + this._boot(); + + return this; + }, + + /** + * @inheritdoc + */ + initObservable: function () { + this._super().observe([ + 'statusLevel', 'statusWord', 'loading', 'count', + 'showPrompt', 'showCta', 'showEmptyNote', 'sourcesVisible', + 'childrenVisible', 'setsText', + 'sourceRows', 'variantRows', 'childRows' + ]); + + this.statusClass = ko.pureComputed(function () { + return 'level-' + this.statusLevel(); + }, this); + + this.showCount = ko.pureComputed(function () { + return this.loading() || this.count() !== ''; + }, this); + + this.flush = ko.pureComputed(function () { + switch (this.kind) { + case 'variant': + return !this.showPrompt() && this.variantRows().length === 0; + case 'bundleMax': + return !this.showPrompt(); + case 'children': + return !this.childrenVisible() || this.childRows().length === 0; + default: + return this.perSource ? !this.sourcesVisible() : true; + } + }, this); + + return this; + }, + + /** + * Route the boot sequence to the strategy the panel was configured with. + * + * @private + */ + _boot: function () { + switch (this.kind) { + case 'variant': + this._bootVariant(); + break; + case 'bundleMax': + this._bootBundle(); + break; + case 'children': + this._bootChildren(); + break; + default: + this._bootQuantity(); + } + }, + + /** + * Call-to-action handler (on-demand modes). Shared across strategies: the button is + * only rendered when a fetch is deferred, so activating it always starts one. + */ + activate: function () { + this.showCta(false); + + switch (this.kind) { + case 'variant': + this.loading(true); + this._fetchVariant(this.pendingId); + break; + case 'bundleMax': + this.loading(true); + this._fetchBundle(this.pendingSelections); + break; + case 'children': + this.childrenVisible(true); + this._fetchChildren(); + break; + default: + this.sourcesVisible(true); + this.loading(true); + this._fetchQuantity(); + } + }, + + /** + * Quantity strategy (simple/virtual/downloadable): a single SKU with an exact salable + * quantity and an optional per-source breakdown. In instant mode it fetches on mount; + * in on-demand mode it waits for the call-to-action. + * + * @private + */ + _bootQuantity: function () { + if (this.perSource) { + this.sourceRows(this._scaffoldRows()); + } + if (this.mode === 'instant') { + this.loading(true); + this._fetchQuantity(); + } + }, + + /** + * @private + */ + _fetchQuantity: function () { + var self = this; + + $.ajax({ + url: this.ajaxUrl, + type: 'GET', + dataType: 'json', + cache: true, + data: { sku: this.sku, _cv: this.configVersion } + }).done(function (response) { + self._fillQuantity(response && response.data ? response.data : null); + }).fail(function () { + self._fillQuantity(null); + }); + }, + + /** + * Reconcile the cached status pill with the live salable quantity and reveal the + * number. On a failed fetch the server-rendered status is left untouched. + * + * @param {Object|null} data + * @private + */ + _fillQuantity: function (data) { + this.loading(false); + + if (!data) { + return; + } + + var salable = data.qty > 0; + + this.statusLevel(salable ? 'high' : 'out'); + this.statusWord(salable ? $t('In stock') : $t('Out of stock')); + this.count(salable ? this._formatQty(data.qty) : ''); + + if (this.perSource) { + this._fillSources(this.sourceRows(), data.sources || {}); + } + }, + + /** + * Variant strategy (configurable): show the exact availability of the child the + * customer selects. Nothing is fetched until a full option combination resolves. + * + * @private + */ + _bootVariant: function () { + var self = this; + + this._form().on('change', '.super-attribute-select, .swatch-input', function () { + self._onVariantChange(); + }); + }, + + /** + * React to a variant selection. Instant mode fetches immediately; on-demand reveals the + * call-to-action so the fetch is deferred to an explicit click, and falls back to the + * prompt while the selection is incomplete. + * + * @private + */ + _onVariantChange: function () { + var productId = this._resolveSelectedProductId(); + + if (!productId) { + this._promptInteractive(); + + return; + } + this.showPrompt(false); + + if (this.mode === 'instant') { + this.loading(true); + this._fetchVariant(productId); + + return; + } + this.pendingId = productId; + this._deferInteractive(); + }, + + /** + * @param {Number} productId + * @private + */ + _fetchVariant: function (productId) { + var self = this; + + $.ajax({ + url: this.ajaxUrl, + type: 'GET', + dataType: 'json', + cache: true, + data: { product_id: productId, _cv: this.configVersion } + }).done(function (response) { + self._fillVariant(response && response.data ? response.data : null); + }).fail(function () { + self._promptInteractive(); + }); + }, + + /** + * @param {Object|null} data + * @private + */ + _fillVariant: function (data) { + this.loading(false); + + if (!data) { + this._promptInteractive(); + + return; + } + this.showPrompt(false); + + if (this.levelDisplay) { + var level = data.level || 'out'; + + this.statusLevel(level); + this.statusWord(this._levelLabel(level)); + this.count(''); + this.variantRows(this._buildLevelRows(data.sources || {})); + + return; + } + + if (typeof data.qty === 'undefined') { + this._promptInteractive(); + + return; + } + + var salable = data.qty > 0; + + this.statusLevel(salable ? 'high' : 'out'); + this.statusWord(salable ? $t('In stock') : $t('Out of stock')); + this.count(salable ? this._formatQty(data.qty) : ''); + this.variantRows(this._buildVariantRows(salable ? (data.sources || {}) : {})); + }, + + /** + * The child product id for the fully selected option combination, or null. Both native + * widgets already resolve it: the dropdown configurable exposes the resolved child in + * `simpleProduct`, and the swatch renderer's `getProductId()` returns an id only when the + * selection narrows to a single product (its own price-calculation intersection). The + * panel never reconstructs the selection from the DOM. + * + * @return {Number|null} + * @private + */ + _resolveSelectedProductId: function () { + var $form = this._form(), + configurable = $form.data('mageConfigurable'); + + if (configurable) { + return this._toId(configurable.simpleProduct); + } + + var swatch = this._swatchRenderer(); + + if (swatch && typeof swatch.getProductId === 'function') { + return this._toId(swatch.getProductId()); + } + + return null; + }, + + /** + * The swatch renderer widget instance, if the configurable renders as swatches. The + * jQuery UI bridge stores it under the widget full name ('mage-SwatchRenderer'); the + * camel-cased key is checked too for other versions. + * + * @return {Object|null} + * @private + */ + _swatchRenderer: function () { + var $el = $('[data-role=swatch-options]'); + + return $el.data('mageSwatchRenderer') || $el.data('mage-SwatchRenderer') || null; + }, + + /** + * Coerce a native id value to a positive integer, or null. + * + * @param {*} value + * @return {Number|null} + * @private + */ + _toId: function (value) { + var id = parseInt(value, 10); + + return id > 0 ? id : null; + }, + + /** + * Bundle strategy (max sellable): compute how many of the current selection can be + * ordered. Reads the live selection from the native priceBundle option config and + * recomputes as the customer changes options or quantities. + * + * @private + */ + _bootBundle: function () { + var self = this; + + this._form().on('updateProductSummary', function (event, data) { + self.optionConfig = data && data.config ? data.config : self.optionConfig; + self._onBundleChange(); + }); + + this._onBundleChange(); + }, + + /** + * React to a bundle selection change. Instant mode recomputes immediately; on-demand + * reveals the call-to-action so the compute is deferred to an explicit click. + * + * @private + */ + _onBundleChange: function () { + var self = this; + + clearTimeout(this.recomputeTimer); + this.recomputeTimer = setTimeout(function () { + var selections = self._collectSelections(); + + if ($.isEmptyObject(selections)) { + self._promptInteractive(); + + return; + } + self.showPrompt(false); + + if (self.mode === 'instant') { + self.loading(true); + self._fetchBundle(selections); + + return; + } + self.pendingSelections = selections; + self._deferInteractive(); + }, RECOMPUTE_DELAY); + }, + + /** + * @param {Object} selections + * @private + */ + _fetchBundle: function (selections) { + var self = this; + + $.ajax({ + url: this.ajaxUrl, + type: 'GET', + dataType: 'json', + cache: true, + data: { + sku: this.sku, + selections: JSON.stringify(selections), + _cv: this.configVersion + } + }).done(function (response) { + self._fillBundle(response && response.data ? response.data : null); + }).fail(function () { + self._promptInteractive(); + }); + }, + + /** + * @param {Object|null} data + * @private + */ + _fillBundle: function (data) { + this.loading(false); + + if (!data) { + this._promptInteractive(); + + return; + } + this.showPrompt(false); + + if (this.levelDisplay) { + var level = data.level || 'out'; + + this.statusLevel(level); + this.statusWord(this._levelLabel(level)); + this.count(''); + + return; + } + + if (data.max === null || typeof data.max === 'undefined') { + this._promptInteractive(); + + return; + } + + var salable = data.max > 0; + + this.statusLevel(salable ? 'high' : 'out'); + this.statusWord(salable ? $t('In stock') : $t('Out of stock')); + this.count(salable ? $t('up to %1').replace('%1', data.max) : ''); + }, + + /** + * The live bundle {selectionId: qty} map from the native priceBundle option config, + * including customer-edited quantities and the required options Magento auto-selects. + * + * @return {Object} + * @private + */ + _collectSelections: function () { + var config = this._optionConfig(), + selections = {}; + + if (!config || !config.selected) { + return selections; + } + + $.each(config.selected, function (optionId, selectionIds) { + if (!selectionIds || !selectionIds.length) { + return; + } + var option = config.options && config.options[optionId] ? config.options[optionId] : null; + + $.each(selectionIds, function (index, selectionId) { + if (selectionId === null || selectionId === undefined || selectionId === '') { + return; + } + var selection = option && option.selections ? option.selections[selectionId] : null, + qty = selection ? parseFloat(selection.qty) : 1; + + selections[selectionId] = qty > 0 ? qty : 1; + }); + }); + + return selections; + }, + + /** + * @return {Object|null} + * @private + */ + _optionConfig: function () { + if (this.optionConfig) { + return this.optionConfig; + } + var priceBundle = this._form().data('magePriceBundle'); + + return priceBundle && priceBundle.options ? priceBundle.options.optionConfig : null; + }, + + /** + * Return the interactive panel to its pre-selection prompt. + * + * @private + */ + _promptInteractive: function () { + this.loading(false); + this.count(''); + this.variantRows([]); + this.showCta(false); + this.showPrompt(true); + }, + + /** + * Defer the fetch behind the call-to-action (on-demand): a selection exists but is not + * fetched until the customer clicks, so any stale result is cleared while the button shows. + * + * @private + */ + _deferInteractive: function () { + this.loading(false); + this.count(''); + this.variantRows([]); + this.showPrompt(false); + this.showCta(true); + }, + + /** + * Human-readable label for a level. + * + * @param {String} level + * @return {String} + * @private + */ + _levelLabel: function (level) { + switch (level) { + case 'high': + return $t('In stock'); + case 'medium': + return $t('Limited availability'); + case 'low': + return $t('Low stock'); + default: + return $t('Out of stock'); + } + }, + + /** + * Availability-bar fill percentage for a level. + * + * @param {String} level + * @return {Number} + * @private + */ + _levelFill: function (level) { + return LEVEL_FILL[level] || 0; + }, + + /** + * Build the selected variant's per-source level rows (level display), honouring hide-empty. + * + * @param {Object} sources code => level + * @return {Array} + * @private + */ + _buildLevelRows: function (sources) { + var self = this, + rows = []; + + this.sourceScaffold.forEach(function (source) { + var level = sources[source.code] || 'out'; + + if (self.hideEmptySources && level === 'out') { + return; + } + rows.push({ + code: source.code, + name: source.name, + qtyText: self._levelLabel(level), + level: level, + fill: self._levelFill(level) + }); + }); + + return rows; + }, + + /** + * Children strategy (composite children/status): fetch the per-child breakdown as a + * cacheable fragment so the volatile child quantities never sit in the product page. + * The child labels are known from the structure scaffold; only their stock is fetched. + * + * @private + */ + _bootChildren: function () { + this.childRows(this._scaffoldChildRows()); + + if (this.mode === 'instant') { + this.childrenVisible(true); + this._fetchChildren(); + } + }, + + /** + * @private + */ + _fetchChildren: function () { + var self = this; + + $.ajax({ + url: this.ajaxUrl, + type: 'GET', + dataType: 'json', + cache: true, + data: { sku: this.sku, _cv: this.configVersion } + }).done(function (response) { + self._fillChildren(response && response.data ? response.data : null); + }).fail(function () { + self._fillChildren(null); + }); + }, + + /** + * Fill the child rows and the grouped-sets line, and reconcile the aggregate status + * pill from the fetched salability (the server-rendered pill is coarse and may be stale). + * + * @param {Object|null} data + * @private + */ + _fillChildren: function (data) { + if (!data) { + return; + } + + this.statusLevel(data.salable ? 'high' : 'out'); + this.statusWord(data.salable ? $t('In stock') : $t('Out of stock')); + + var self = this, + bySku = {}; + + $.each(data.children || [], function (index, child) { + bySku[child.sku] = child; + }); + + this.childRows().forEach(function (row) { + var child = bySku[row.sku]; + + row.loading(false); + if (!child) { + return; + } + row.salable(child.salable); + if (self.levelDisplay) { + row.value(self._levelLabel(child.level)); + row.level(child.level); + row.fill(self._levelFill(child.level)); + } else { + row.value(child.salable ? self._formatQty(child.qty) : $t('Out of stock')); + } + }); + + this.setsText(this._setsText(data.sets)); + }, + + /** + * Human-readable grouped-sets line for the fetched maximum, or '' when not applicable. + * + * @param {Number|null|undefined} sets + * @return {String} + * @private + */ + _setsText: function (sets) { + if (sets === null || typeof sets === 'undefined') { + return ''; + } + if (sets <= 0) { + return $t('Not enough stock to assemble a complete set.'); + } + if (this.levelDisplay) { + return $t('Complete sets available.'); + } + + return $t('You can assemble up to %1 complete set(s).').replace('%1', sets); + }, + + /** + * Build the child scaffold rows (labels known from structure, stock arrives over AJAX). + * + * @return {Array} + * @private + */ + _scaffoldChildRows: function () { + return this.childScaffold.map(function (child) { + return { + sku: child.sku, + label: child.label, + value: ko.observable(''), + salable: ko.observable(true), + level: ko.observable('out'), + fill: ko.observable(0), + loading: ko.observable(true) + }; + }); + }, + + /** + * Build the per-source scaffold rows (labels only, numbers arrive over AJAX). + * + * @return {Array} + * @private + */ + _scaffoldRows: function () { + return this.sourceScaffold.map(function (source) { + return { + code: source.code, + name: source.name, + qty: ko.observable(''), + fill: ko.observable(0), + collapsed: ko.observable(false), + loading: ko.observable(true) + }; + }); + }, + + /** + * Fill per-source value cells in place and size each meter as a share of the total. + * + * @param {Array} rows + * @param {Object} sources code => quantity + * @private + */ + _fillSources: function (rows, sources) { + var self = this, + total = 0, + visible = 0; + + $.each(sources, function (code, qty) { + total += qty > 0 ? qty : 0; + }); + + rows.forEach(function (row) { + var qty = typeof sources[row.code] !== 'undefined' ? sources[row.code] : 0, + width = total > 0 && qty > 0 ? Math.round(qty / total * 100) : 0; + + row.qty(self._formatQty(qty)); + row.fill(width); + row.loading(false); + + if (self.hideEmptySources && qty <= 0) { + row.collapsed(true); + } else { + row.collapsed(false); + visible++; + } + }); + + this.showEmptyNote(visible === 0); + }, + + /** + * Build the selected variant's per-source rows, honouring hide-empty. + * + * @param {Object} sources code => quantity + * @return {Array} + * @private + */ + _buildVariantRows: function (sources) { + var self = this, + rows = [], + total = 0; + + $.each(sources, function (code, qty) { + total += qty > 0 ? qty : 0; + }); + + this.sourceScaffold.forEach(function (source) { + var qty = sources[source.code] || 0; + + if (self.hideEmptySources && qty <= 0) { + return; + } + rows.push({ + code: source.code, + name: source.name, + qtyText: self._formatQty(qty), + level: '', + fill: total > 0 && qty > 0 ? Math.round(qty / total * 100) : 0 + }); + }); + + return rows; + }, + + /** + * The add-to-cart form that carries the type-specific client widgets. + * + * @return {jQuery} + * @private + */ + _form: function () { + if (!this.$form || !this.$form.length) { + this.$form = $('#product_addtocart_form'); + } + + return this.$form; + }, + + /** + * Format a quantity for display. + * + * @param {Number} qty + * @return {String} + * @private + */ + _formatQty: function (qty) { + return (Math.round(qty * 100) / 100).toString(); + } + }); +});