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

Console-Command: manuelle Punkte-Neuberechnung und Audit des Ledgers

Console-Command: manuelle Punkte-Neuberechnung und Audit des Ledgers

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

Ein Append-Only-Ledger (Kapitel 3) macht Fehler nachvollziehbar, verhindert sie aber nicht: ein fehlgeschlagener Deploy mitten in einer Beobachter-Kette, ein manueller Datenbankeingriff, ein Bug in einer früheren Modulversion - all das kann dazu führen, dass die in balance_after gespeicherten Werte nicht mehr zur tatsächlichen Summe der Punkte passen. Dieser letzte Baustein von Block 1 ist ein Konsolenbefehl, der genau das erkennt und - optional - korrigiert.

Kommando-Design: mironsoft:loyalty:recalculate

Der Befehl liest die Ledger-Einträge eines Kunden (oder aller Kunden) chronologisch, führt eine laufende Summe mit, und vergleicht sie gegen den gespeicherten balance_after-Wert jeder Zeile. Bei einer Abweichung wird sie gemeldet - und, sofern nicht --dry-run gesetzt ist, sofort durch eine neue Zeile vom Typ adjust korrigiert. Wichtig: es wird niemals eine bestehende Zeile verändert, nur ergänzt - der Grundsatz aus Kapitel 3 gilt auch hier uneingeschränkt.

app/code/Mironsoft/Loyalty/Console/Command/RecalculatePointsCommand.php
<?php

declare(strict_types=1);

namespace Mironsoft\Loyalty\Console\Command;

use Mironsoft\Loyalty\Api\Data\PointsLedgerInterface;
use Mironsoft\Loyalty\Api\Data\PointsLedgerInterfaceFactory;
use Mironsoft\Loyalty\Api\PointsLedgerRepositoryInterface;
use Mironsoft\Loyalty\Model\ResourceModel\PointsLedger\Collection;
use Mironsoft\Loyalty\Model\ResourceModel\PointsLedger\CollectionFactory;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

/**
 * Recalculates and audits customer points balances from the append-only ledger.
 */
class RecalculatePointsCommand extends Command
{
    private const OPTION_CUSTOMER_ID = 'customer-id';
    private const OPTION_DRY_RUN = 'dry-run';

    /**
     * @param CollectionFactory $ledgerCollectionFactory Factory for the ledger entry collection.
     * @param PointsLedgerRepositoryInterface $pointsLedgerRepository Persists correction entries.
     * @param PointsLedgerInterfaceFactory $pointsLedgerFactory Creates new, unsaved ledger entries.
     * @param string|null $name Optional command name override, forwarded to the parent constructor.
     */
    public function __construct(
        private readonly CollectionFactory $ledgerCollectionFactory,
        private readonly PointsLedgerRepositoryInterface $pointsLedgerRepository,
        private readonly PointsLedgerInterfaceFactory $pointsLedgerFactory,
        ?string $name = null
    ) {
        parent::__construct($name);
    }

    /**
     * Declares the command name, description, and CLI options.
     *
     * @return void
     */
    protected function configure(): void
    {
        $this->setName('mironsoft:loyalty:recalculate');
        $this->setDescription('Recalculates and audits customer points balances from the ledger.');
        $this->addOption(
            self::OPTION_CUSTOMER_ID,
            null,
            InputOption::VALUE_OPTIONAL,
            'Limit the recalculation to a single customer ID.'
        );
        $this->addOption(
            self::OPTION_DRY_RUN,
            null,
            InputOption::VALUE_NONE,
            'Only report discrepancies without writing adjustment entries.'
        );
        parent::configure();
    }

    /**
     * Walks the ledger chronologically per customer, compares the running sum against
     * the stored balance_after, and writes an "adjust" entry on drift unless dry-run.
     *
     * @param InputInterface $input CLI input, provides the command options.
     * @param OutputInterface $output CLI output, used to report progress and drift.
     * @return int
     */
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $customerIdOption = $input->getOption(self::OPTION_CUSTOMER_ID);
        $dryRun = (bool) $input->getOption(self::OPTION_DRY_RUN);

        $collection = $this->ledgerCollectionFactory->create();
        if ($customerIdOption !== null) {
            $collection->addCustomerFilter((int) $customerIdOption);
        }
        $collection->setOrder('customer_id', Collection::SORT_ORDER_ASC);
        $collection->addOrder('created_at', Collection::SORT_ORDER_ASC);

        $runningBalance = [];
        $correctedEntries = 0;

