Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/scripts/dist-qa-local-docker.sh
Original file line number Diff line number Diff line change
@@ -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=<image with ext-xml + composer>." >&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 "$@"' _ "$@"
102 changes: 102 additions & 0 deletions .github/scripts/dist-qa-local.sh
Original file line number Diff line number Diff line change
@@ -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 <base-ref>" >&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"
9 changes: 8 additions & 1 deletion .github/workflows/dist-qa.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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/
10 changes: 7 additions & 3 deletions DISTRIBUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
45 changes: 45 additions & 0 deletions InventoryStockVisualizer/Api/Data/ChildViewInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php
/**
* Copyright 2026 Jeanmarcos Juarez
* SPDX-License-Identifier: OSL-3.0 OR AFL-3.0
*/
declare(strict_types=1);

namespace Magento\InventoryStockVisualizer\Api\Data;

/**
* Availability of one child of a composite product (a configurable variant, a grouped
* associated product or a bundle selection), shown as a per-component breakdown.
*
* @api
*/
interface ChildViewInterface
{
/**
* Child SKU.
*
* @return string
*/
public function getSku(): string;

/**
* Display label (child product name, falling back to the SKU).
*
* @return string
*/
public function getLabel(): string;

/**
* Salable quantity of the child on the stock.
*
* @return float
*/
public function getQty(): float;

/**
* Whether the child is salable.
*
* @return bool
*/
public function isSalable(): bool;
}
31 changes: 31 additions & 0 deletions InventoryStockVisualizer/Api/Data/StockViewInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,35 @@ public function setSources(array $sources): void;
* @return bool
*/
public function isSourceReservationsEnabled(): bool;

/**
* Authoritative salability of the SKU on the stock.
*
* For stockable (is_qty) types this mirrors salableQty > 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;
}
8 changes: 7 additions & 1 deletion InventoryStockVisualizer/Api/GetStockViewInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
114 changes: 114 additions & 0 deletions InventoryStockVisualizer/Block/Product/AvailabilityData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<?php
/**
* Copyright 2026 Jeanmarcos Juarez
* SPDX-License-Identifier: OSL-3.0 OR AFL-3.0
*/
declare(strict_types=1);

namespace Magento\InventoryStockVisualizer\Block\Product;

use Magento\Catalog\Api\Data\ProductInterface;
use Magento\InventoryCatalog\Model\GetStockIdForCurrentWebsite;
use Magento\InventoryStockVisualizer\Api\Data\StockViewInterface;
use Magento\InventoryStockVisualizer\Api\GetStockViewInterface;
use Magento\InventoryStockVisualizer\Model\DisplayConfig;
use Magento\InventoryStockVisualizer\Model\GetEnabledSources;
use Magento\InventoryStockVisualizer\Model\LevelResolver;
use Magento\InventoryStockVisualizer\Model\ResolveDisplayConfig;

/**
* Availability collaborators for the storefront panel block.
*
* Groups the stock, display-config and level services the block delegates to, so the block
* itself carries only the presentation glue. Stateless — the block owns per-render memoization.
*/
class AvailabilityData
{
/**
* @param GetStockViewInterface $getStockView
* @param GetStockIdForCurrentWebsite $getStockIdForCurrentWebsite
* @param GetEnabledSources $getEnabledSources
* @param LevelResolver $levelResolver
* @param ResolveDisplayConfig $resolveDisplayConfig
*/
public function __construct(
private readonly GetStockViewInterface $getStockView,
private readonly GetStockIdForCurrentWebsite $getStockIdForCurrentWebsite,
private readonly GetEnabledSources $getEnabledSources,
private readonly LevelResolver $levelResolver,
private readonly ResolveDisplayConfig $resolveDisplayConfig
) {
}

/**
* Stock id for the current website, or null when it cannot be resolved.
*
* @return int|null
*/
public function resolveStockId(): ?int
{
try {
return (int) $this->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<int, array{code: string, name: string}>
*/
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);
}
}
Loading
Loading