Attribut-übergreifende Konsistenz: wenn Kategorie- und Produkt-Bonus kollidieren
Attribut-übergreifende Konsistenz: wenn Kategorie- und Produkt-Bonus kollidieren
~8 Min. Lesezeit Zuletzt aktualisiert am 9. August 2026
PointsCalculator::calculatePoints() (Kapitel 5) nimmt $categoryBonus als einzelnen float-Wert entgegen - aber ein Produkt kann in Magento gleichzeitig mehreren Kategorien zugeordnet sein, jede mit einem potenziell eigenen loyalty_bonus_category-Wert (Kapitel 20). Welcher Wert soll in die Berechnung einfließen, wenn ein Produkt gleichzeitig in "Sale" (Bonus 0,5) und "Neuheiten" (Bonus 0,1) liegt? Dieses Kapitel schließt diese Lücke - bewusst als eigener Schritt, getrennt von der reinen Berechnung aus Kapitel 5.
Warum das kein Randfall ist
In einem echten Produktkatalog ist Mehrfachzuordnung die Regel, nicht die Ausnahme - ein T-Shirt liegt oft gleichzeitig in einer Navigationskategorie ("Herren > T-Shirts") und einer Marketing-Kategorie ("Sale", "Neu im Sortiment"). Ohne eine klare Regel würde calculatePoints() stillschweigend nur den zufällig zuerst oder zuletzt geladenen Wert verwenden - ein Bug, der sich erst im Live-Betrieb als "inkonsistente Punktegutschrift" zeigt, nie in einem Unit-Test mit nur einer einzigen Test-Kategorie (Block 11).
Ein dedizierter Resolver statt Inline-Logik
PointsCalculator bleibt bewusst unwissend über "Kategorie" als Konzept - Kapitel 5 übergibt ihm nur eine bereits fertige Zahl. Genau das hält ihn weiterhin so leicht unit-testbar (Block 11). Die Auflösung "welche Kategorie gewinnt" gehört fachlich zusammen und verdient eine eigene, ebenso pur testbare Klasse statt verstreuter if-Logik in einem späteren Observer: Mironsoft\Loyalty\Model\Service\CategoryBonusResolver.
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Model\Service;
use Magento\Catalog\Api\CategoryRepositoryInterface;
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Framework\Exception\NoSuchEntityException;
use Psr\Log\LoggerInterface;
/**
* Resolves a single, effective category bonus rate for a product that may be
* assigned to several categories, each potentially carrying a different
* loyalty_bonus_category value (chapter 20). The business rule applied here is
* deliberately the most customer-friendly one: the highest bonus among all
* active categories the product belongs to wins.
*/
class CategoryBonusResolver
{
/**
* @param CategoryRepositoryInterface $categoryRepository Loads full category entities, including the EAV attribute.
* @param LoggerInterface $logger Logs categories that can no longer be loaded instead of failing the whole calculation.
*/
public function __construct(
private readonly CategoryRepositoryInterface $categoryRepository,
private readonly LoggerInterface $logger
) {
}
/**
* Returns the highest loyalty_bonus_category value among all active categories
* a product is assigned to, or 0.0 if none apply.
*
* @param ProductInterface $product Product whose assigned categories are inspected.
* @return float
*/
public function resolveForProduct(ProductInterface $product): float
{
$highestBonus = 0.0;
/** @var array<int, string|int> $categoryIds */
$categoryIds = $product->getCategoryIds();
foreach ($categoryIds as $categoryId) {
try {
$category = $this->categoryRepository->get((int) $categoryId);
} catch (NoSuchEntityException $exception) {
$this->logger->warning(sprintf(
'Loyalty: category %d referenced by product %d no longer exists.',
(int) $categoryId,
(int) $product->getId()
));
continue;
}
if (!$category->getIsActive()) {
continue;
}
$bonus = (float) $category->getData('loyalty_bonus_category');
$highestBonus = max($highestBonus, $bonus);
}
return $highestBonus;
}
}Die Geschäftsregel explizit dokumentieren
"Der höchste Bonus gewinnt" ist keine mathematische Notwendigkeit, sondern eine bewusste Entscheidung - ebenso denkbar wären min() (konservativste Variante, verhindert "Kategorie-Farming" durch übermäßige Mehrfachzuordnung) oder eine Summe aller Boni (großzügigste Variante, aber schwer vorhersehbar für den Shop-Betreiber). Es gibt hier keine einzige "richtige" Antwort - wichtig ist allein, dass die Entscheidung an einer einzigen, benannten Stelle steht und nicht stillschweigend vom Iterationsverhalten eines Arrays abhängt.
Tipp: $product->getCategoryId() (Singular) wirkt wie ein naheliegender Ersatz für die Schleife - ist es aber nicht: Dieser Getter liefert lediglich die Kategorie-ID aus dem aktuellen Request-Kontext (z. B. welche Kategorieseite gerade angezeigt wird), keinen dauerhaft am Produkt gespeicherten "primären" Kategoriewert. Außerhalb einer Kategorieseite - etwa beim Bestellabschluss im Checkout, wo dieser Resolver tatsächlich gebraucht wird - ist der Wert schlicht leer. Ein verbreiteter Irrtum, der getCategoryIds() (Plural, wie hier verwendet) zur einzig zuverlässigen Quelle macht.
Achtung: $this->categoryRepository->get() in der Schleife lädt jede Kategorie einzeln - bei einer Bestellung mit vielen Positionen und überlappenden Kategorien ein klassisches N+1-Problem. Für dieses Kapitel bewusst zugunsten der Klarheit in Kauf genommen; der Cache-Typ aus Kapitel 8 wäre der naheliegende nächste Schritt, um wiederholte Ladevorgänge über mehrere Bestellungen hinweg zu vermeiden.
Anchor-Kategorien sind nicht automatisch inbegriffen
getCategoryIds() liefert ausschließlich die Kategorien, denen ein Produkt direkt zugewiesen ist - nicht automatisch deren übergeordnete (Anchor-)Kategorien. Trägt eine übergeordnete Kategorie "Sale" einen loyalty_bonus_category-Wert, ein Produkt ist aber nur einer Unterkategorie zugewiesen, bleibt dieser Bonus in der obigen Implementierung unberücksichtigt - dieselbe Anchor-Kategorie-Verwirrung, die im Katalog-Kontext (Produktlisten, Navigation) regelmäßig für Überraschungen sorgt. Wer das braucht, müsste zusätzlich $category->getParentIds() auflösen und dieselbe Prüfung für jede Vorfahren-Kategorie wiederholen - bewusst außerhalb des Umfangs dieses Kapitels.
Einsatz im aufrufenden Code
So sehen CategoryBonusResolver und PointsCalculator gemeinsam im aufrufenden Code aus, den der Observer aus Kapitel 30 später nutzt:
$categoryBonus = $this->categoryBonusResolver->resolveForProduct($product);
$productMultiplier = (float) $product->getData('loyalty_points_multiplier');
$points = $this->pointsCalculator->calculatePoints(
$lineTotal,
$pointsPerEuro,
$productMultiplier,
$categoryBonus
);Damit ist Block 3 inhaltlich abgeschlossen: fünf Attribute, drei Speichertechniken, ein Backend Model für Selbstkonsistenz und ein Resolver-Service für Konsistenz über Attributgrenzen hinweg. Kapitel 28 fasst das als Checkliste zusammen, bevor Block 4 in Kapitel 29 das Event-System einführt, das CategoryBonusResolver und LoyaltyTierBackend erstmals tatsächlich aufruft.