Magento 2 Experten — Hyvä Theme, Tailwind CSS & SEO aus einer Hand ›

Eine eigene Preference für die Punkte-Berechnung schreiben

Eine eigene Preference für die Punkte-Berechnung schreiben

~9 Min. Lesezeit Zuletzt aktualisiert am 9. August 2026

Das Business-Szenario aus Kapitel 40: dieser Shop nutzt bereits eine lizenzierte Drittanbieter-Erweiterung, die die B2B-Sonderkonditionen aus Kapitel 22 mit einem externen ERP-System synchronisiert - fiktiver Hersteller-Namespace ErpSync\CorporateRewards. Neue Anforderung: der von der ERP-Anbindung gelieferte Firmen-Multiplikator soll nie unter den Wert fallen, den loyalty_tier_override (Kapitel 22) für die Firma vorsieht - aber die Erweiterung selbst kennt dieses Attribut naturgemäß nicht.

Die Ausgangslage: eine fremde final-Methode

vendor/erp-sync/corporate-rewards/Api/MultiplierResolverInterface.php
<?php

declare(strict_types=1);

namespace ErpSync\CorporateRewards\Api;

/**
 * Vendor-provided contract for resolving a company's ERP-driven pricing
 * multiplier. Part of a licensed third-party extension, not this project's code.
 */
interface MultiplierResolverInterface
{
    /**
     * @param int $companyId Company entity ID.
     * @return float
     */
    public function resolveForCompany(int $companyId): float;
}
vendor/erp-sync/corporate-rewards/Model/MultiplierResolver.php
<?php

declare(strict_types=1);

namespace ErpSync\CorporateRewards\Model;

use ErpSync\CorporateRewards\Api\MultiplierResolverInterface;

/**
 * Default implementation, shipped by the vendor. resolveForCompany() is
 * declared final on purpose - the vendor wants every shop's ERP multiplier
 * to always come straight from their sync service, with no local drift.
 */
class MultiplierResolver implements MultiplierResolverInterface
{
    // ... constructor with the vendor's own ERP client dependency ...

    /**
     * @param int $companyId Company entity ID.
     * @return float
     */
    final public function resolveForCompany(int $companyId): float
    {
        // ... calls the external ERP system, falls back to 1.0 on error ...
    }
}

Der Hersteller registriert seine eigene Implementierung bereits per Preference in seiner eigenen di.xml: <preference for="ErpSync\CorporateRewards\Api\MultiplierResolverInterface" type="ErpSync\CorporateRewards\Model\MultiplierResolver"/>.

Warum ein Plugin hier scheitert

Ein Plugin auf resolveForCompany() würde bei setup:di:compile (oder beim ersten Laufzeit-Aufruf, falls der Compiler-Schritt übersprungen wird) mit einem PHP-Fatal-Error abbrechen: die generierte Interceptor-Klasse müsste MultiplierResolver erweitern und resolveForCompany() überschreiben - genau das verbietet final kategorisch. Es gibt keinen Weg, dieses eine Detail per Plugin anzupassen, ohne die final-Deklaration selbst zu entfernen (und damit den Quellcode eines fremden, lizenzierten Moduls zu verändern - keine Option).

Die Lösung: Preference mit Komposition statt Vererbung

Der entscheidende Kniff: die eigene Preference-Klasse extends MultiplierResolver nicht - sie implementiert stattdessen direkt MultiplierResolverInterface und hält die Original-Implementierung als ganz normale, injizierte Abhängigkeit (Komposition). Da final ausschließlich Vererbung verbietet, nicht aber das Halten einer Instanz als Kollaborator, bleibt der Original-Algorithmus des Herstellers vollständig nutzbar - nur eben nicht überschreibbar.

app/code/Mironsoft/Loyalty/Model/Erp/CompanyMultiplierPreference.php
<?php

declare(strict_types=1);

namespace Mironsoft\Loyalty\Model\Erp;

use ErpSync\CorporateRewards\Api\MultiplierResolverInterface;
use ErpSync\CorporateRewards\Model\MultiplierResolver;
use Magento\Company\Api\CompanyRepositoryInterface;
use Mironsoft\Loyalty\Model\Source\LoyaltyTier;

/**
 * Replaces the vendor's MultiplierResolver via a di.xml preference. Composes
 * - rather than extends - the vendor's final-method implementation, so its ERP
 * logic stays intact while a project-specific floor value is layered on top.
 */
class CompanyMultiplierPreference implements MultiplierResolverInterface
{
    /**
     * @var array<string, float>
     */
    private const TIER_FLOOR_MULTIPLIERS = [
        LoyaltyTier::TIER_BRONZE => 1.0,
        LoyaltyTier::TIER_SILVER => 1.2,
        LoyaltyTier::TIER_GOLD => 1.5,
    ];

    /**
     * @param MultiplierResolver $originalResolver The vendor's own final-method implementation, composed rather than extended.
     * @param CompanyRepositoryInterface $companyRepository Loads the company to read loyalty_tier_override (chapter 22).
     */
    public function __construct(
        private readonly MultiplierResolver $originalResolver,
        private readonly CompanyRepositoryInterface $companyRepository,
    ) {
    }

    /**
     * Resolves the ERP multiplier, raised to the project's tier floor if needed.
     *
     * @param int $companyId Company entity ID.
     * @return float
     */
    public function resolveForCompany(int $companyId): float
    {
        $erpMultiplier = $this->originalResolver->resolveForCompany($companyId);

        $company = $this->companyRepository->get($companyId);
        // @phpstan-ignore-next-line CompanyInterface::getData() is not on the interface but exists on the model
        $tierOverride = (string) $company->getData('loyalty_tier_override');
        $floor = self::TIER_FLOOR_MULTIPLIERS[$tierOverride] ?? 0.0;

        return max($erpMultiplier, $floor);
    }
}
app/code/Mironsoft/Loyalty/etc/di.xml
<preference for="ErpSync\CorporateRewards\Api\MultiplierResolverInterface"
            type="Mironsoft\Loyalty\Model\Erp\CompanyMultiplierPreference"/>

Wo dieser Wert später einfließt

CompanyMultiplierPreference bleibt bewusst eigenständig und ist noch nicht in PointsCalculator::calculatePoints() (Kapitel 5) verdrahtet - das würde eine neue Konstruktor-Abhängigkeit in einer bereits fixierten Klasse dieser Serie bedeuten und liegt außerhalb des heutigen Kapitel-Fokus. Für dieses Kapitel zählt die Preference-Technik selbst: ein final-Hindernis sauber umgangen, ohne fremden Quellcode zu verändern.

Tipp: Der Komposition-statt-Vererbung-Ansatz zahlt sich auch beim Testen aus: CompanyMultiplierPreference lässt sich isoliert instanziieren, indem MultiplierResolver und CompanyRepositoryInterface im Unit-Test durch Test-Doubles ersetzt werden (Kapitel 91/92) - kein ObjectManager, kein generierter Interceptor, kein Setup-Overhead.

Achtung: Pro Interface gewinnt immer nur eine Preference. Registriert irgendein anderes, später geladenes Modul ebenfalls eine Preference für MultiplierResolverInterface, wird CompanyMultiplierPreference stillschweigend nie instanziiert - ohne Fehlermeldung. Kapitel 42 zeigt, wie dieses Risiko in der Praxis aussieht und wie man es eindämmt.