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

Bestellabwicklung für den neuen Produkttyp anpassen

Bestellabwicklung für den neuen Produkttyp anpassen

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

Ein Punkte-Paket lässt sich seit Kapitel 75 kaufen - nur bucht der Kauf bisher keine einzige Punktzahl gut. Dieses Kapitel schließt die Lücke und verzahnt den neuen Produkttyp mit genau den Block-1-Bausteinen, die die gesamte Serie schon trägt: PointsLedgerRepositoryInterface (Kapitel 6) fürs Buchen, CustomerRepositoryInterface fürs Aktualisieren des Punktestands.

Beobachten statt eingreifen

Kapitel 37 hat die Entscheidungshilfe Observer-vs-Plugin bereits geliefert: Ein Plugin verändert das Verhalten einer fremden Methode, ein Observer reagiert lediglich auf ein bereits abgeschlossenes Business-Ereignis. "Ein Kauf ist passiert, jetzt Punkte gutschreiben" ist exakt Letzteres - keine fremde Methode wird beeinflusst, es wird nur zugehört. Deshalb bleibt dieses Kapitel konsequent beim bereits etablierten Muster aus Kapitel 30/63: ein weiterer Observer auf sales_order_place_after.

Ein neues Sales-Attribut für Idempotenz

loyalty_points_earned aus Kapitel 23 gehört bereits AwardPointsOnOrderPlaced - dessen Sperrlogik prüft es auftragsweit, nicht pro Position. Eine Wiederverwendung für Punkte-Pakete würde beide Observer voneinander abhängig machen, je nachdem, welcher zuerst läuft. Eine eigene, ausschließlich vom neuen Observer gepflegte Spalte vermeidet das:

app/code/Mironsoft/Loyalty/Setup/Patch/Data/InstallSalesLoyaltyPointsPackageCreditedAttribute.php
<?php

declare(strict_types=1);

namespace Mironsoft\Loyalty\Setup\Patch\Data;

use Magento\Framework\Setup\ModuleDataSetupInterface;
use Magento\Framework\Setup\Patch\DataPatchInterface;
use Magento\Sales\Setup\SalesSetupFactory;

/**
 * Adds the loyalty_points_package_credited attribute to sales_order_item -
 * item-level only, since points-package credit is decided per line item, not
 * per order, and deliberately a NEW column rather than reusing
 * loyalty_points_earned from chapter 23: that column is guarded and written
 * by AwardPointsOnOrderPlaced (chapter 30) for an entirely different,
 * multiplier-based formula. A shared column would make the two observers'
 * idempotency guards interfere with each other depending on execution order
 * - see the warning in chapter 76 for why events.xml gives no such guarantee.
 */
class InstallSalesLoyaltyPointsPackageCreditedAttribute implements DataPatchInterface
{
    /**
     * @param ModuleDataSetupInterface $moduleDataSetup Data setup instance
     * @param SalesSetupFactory $salesSetupFactory Factory for the Sales module's setup helper
     */
    public function __construct(
        private readonly ModuleDataSetupInterface $moduleDataSetup,
        private readonly SalesSetupFactory $salesSetupFactory,
    ) {
    }

    /**
     * Registers the item-level attribute for credited points-package points.
     *
     * @return void
     */
    public function apply(): void
    {
        $this->moduleDataSetup->getConnection()->startSetup();

        $salesSetup = $this->salesSetupFactory->create(['setup' => $this->moduleDataSetup]);
        $salesSetup->addAttribute('order_item', 'loyalty_points_package_credited', [
            'type' => 'int',
            'visible' => false,
            'default' => 0,
        ]);

        $this->moduleDataSetup->getConnection()->endSetup();
    }

    /**
     * Declares this patch depends on chapter 23's sales attribute installer.
     *
     * @return string[]
     */
    public static function getDependencies(): array
    {
        return [InstallSalesLoyaltyAttributes::class];
    }

    /**
     * Declares no aliases for this patch.
     *
     * @return string[]
     */
    public function getAliases(): array
    {
        return [];
    }
}

Der Observer

app/code/Mironsoft/Loyalty/Observer/CreditPurchasedPointsPackageOnOrderPlaced.php
<?php

declare(strict_types=1);

namespace Mironsoft\Loyalty\Observer;

use Magento\Customer\Api\CustomerRepositoryInterface;
use Magento\Framework\Event\Observer as EventObserver;
use Magento\Framework\Event\ObserverInterface;
use Magento\Sales\Api\Data\OrderInterface;
use Magento\Sales\Api\OrderRepositoryInterface;
use Mironsoft\Loyalty\Api\Data\PointsLedgerInterface;
use Mironsoft\Loyalty\Api\Data\PointsLedgerInterfaceFactory;
use Mironsoft\Loyalty\Api\PointsLedgerRepositoryInterface;
use Mironsoft\Loyalty\Model\Product\Type\PointsPackage;
use Psr\Log\LoggerInterface;

