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.
<?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.
<?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:recalculateAchtung: 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.phpBlock 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.