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:
<?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
<?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);
}
}
<?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.