/**
 * Credits the loyalty points contained in a purchased "points package"
 * product (chapters 71-75) once the order that bought it has been placed.
 * Deliberately separate from AwardPointsOnOrderPlaced (chapter 30): that
 * observer earns points as a percentage of what was spent via
 * PointsCalculator (chapter 5), while this one credits a fixed,
 * product-defined amount - the two concepts share the same event and the
 * same PointsLedgerRepositoryInterface (chapter 6), but not the same
 * formula, so PointsCalculator is intentionally not reused here.
 */
class CreditPurchasedPointsPackageOnOrderPlaced implements ObserverInterface
{
    /**
     * @param CustomerRepositoryInterface $customerRepository Loads and saves the customer's points balance
     * @param PointsLedgerRepositoryInterface $pointsLedgerRepository Persists the earn ledger entry
     * @param PointsLedgerInterfaceFactory $pointsLedgerFactory Creates a new, unsaved ledger entry
     * @param OrderRepositoryInterface $orderRepository Persists the order items' credited-flag
     * @param LoggerInterface $logger Logs failures without letting them break checkout
     */
    public function __construct(
        private readonly CustomerRepositoryInterface $customerRepository,
        private readonly PointsLedgerRepositoryInterface $pointsLedgerRepository,
        private readonly PointsLedgerInterfaceFactory $pointsLedgerFactory,
        private readonly OrderRepositoryInterface $orderRepository,
        private readonly LoggerInterface $logger,
    ) {
    }

    /**
     * Entry point required by ObserverInterface. Delegates to
     * creditPackages() and swallows every exception, the same reasoning as
     * AwardPointsOnOrderPlaced (chapter 30): this observer runs after the
     * order already exists, so a failure here must never roll back or block
     * the checkout response.
     *
     * @param EventObserver $observer Event observer carrying the placed order
     * @return void
     */
    public function execute(EventObserver $observer): void
    {
        /** @var OrderInterface $order */
        $order = $observer->getEvent()->getData('order');

        if (!$order->getCustomerId()) {
            return;
        }

        try {
            $this->creditPackages($order);
        } catch (\Throwable $exception) {
            $this->logger->error(
                sprintf(
                    'Mironsoft_Loyalty: failed to credit purchased points packages for order #%s: %s',
                    (string) $order->getIncrementId(),
                    $exception->getMessage()
                ),
                ['exception' => $exception]
            );
        }
    }

    /**
     * Walks every points-package order item, credits the points it
     * contains, and persists the idempotency flag - order-independent from
     * AwardPointsOnOrderPlaced (chapter 30), since events.xml observers on
     * the same event have no guaranteed execution order (see the warning
     * below).
     *
     * @param OrderInterface $order The just-placed order
     * @return void
     */
    private function creditPackages(OrderInterface $order): void
    {
        $totalPoints = 0;
        $itemsChanged = false;

        foreach ($order->getItems() as $item) {
            if ($item->getProductType() !== PointsPackage::TYPE_CODE) {
                continue;
            }

            if ((int) $item->getData('loyalty_points_package_credited') > 0) {
                continue; // idempotency guard, keyed on this item's own dedicated column
            }

            $product = $item->getProduct();
            $pointsPerUnit = $product !== null
                ? (int) $product->getData('loyalty_points_package_amount')
                : 0;
            $itemPoints = $pointsPerUnit * (int) $item->getQtyOrdered();

            if ($itemPoints <= 0) {
                continue;
            }

            $item->setData('loyalty_points_package_credited', $itemPoints);
            $totalPoints += $itemPoints;
            $itemsChanged = true;
        }

        if (!$itemsChanged) {
            return;
        }

        $this->orderRepository->save($order);
        $this->creditCustomer((int) $order->getCustomerId(), $totalPoints, (int) $order->getEntityId());
    }

