Quelltext des aktuellen Standes
<?php
declare(strict_types=1);
use OpenNIT\Audit\AuditLogger;
use OpenNIT\Content\ContractManager;
use OpenNIT\Core\Database;
/**
* Deckt den Preis- und Laufzeitverlauf eines Vertrags ab.
*
* Die Zeitachse steht neben dem Audit-Log, nicht darin: Sie zeigt nur, was den
* Wert und die Dauer eines Vertrags ausmacht. Daraus folgen zwei Zusagen, die
* hier festgehalten werden – eine Umbenennung darf keinen Eintrag erzeugen,
* und derselbe Betrag in anderer Schreibweise ebenso wenig.
*
* Der dritte Punkt ist die Darstellung: Gespeichert werden Rohwerte, damit ein
* Verlauf auch nach einer geänderten Beschriftung noch stimmt. Lesbar werden
* sie erst beim Anzeigen.
*/
return static function (): void {
$wurzel = dirname(__DIR__);
/**
* Schreibt einen Wechsel von `$alt` nach `$neu` über das private
* recordHistory() fort und liefert die dabei entstandenen Zeilen.
*
* Die Datenbank ist eine Attrappe, die Einfügungen sammelt statt sie
* auszuführen.
*/
$verlauf = static function (array $alt, array $neu, int $userId = 7): array {
$db = new class extends Database {
/** @var list<array<string,mixed>> */
public array $inserts = [];
public function __construct()
{
parent::__construct([]);
}
public function fetchOne(string $sql, array $params = []): ?array
{
return null;
}
public function fetchAll(string $sql, array $params = []): array
{
return [];
}
public function insert(string $table, array $data): int
{
$this->inserts[] = ['table' => $table] + $data;
return count($this->inserts);
}
public function update(string $table, array $data, string $where, array $whereParams = []): int
{
return 1;
}
public function delete(string $table, string $where, array $params = []): int
{
return 0;
}
};
$ref = new ReflectionClass(ContractManager::class);
$m = $ref->newInstanceWithoutConstructor();
foreach (['db' => $db, 'auditLogger' => new AuditLogger($db)] as $name => $wert) {
$prop = $ref->getProperty($name);
$prop->setAccessible(true);
$prop->setValue($m, $wert);
}
$methode = new ReflectionMethod(ContractManager::class, 'recordHistory');
$methode->setAccessible(true);
$methode->invoke($m, 42, $alt, $neu, $userId);
return array_values(array_filter($db->inserts, fn($r) => $r['table'] === 'contract_history'));
};
// ----------------------------------------------------------------
// Was einen Eintrag erzeugt – und was nicht
// ----------------------------------------------------------------
test('Eine reine Umbenennung landet nicht im Verlauf', function () use ($verlauf) {
$zeilen = $verlauf(
['title' => 'Alt', 'cost_amount' => '100.00', 'cost_interval' => 'monthly', 'status' => 'active'],
['title' => 'Neu', 'cost_amount' => '100.00', 'cost_interval' => 'monthly', 'status' => 'active']
);
assertSame(0, count($zeilen), 'Eine Umbenennung hat einen Eintrag erzeugt');
});
test('Derselbe Betrag in anderer Schreibweise gilt als unverändert', function () use ($verlauf) {
// Aus dem Formular kommt '100', aus der Datenbank '100.00'. Ohne
// Normalisierung wüchse der Verlauf bei jedem Speichern.
$zeilen = $verlauf(
['cost_amount' => '100.00', 'tax_rate' => '19.00', 'auto_renew' => 1, 'cancellation_period_months' => '3'],
['cost_amount' => '100', 'tax_rate' => 19.0, 'auto_renew' => 1, 'cancellation_period_months' => 3]
);
assertSame(0, count($zeilen), 'Gleiche Werte wurden als Änderung gewertet');
});
test('Eine Preisänderung erzeugt genau einen Eintrag im Bereich Preis', function () use ($verlauf) {
$zeilen = $verlauf(
['cost_amount' => '100.00', 'cost_interval' => 'monthly'],
['cost_amount' => '120.00', 'cost_interval' => 'yearly']
);
assertSame(1, count($zeilen), 'Erwartet wurde ein Eintrag');
assertSame('price', $zeilen[0]['event_type']);
assertSame(42, $zeilen[0]['contract_id']);
assertSame(7, $zeilen[0]['changed_by']);
$aenderungen = json_decode((string)$zeilen[0]['changes_json'], true);
assertSame(2, count($aenderungen), 'Beide geänderten Felder fehlen im Eintrag');
// Gespeichert werden Rohwerte, keine fertigen Beschriftungen.
$felder = array_column($aenderungen, 'new', 'field');
assertSame('120.00', $felder['cost_amount']);
assertSame('yearly', $felder['cost_interval']);
});
test('Preis und Laufzeit werden getrennt festgehalten', function () use ($verlauf) {
// Ein Speichervorgang kann beides ändern; die Zeitachse soll dann zwei
// Einträge zeigen statt einen vermischten.
$zeilen = $verlauf(
['cost_amount' => '100.00', 'end_date' => '2026-12-31', 'status' => 'active'],
['cost_amount' => '150.00', 'end_date' => '2027-12-31', 'status' => 'cancelled']
);
$bereiche = array_column($zeilen, 'event_type');
sort($bereiche);
assertSame(['price', 'status', 'term'], $bereiche);
});
test('Ein geleertes Feld wird als solches festgehalten', function () use ($verlauf) {
$zeilen = $verlauf(['end_date' => '2026-12-31'], ['end_date' => null]);
assertSame(1, count($zeilen));
$aenderungen = json_decode((string)$zeilen[0]['changes_json'], true);
assertSame('2026-12-31', $aenderungen[0]['old']);
assertSame(null, $aenderungen[0]['new'], 'Das Leeren wurde nicht als null gespeichert');
});
// ----------------------------------------------------------------
// Darstellung
// ----------------------------------------------------------------
test('Rohwerte werden erst beim Anzeigen lesbar', function () {
// Der Betrag folgt der eingestellten Waehrung; ohne Angabe gilt die Vorgabe.
assertSame("120,00\u{202F}€", ContractManager::formatHistoryValue('cost_amount', '120.00'));
assertSame("CHF\u{202F}120.00", ContractManager::formatHistoryValue(
'cost_amount', '120.00', new \OpenNIT\Core\Money(['currency_code' => 'CHF'])
));
assertSame('19,00 %', ContractManager::formatHistoryValue('tax_rate', '19.00'));
assertSame('jährlich', ContractManager::formatHistoryValue('cost_interval', 'yearly'));
assertSame('Gekündigt', ContractManager::formatHistoryValue('status', 'cancelled'));
assertSame('31.12.2026', ContractManager::formatHistoryValue('end_date', '2026-12-31'));
assertSame('1 Monat', ContractManager::formatHistoryValue('cancellation_period_months', '1'));
assertSame('3 Monate', ContractManager::formatHistoryValue('cancellation_period_months', '3'));
assertSame('Ja', ContractManager::formatHistoryValue('auto_renew', '1'));
assertSame('Nein', ContractManager::formatHistoryValue('auto_renew', '0'));
assertSame(null, ContractManager::formatHistoryValue('end_date', null), 'Nicht gesetzt muss null bleiben');
});
// ----------------------------------------------------------------
// Abgeleitete Statuswechsel bleiben draußen
// ----------------------------------------------------------------
test('Der tägliche Wechsel auf „Kündigung beachten" erzeugt keinen Eintrag', function () use ($wurzel) {
// Dieser Wechsel folgt allein aus dem Datum und würde die Zeitachse
// jedes Vertrags mit einer Zeile füllen, die nichts erklärt.
$quelle = (string)file_get_contents($wurzel . '/src/Content/ContractManager.php');
$start = strpos($quelle, "update('contracts', ['status' => 'expiring']");
assertTrue($start !== false, 'Der Status-Durchlauf fehlt');
$umfeld = substr($quelle, max(0, $start - 600), 900);
assertFalse(str_contains($umfeld, 'contract_history'), 'Der abgeleitete Wechsel schreibt in den Verlauf');
});
test('Eine automatische Verlängerung erscheint als eigener Bereich', function () use ($wurzel) {
$quelle = (string)file_get_contents($wurzel . '/src/Content/ContractManager.php');
$start = strpos($quelle, 'public function autoRenewContracts(');
assertTrue($start !== false, 'autoRenewContracts() fehlt');
$body = substr($quelle, $start, 3500);
assertTrue(str_contains($body, "'event_type' => 'renewal'"), 'Die Verlängerung landet nicht im Verlauf');
assertTrue(str_contains($body, "'changed_by' => null"), 'Die Verlängerung wird einem Benutzer zugeschrieben');
});
// ----------------------------------------------------------------
// Ablage
// ----------------------------------------------------------------
test('Der Verlauf verschwindet mit dem Vertrag', function () use ($wurzel) {
$sql = (string)file_get_contents($wurzel . '/database/migrations/220_contract_history.sql');
assertTrue(str_contains($sql, 'CREATE TABLE IF NOT EXISTS `contract_history`'), 'Die Tabelle fehlt');
assertTrue(
str_contains($sql, 'REFERENCES `contracts`(`id`) ON DELETE CASCADE'),
'Der Verlauf bliebe nach dem Löschen des Vertrags zurück'
);
});
test('Die Vertragsseite zeigt den Verlauf mit der zentralen Zeitleiste', function () use ($wurzel) {
$tpl = (string)file_get_contents($wurzel . '/templates/contracts/show.php');
assertTrue(str_contains($tpl, 'Preis- und Laufzeitverlauf'), 'Der Verlauf fehlt auf der Vertragsseite');
// Die Zeitleiste steht zentral in app.css; ein eigener Nachbau unter
// cs-Prefix würde beim nächsten Feinschliff auseinanderlaufen.
assertTrue(str_contains($tpl, '<ul class="timeline">'), 'Die zentrale Zeitleiste wird nicht verwendet');
assertFalse(str_contains($tpl, '.cs-timeline'), 'Die Zeitleiste wurde modulweise nachgebaut');
$ctrl = (string)file_get_contents($wurzel . '/src/Controllers/ContractController.php');
assertTrue(str_contains($ctrl, 'getContractTimeline('), 'Der Verlauf wird nicht an die Seite übergeben');
});
};