        /** @var PointsLedgerInterface $entry */
        foreach ($collection as $entry) {
            $customerId = $entry->getCustomerId();
            $expectedBalance = ($runningBalance[$customerId] ?? 0) + $entry->getPoints();
            $runningBalance[$customerId] = $expectedBalance;

            if ($expectedBalance === $entry->getBalanceAfter()) {
                continue;
            }

            $output->writeln(sprintf(
                'Drift detected for customer #%d: ledger says %d, recalculated %d.',
                $customerId,
                $entry->getBalanceAfter(),
                $expectedBalance
            ));

            if ($dryRun) {
                continue;
            }

            $adjustment = $this->pointsLedgerFactory->create();
            $adjustment->setCustomerId($customerId);
            $adjustment->setType(PointsLedgerInterface::TYPE_ADJUST);
            $adjustment->setPoints($expectedBalance - $entry->getBalanceAfter());
            $adjustment->setBalanceAfter($expectedBalance);
            $this->pointsLedgerRepository->save($adjustment);

            $runningBalance[$customerId] = $expectedBalance;
            $correctedEntries++;
        }

        $output->writeln(sprintf('Recalculation finished, %d correction(s) written.', $correctedEntries));

        return Command::SUCCESS;
    }
}

Registrierung in di.xml

Console-Commands werden über das Array-Argument commands von Magento\Framework\Console\CommandListInterface registriert - hier die vollständige, um Kapitel 9 ergänzte di.xml im Vergleich zu Kapitel 6.

app/code/Mironsoft/Loyalty/etc/di.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <preference for="Mironsoft\Loyalty\Api\Data\PointsLedgerInterface"
                type="Mironsoft\Loyalty\Model\PointsLedger"/>
    <preference for="Mironsoft\Loyalty\Api\PointsLedgerRepositoryInterface"
                type="Mironsoft\Loyalty\Model\PointsLedgerRepository"/>
    <type name="Magento\Framework\Console\CommandListInterface">
        <arguments>
            <argument name="commands" xsi:type="array">
                <item name="mironsoft_loyalty_recalculate" xsi:type="object">
                    Mironsoft\Loyalty\Console\Command\RecalculatePointsCommand
                </item>
            </argument>
        </arguments>
    </type>
</config>

Den Befehl über den bin/-Wrapper nutzen

Wie jeder andere Magento-Konsolenbefehl in diesem Projekt läuft er ausschließlich über den bin/magento-Wrapper aus dem Mark-Shust-Setup, niemals direkt über php bin/magento.

# Erst trocken testen, ohne etwas zu schreiben
bin/magento mironsoft:loyalty:recalculate --dry-run

# Nur einen einzelnen Kunden prüfen
bin/magento mironsoft:loyalty:recalculate --customer-id=42 --dry-run

# Tatsächlich korrigieren
bin/magento mironsoft:loyalty:recalculate

Achtung: Auf einer Produktionsumgebung sollte dieser Befehl ausschließlich mit --dry-run und anschließender manueller Prüfung der gemeldeten Abweichungen laufen, bevor er ohne dieses Flag ausgeführt wird - eine automatische Korrektur, die selbst auf einer fehlerhaften Grundannahme beruht, kann eine Abweichung verschlimmern statt sie zu beheben.

Tipp: Genau diese reine, gut abgegrenzte Logik - laufende Summe, Vergleich, Korrekturzeile - macht PointsCalculator (Kapitel 5) und das Repository (Kapitel 6) zu idealen Kandidaten für die Unit- und Integrationstests aus Block 11. Der Konsolenbefehl selbst wird dort bewusst nicht direkt getestet, sondern nur die Bausteine, aus denen er sich zusammensetzt.

Block 1 abgeschlossen

Neun Kapitel, ein vollständiges Datenfundament: Tabelle, Model/ResourceModel/Collection, ein reiner Business-Logik-Service, ein Repository, eine Konfigurationsseite, ein Cache-Typ und ein Audit-Befehl. Die vollständige Verzeichnisstruktur nach diesem Kapitel entspricht exakt der Zielstruktur aus Kapitel 2 - nichts musste nachträglich umgebaut werden.

Mironsoft\Loyalty, vollständig nach Block 1

app/code/Mironsoft/Loyalty/
├── registration.php
├── composer.json
├── etc/
│   ├── module.xml
│   ├── di.xml
│   ├── acl.xml
│   ├── cache.xml
│   ├── config.xml
│   ├── db_schema.xml
│   └── adminhtml/
│       └── system.xml
├── Api/
│   ├── PointsLedgerRepositoryInterface.php
│   └── Data/
│       └── PointsLedgerInterface.php
├── Model/
│   ├── PointsLedger.php
│   ├── PointsLedgerRepository.php
│   ├── Cache/
│   │   └── Type/
│   │       └── LoyaltyCatalog.php
│   ├── Config/
│   │   └── LoyaltyConfig.php
│   ├── ResourceModel/
│   │   ├── PointsLedger.php
│   │   └── PointsLedger/
│   │       └── Collection.php
│   └── Service/
│       └── PointsCalculator.php
└── Console/
    └── Command/
        └── RecalculatePointsCommand.php

Block 2 setzt genau hier an und baut die erste EAV-Entity dieser Serie: die Prämien selbst, gegen die Kunden ihre Punkte in Kapitel 10 bis 18 einlösen können.