    /**
     * Credits points to the customer's balance and appends the matching
     * ledger entry - the same CustomerRepositoryInterface custom-attribute
     * technique used throughout this series since chapter 30.
     *
     * @param int $customerId Customer entity ID
     * @param int $points Points credited by purchased packages in this order
     * @param int $orderId Order entity ID, stored on the ledger entry
     * @return void
     */
    private function creditCustomer(int $customerId, int $points, int $orderId): void
    {
        $customer = $this->customerRepository->getById($customerId);
        $currentAttribute = $customer->getCustomAttribute('loyalty_points_balance');
        $currentBalance = $currentAttribute !== null ? (int) $currentAttribute->getValue() : 0;
        $newBalance = $currentBalance + $points;

        /** @var PointsLedgerInterface $ledgerEntry */
        $ledgerEntry = $this->pointsLedgerFactory->create();
        $ledgerEntry->setCustomerId($customerId);
        $ledgerEntry->setOrderId($orderId);
        $ledgerEntry->setType(PointsLedgerInterface::TYPE_EARN);
        $ledgerEntry->setPoints($points);
        $ledgerEntry->setBalanceAfter($newBalance);
        $this->pointsLedgerRepository->save($ledgerEntry);

        $customer->setCustomAttribute('loyalty_points_balance', $newBalance);
        $this->customerRepository->save($customer);
    }
}
app/code/Mironsoft/Loyalty/etc/events.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
    <event name="sales_order_place_after">
        <observer name="mironsoft_loyalty_award_points_on_order_placed"
                  instance="Mironsoft\Loyalty\Observer\AwardPointsOnOrderPlaced"/>
        <observer name="mironsoft_loyalty_redeem_points_on_order_placed"
                  instance="Mironsoft\Loyalty\Observer\RedeemPointsOnOrderPlaced"/>
        <observer name="mironsoft_loyalty_credit_purchased_points_package_on_order_placed"
                  instance="Mironsoft\Loyalty\Observer\CreditPurchasedPointsPackageOnOrderPlaced"/>
    </event>
    <!-- sales_order_creditmemo_save_commit_after (chapter 31) unchanged, omitted here for brevity -->
</config>

$item->getProductType() === PointsPackage::TYPE_CODE ist der konkrete Beleg für das in Kapitel 71 versprochene Argument: eine robuste, typsichere Filterung statt eines fragilen Boolean-Attributs, das jemand vergessen könnte richtig zu setzen.

Achtung: Keine garantierte Ausführungsreihenfolge: Anders als Plugins kennt events.xml kein sortOrder - ob AwardPointsOnOrderPlaced oder CreditPurchasedPointsPackageOnOrderPlaced für dieselbe Bestellung zuerst läuft, hängt von der Modul-Ladereihenfolge ab, nicht von einer expliziten Konfiguration. Genau deshalb darf keiner der beiden Observer sich in seiner Sperrlogik auf ein bereits gesetztes Ergebnis des jeweils anderen verlassen - der eigene loyalty_points_package_credited-Flag oben ist bewusst so gewählt, dass er unabhängig von dieser Reihenfolge korrekt funktioniert.

Doppeltes Punkten vermeiden: loyalty_points_multiplier

Achtung: AwardPointsOnOrderPlaced (Kapitel 30) verdient für JEDE Bestellposition Punkte nach Kaufpreis, unabhängig vom Produkttyp - auch für ein Punkte-Paket selbst, dessen loyalty_points_multiplier (Kapitel 19) standardmäßig 1.0 beträgt. Ohne Gegenmaßnahme würde ein Kunde beim Kauf eines Punkte-Pakets zweifach profitieren: die enthaltenen Punkte (dieser Observer) UND regulär verdiente Kaufpreis-Punkte (Kapitel 30) obendrauf. Die empfohlene Lösung ist bewusst keine Code-Änderung an einer bereits fixierten Block-1/4-Klasse, sondern eine reine Admin-Datenpflege: loyalty_points_multiplier auf jedem Punkte-Paket-Produkt explizit auf 0 setzen - derselbe Wert, den Kapitel 19 bereits als "dieses Produkt bringt trotz Kauf keine Punkte" dokumentiert hat.

Bekannte Grenze: keine Erstattungslogik

Anders als ReversePointsOnCreditmemoSave (Kapitel 31) für regulär verdiente Punkte kennt dieser Block keine symmetrische Rückbuchung, falls eine Bestellung mit Punkte-Paket später als Gutschrift storniert wird - bewusst nicht behandelt, um den Umfang dieses Kapitels nicht zu sprengen. Wer das nachrüsten möchte, findet in Kapitel 31 den passenden Ansatzpunkt: dasselbe Soll-Ist-Reconciliation-Muster, angewendet auf loyalty_points_package_credited statt loyalty_points_earned.

Tipp: Damit schließt sich der Kreis zu Block 1: derselbe PointsLedgerRepositoryInterface, dieselbe CustomerRepositoryInterface-Custom-Attribute-Technik, dieselbe TYPE_EARN-Konstante - ein völlig neuer Produkttyp bucht am Ende exakt über dieselbe, bereits in Kapitel 6 fixierte Schnittstelle wie jeder andere Weg, Punkte zu verdienen oder einzulösen.

Ein Punkte-Paket lässt sich jetzt vollständig kaufen und schreibt seine Punkte zuverlässig gut. Kapitel 77 prüft als Nächstes, wie gut sich dieser neue Typ mit Preisregeln und Rabatten verträgt.