M4 Ledger-Migration fuer Legacy-Buchungen starten

- ledger_entries als tenant-sichere Buchungstabelle ergaenzen
- Legacy-Einzahlungen und Kaffeeverbrauch idempotent spiegeln
- M4-Paritaetscheck und Datenmigrationsdoku ergaenzen
This commit is contained in:
2026-07-13 20:45:28 +02:00
parent b0e21bbb0a
commit e4b0c81b48
6 changed files with 640 additions and 13 deletions
@@ -0,0 +1,38 @@
CREATE TABLE IF NOT EXISTS ledger_entries (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
participant_id INT NOT NULL,
type VARCHAR(30) NOT NULL,
amount_cents INT NOT NULL,
marks_count INT NULL,
unit_price_cents INT NULL,
booked_at DATETIME NOT NULL,
source VARCHAR(40) NOT NULL DEFAULT 'legacy',
note VARCHAR(1000) NULL,
created_by_user_id INT NULL,
import_batch_id INT NULL,
legacy_table VARCHAR(100) NULL,
legacy_id INT NULL,
voided_at DATETIME NULL,
reversal_of_entry_id INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_ledger_entries_tenant_legacy (tenant_id, legacy_table, legacy_id),
KEY idx_ledger_entries_tenant_booked_at (tenant_id, booked_at),
KEY idx_ledger_entries_participant_booked_at (participant_id, booked_at),
KEY idx_ledger_entries_tenant_type (tenant_id, type),
KEY idx_ledger_entries_reversal (reversal_of_entry_id),
KEY idx_ledger_entries_created_by_user (created_by_user_id),
CONSTRAINT fk_ledger_entries_tenant
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
ON DELETE CASCADE,
CONSTRAINT fk_ledger_entries_participant
FOREIGN KEY (participant_id) REFERENCES participants(id)
ON DELETE CASCADE,
CONSTRAINT fk_ledger_entries_created_by_user
FOREIGN KEY (created_by_user_id) REFERENCES users(id)
ON DELETE SET NULL,
CONSTRAINT fk_ledger_entries_reversal
FOREIGN KEY (reversal_of_entry_id) REFERENCES ledger_entries(id)
ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+14 -2
View File
@@ -6,6 +6,9 @@ M3 fuehrt Mandanten, Benutzer, Mitgliedschaften und Grundeinstellungen additiv
ein. Die bestehende Legacy-App bleibt dabei lauffaehig und wird noch nicht auf ein. Die bestehende Legacy-App bleibt dabei lauffaehig und wird noch nicht auf
das neue Modell umgestellt. das neue Modell umgestellt.
Status: abgeschlossen fuer den M3-Scope. M4 startet mit der additiven
Migration von Einzahlungen und Kaffeeverbrauch nach `ledger_entries`.
## Ziel ## Ziel
- Einen Default-Tenant fuer den aktuellen Bestand erzeugen. - Einen Default-Tenant fuer den aktuellen Bestand erzeugen.
@@ -272,5 +275,14 @@ Umgesetzter Umfang:
16. Golden-Master und HTTP-Smoke ausfuehren: erledigt. 16. Golden-Master und HTTP-Smoke ausfuehren: erledigt.
17. Mail-Transport-Abstraktion und Mail-Flow-Kontrollskript bauen: erledigt. 17. Mail-Transport-Abstraktion und Mail-Flow-Kontrollskript bauen: erledigt.
18. Public-Landingpage mit erstem Hero-Asset vorbereiten: erledigt. 18. Public-Landingpage mit erstem Hero-Asset vorbereiten: erledigt.
19. M3-Abschlusscheck dokumentieren und M4-Datenmigration starten: 19. M3-Abschlusscheck dokumentieren und M4-Datenmigration starten: erledigt.
naechster Schritt.
## Uebergabe an M4
M4 ist in `docs/m4-data-migration.md` dokumentiert. Der erste M4-Schritt ist
bewusst additiv:
- `ledger_entries` wird als neue tenant-sichere Buchungstabelle angelegt.
- Legacy-Einzahlungen und Legacy-Kaffeeverbrauch werden gespiegelt.
- Bestehende Seiten bleiben vorerst auf den `kl_*`-Tabellen.
- Summen werden gegen den Golden Master verglichen.
+150
View File
@@ -0,0 +1,150 @@
# M4 Datenmigration Fachlicher Kern
Stand: 2026-07-13
M4 uebernimmt die finanznahen Legacy-Buchungen additiv in das neue
tenant-sichere Ledger-Modell. Die bestehenden Legacy-Seiten lesen und schreiben
weiterhin die bisherigen `kl_*`-Tabellen; das Ledger dient zunaechst als
vergleichbare, pruefbare Zielstruktur.
## Ziel
- `kl_Einzahlungen` nach `ledger_entries` spiegeln.
- `kl_Kaffeeverbrauch` nach `ledger_entries` spiegeln.
- Legacy-IDs erhalten, damit Paritaet und spaetere Delta-Migration moeglich
bleiben.
- Summen gegen den Golden Master pruefen.
- Keine bestehenden Legacy-Buchungen loeschen oder veraendern.
## Nicht-Ziele
- Keine Umstellung der operativen Seiten auf das Ledger in diesem Schritt.
- Keine Storno-/Korrektur-UI.
- Kein CSV-Import auf neues Import-Batch-Modell.
- Kein PDF-/Mail-/Jahresprozess-Rewrite.
## Umgesetzter erster Schritt
Umgesetzte Migration:
```text
database/migrations/0006_saas_ledger_entries.sql
```
Neue Tabelle:
- `ledger_entries`
Ergaenzende Skripte:
```text
scripts/backfill-ledger-entries.php
scripts/check-m4-ledger-migration.php
```
Ausgefuehrter Dev-Stand:
- Migration `0006_saas_ledger_entries.sql` wurde angewendet.
- 9 Legacy-Einzahlungen wurden als `payment` gespiegelt.
- 12 Legacy-Kaffeeverbrauch-Zeilen wurden als `consumption` gespiegelt.
- Ein zweiter Backfill-Lauf blieb idempotent und erzeugte keine Dubletten.
## Abbildungsregeln
### Einzahlungen
Quelle:
```text
kl_Einzahlungen
```
Ziel:
```text
ledger_entries.type = payment
ledger_entries.amount_cents = Betrag * 100
ledger_entries.booked_at = Datum
ledger_entries.source = legacy_payment
ledger_entries.legacy_table = kl_Einzahlungen
ledger_entries.legacy_id = EinzahlungsID
```
Einzahlungen werden positiv gespeichert.
### Kaffeeverbrauch
Quelle:
```text
kl_Kaffeeverbrauch
```
Ziel:
```text
ledger_entries.type = consumption
ledger_entries.amount_cents = -Kosten * 100
ledger_entries.marks_count = AnzahlStriche
ledger_entries.unit_price_cents = KostenproStrich * 100
ledger_entries.booked_at = Datum
ledger_entries.source = legacy_web bei Eintragsart = 2, sonst legacy_manual
ledger_entries.legacy_table = kl_Kaffeeverbrauch
ledger_entries.legacy_id = VerbrauchID
```
Verbrauch wird negativ gespeichert. Damit ergibt `SUM(amount_cents)` direkt den
aktuellen Saldo eines Teilnehmers.
## Idempotenz und Aufraeumen
Das Backfill-Skript nutzt `tenant_id`, `legacy_table` und `legacy_id` als
eindeutige Quelle. Wiederholte Laeufe aktualisieren vorhandene Ledger-Zeilen.
Fuer den Default-Tenant werden nur solche Legacy-Spiegelungen entfernt, deren
urspruengliche Legacy-Zeile nicht mehr existiert. Fachliche Daten werden dabei
nicht aus den `kl_*`-Tabellen geloescht.
## Ausfuehrung
```bash
php scripts/backfill-default-tenant.php
php scripts/backfill-ledger-entries.php
php scripts/check-m4-ledger-migration.php
php scripts/check-golden-master.php
```
In der lokalen VS-Code-Server-Umgebung muss wie bei M2/M3 die lokale PHP-8.3-
Installation aus `.local/` verwendet werden.
## Akzeptanzkriterien
- Migration `0006_saas_ledger_entries.sql` laeuft erfolgreich: erfuellt.
- Jede Legacy-Einzahlung hat genau eine Ledger-Zeile: erfuellt.
- Jeder Legacy-Kaffeeverbrauch hat genau eine Ledger-Zeile: erfuellt.
- Legacy-IDs sind je Tenant eindeutig: erfuellt.
- Einzahlungen sind positiv, Verbrauch ist negativ: erfuellt.
- Verbrauch behaelt Strichanzahl und Preis pro Strich: erfuellt.
- Web-Striche aus `Eintragsart = 2` bleiben als `source = legacy_web`
erkennbar: erfuellt.
- Ledger-Summen entsprechen dem Golden Master: erfuellt.
## Aktueller Pruefstatus
- M4 Ledger-Migration: gruen mit 73 Assertions.
- M3 SaaS-Basis: weiterhin gruen.
- M3 Tenant-Aufloesung: weiterhin gruen.
- M3 Passwort/E-Mail: weiterhin gruen.
- M3 Auth-Flow: weiterhin gruen.
- M3 Settings-Flow: weiterhin gruen.
- M3 Mail-Flow: weiterhin gruen.
- Golden Master: weiterhin gruen mit 104 Assertions.
- HTTP-Smoke: weiterhin gruen mit 22 geprueften Seiten.
## Noch offen in M4
- Neues Repository/Service fuer Ledger-Abfragen der App-Seiten.
- Tenant-sichere Dashboard-, Strich-, Einzahlungs- und Listenqueries auf Basis
des Ledgers.
- Storno-/Reversal-Modell in der UI statt harter Deletes.
- Delta-Strategie fuer den finalen Cutover.
+17 -11
View File
@@ -283,8 +283,8 @@ Uebersicht:
| M0 | Baseline | Bestand, Sicherheit und Designreferenz sind dokumentiert | | M0 | Baseline | Bestand, Sicherheit und Designreferenz sind dokumentiert |
| M1 | Golden Master | Legacy-Ergebnisse sind als Vergleichsbasis eingefroren; HTTP-Smoke prueft sichere Seiten | | M1 | Golden Master | Legacy-Ergebnisse sind als Vergleichsbasis eingefroren; HTTP-Smoke prueft sichere Seiten |
| M2 | Technisches Fundament | Abgeschlossen: Migrationen, Bootstrap, Session, CSRF-Helper und Legacy-Schreibseitenschutz stehen | | M2 | Technisches Fundament | Abgeschlossen: Migrationen, Bootstrap, Session, CSRF-Helper und Legacy-Schreibseitenschutz stehen |
| M3 | SaaS-Basis | Tenants, User, Registrierung, Login, Rollen, Mail-Links und zentrale Mandantenauswahl funktionieren | | M3 | SaaS-Basis | Abgeschlossen: Tenants, User, Registrierung, Login, Rollen, Mail-Links und zentrale Mandantenauswahl funktionieren |
| M4 | Datenmigration | Legacy-Daten sind tenant-sicher im Zielmodell abgebildet | | M4 | Datenmigration | Gestartet: Ledger-Tabelle, Legacy-Backfill und Paritaetscheck sind umgesetzt |
| M5 | App-Kern | Dashboard, Striche, Einzahlungen, Mitglieder und Liste laufen | | M5 | App-Kern | Dashboard, Striche, Einzahlungen, Mitglieder und Liste laufen |
| M6 | Betriebsflows | Import, Export, Mail und Jahresprozesse sind auditierbar | | M6 | Betriebsflows | Import, Export, Mail und Jahresprozesse sind auditierbar |
| M7 | Landingpage | Erste werbliche Seite ist oeffentlich nutzbar; spaetere Ausbaustufen folgen | | M7 | Landingpage | Erste werbliche Seite ist oeffentlich nutzbar; spaetere Ausbaustufen folgen |
@@ -434,6 +434,7 @@ Ergebnis:
- Rollen und Tenant-Kontext sind serverseitig verfuegbar. - Rollen und Tenant-Kontext sind serverseitig verfuegbar.
- Reset- und Verifikationslinks koennen im Dev-Modus geloggt und produktiv ueber - Reset- und Verifikationslinks koennen im Dev-Modus geloggt und produktiv ueber
einen konfigurierten Mail-Transport versendet werden. einen konfigurierten Mail-Transport versendet werden.
- Status: abgeschlossen fuer den M3-Scope.
Abhaengigkeiten: Abhaengigkeiten:
@@ -446,18 +447,23 @@ Bestehende Kaffeelisten-Daten tenant-sicher uebernehmen.
Schritte: Schritte:
- Initialen Tenant fuer den bisherigen Bestand anlegen. - Initialen Tenant fuer den bisherigen Bestand anlegen: erledigt in M3.
- `kl_Mitarbeiter` nach `participants` migrieren. - `kl_Mitarbeiter` nach `participants` migrieren: erledigt in M3.
- `admin`-Informationen in `tenant_memberships` oder Admin-Rollen ueberfuehren. - `admin`-Informationen in `tenant_memberships` oder Admin-Rollen ueberfuehren:
- `kl_config` nach `tenant_settings` migrieren. erledigt in M3.
- `kl_Einzahlungen` und `kl_Kaffeeverbrauch` nach `ledger_entries` migrieren. - `kl_config` nach `tenant_settings` migrieren: erledigt in M3.
- Legacy-IDs speichern. - `ledger_entries` als tenant-sichere Buchungstabelle anlegen: erledigt.
- Salden gegen Golden-Master vergleichen. - `kl_Einzahlungen` und `kl_Kaffeeverbrauch` additiv nach `ledger_entries`
spiegeln: erster Backfill erledigt.
- Legacy-IDs speichern: erledigt ueber `legacy_table` und `legacy_id`.
- Salden gegen Golden-Master vergleichen: Kontrollskript erledigt.
Ergebnis: Ergebnis:
- Bestehender Datenbestand ist im neuen Modell abgebildet. - Bestehender Datenbestand kann im neuen Modell abgebildet werden.
- Saldenparitaet ist nachweisbar. - Saldenparitaet ist ueber `scripts/check-m4-ledger-migration.php`
nachweisbar.
- Dokumentation: `docs/m4-data-migration.md`.
Abhaengigkeiten: Abhaengigkeiten:
+212
View File
@@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
require __DIR__ . '/dev-db.php';
function m4_env(string $name, string $default): string
{
$value = getenv($name);
if ($value === false || trim($value) === '') {
return $default;
}
return trim($value);
}
function m4_fetch_tenant_id(PDO $pdo, string $slug): int
{
$stmt = $pdo->prepare('SELECT id FROM tenants WHERE slug = ?');
$stmt->execute([$slug]);
$tenantId = $stmt->fetchColumn();
if ($tenantId === false) {
throw new RuntimeException("Tenant '{$slug}' was not found. Run scripts/backfill-default-tenant.php first.");
}
return (int)$tenantId;
}
function m4_count_missing_payment_participants(PDO $pdo, int $tenantId): int
{
$stmt = $pdo->prepare(
'SELECT COUNT(*)
FROM kl_Einzahlungen e
LEFT JOIN participants p
ON p.tenant_id = ?
AND p.legacy_mitarbeiter_id = e.MitarbeiterID
WHERE p.id IS NULL'
);
$stmt->execute([$tenantId]);
return (int)$stmt->fetchColumn();
}
function m4_count_missing_consumption_participants(PDO $pdo, int $tenantId): int
{
$stmt = $pdo->prepare(
'SELECT COUNT(*)
FROM kl_Kaffeeverbrauch v
LEFT JOIN participants p
ON p.tenant_id = ?
AND p.legacy_mitarbeiter_id = v.MitarbeiterID
WHERE p.id IS NULL'
);
$stmt->execute([$tenantId]);
return (int)$stmt->fetchColumn();
}
$pdo = dev_pdo();
$applied = dev_apply_migrations($pdo);
$tenantSlug = m4_env('M4_DEFAULT_TENANT_SLUG', m4_env('M3_DEFAULT_TENANT_SLUG', 'default'));
$stats = [
'applied_migrations' => count($applied),
'stale_payments_removed' => 0,
'stale_consumptions_removed' => 0,
'payments_mirrored' => 0,
'consumptions_mirrored' => 0,
];
try {
$tenantId = m4_fetch_tenant_id($pdo, $tenantSlug);
$missingPayments = m4_count_missing_payment_participants($pdo, $tenantId);
$missingConsumptions = m4_count_missing_consumption_participants($pdo, $tenantId);
if ($missingPayments > 0 || $missingConsumptions > 0) {
throw new RuntimeException(
"Missing participant mappings for {$missingPayments} payment(s) and {$missingConsumptions} consumption(s)."
);
}
$pdo->beginTransaction();
$removeStalePayments = $pdo->prepare(
"DELETE le
FROM ledger_entries le
LEFT JOIN kl_Einzahlungen e
ON e.EinzahlungsID = le.legacy_id
WHERE le.tenant_id = ?
AND le.legacy_table = 'kl_Einzahlungen'
AND e.EinzahlungsID IS NULL"
);
$removeStalePayments->execute([$tenantId]);
$stats['stale_payments_removed'] = $removeStalePayments->rowCount();
$removeStaleConsumptions = $pdo->prepare(
"DELETE le
FROM ledger_entries le
LEFT JOIN kl_Kaffeeverbrauch v
ON v.VerbrauchID = le.legacy_id
WHERE le.tenant_id = ?
AND le.legacy_table = 'kl_Kaffeeverbrauch'
AND v.VerbrauchID IS NULL"
);
$removeStaleConsumptions->execute([$tenantId]);
$stats['stale_consumptions_removed'] = $removeStaleConsumptions->rowCount();
$insertPayments = $pdo->prepare(
"INSERT INTO ledger_entries
(tenant_id, participant_id, type, amount_cents, marks_count,
unit_price_cents, booked_at, source, note, legacy_table, legacy_id)
SELECT
p.tenant_id,
p.id,
'payment',
CAST(ROUND(e.Betrag * 100) AS SIGNED),
NULL,
NULL,
e.Datum,
'legacy_payment',
NULL,
'kl_Einzahlungen',
e.EinzahlungsID
FROM kl_Einzahlungen e
JOIN participants p
ON p.tenant_id = ?
AND p.legacy_mitarbeiter_id = e.MitarbeiterID
ON DUPLICATE KEY UPDATE
participant_id = VALUES(participant_id),
type = VALUES(type),
amount_cents = VALUES(amount_cents),
marks_count = VALUES(marks_count),
unit_price_cents = VALUES(unit_price_cents),
booked_at = VALUES(booked_at),
source = VALUES(source),
note = VALUES(note)"
);
$insertPayments->execute([$tenantId]);
$insertConsumptions = $pdo->prepare(
"INSERT INTO ledger_entries
(tenant_id, participant_id, type, amount_cents, marks_count,
unit_price_cents, booked_at, source, note, legacy_table, legacy_id)
SELECT
p.tenant_id,
p.id,
'consumption',
-CAST(ROUND(v.Kosten * 100) AS SIGNED),
v.AnzahlStriche,
CAST(ROUND(v.KostenproStrich * 100) AS SIGNED),
v.Datum,
CASE
WHEN v.Eintragsart = 2 THEN 'legacy_web'
ELSE 'legacy_manual'
END,
CASE
WHEN v.Eintragsart IS NULL THEN NULL
ELSE CONCAT('Legacy Eintragsart: ', v.Eintragsart)
END,
'kl_Kaffeeverbrauch',
v.VerbrauchID
FROM kl_Kaffeeverbrauch v
JOIN participants p
ON p.tenant_id = ?
AND p.legacy_mitarbeiter_id = v.MitarbeiterID
ON DUPLICATE KEY UPDATE
participant_id = VALUES(participant_id),
type = VALUES(type),
amount_cents = VALUES(amount_cents),
marks_count = VALUES(marks_count),
unit_price_cents = VALUES(unit_price_cents),
booked_at = VALUES(booked_at),
source = VALUES(source),
note = VALUES(note)"
);
$insertConsumptions->execute([$tenantId]);
$stmt = $pdo->prepare(
"SELECT COUNT(*)
FROM ledger_entries
WHERE tenant_id = ?
AND legacy_table = 'kl_Einzahlungen'"
);
$stmt->execute([$tenantId]);
$stats['payments_mirrored'] = (int)$stmt->fetchColumn();
$stmt = $pdo->prepare(
"SELECT COUNT(*)
FROM ledger_entries
WHERE tenant_id = ?
AND legacy_table = 'kl_Kaffeeverbrauch'"
);
$stmt->execute([$tenantId]);
$stats['consumptions_mirrored'] = (int)$stmt->fetchColumn();
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
fwrite(STDERR, "M4 ledger backfill failed: {$e->getMessage()}\n");
exit(1);
}
echo "M4 ledger backfill complete.\n";
echo "Tenant: {$tenantSlug}\n";
echo "Applied migrations: {$stats['applied_migrations']}\n";
echo "Stale payments removed: {$stats['stale_payments_removed']}\n";
echo "Stale consumptions removed: {$stats['stale_consumptions_removed']}\n";
echo "Payments mirrored: {$stats['payments_mirrored']}\n";
echo "Consumptions mirrored: {$stats['consumptions_mirrored']}\n";
+209
View File
@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
require __DIR__ . '/dev-db.php';
require __DIR__ . '/golden-master-data.php';
function m4_check_env(string $name, string $default): string
{
$value = getenv($name);
if ($value === false || trim($value) === '') {
return $default;
}
return trim($value);
}
function m4_check_assert_equal(string $label, mixed $expected, mixed $actual, array &$failures, int &$passes): void
{
if ($expected === $actual) {
$passes++;
echo "PASS {$label}\n";
return;
}
$failures[] = "{$label}: expected " . var_export($expected, true) . ', got ' . var_export($actual, true);
echo "FAIL {$label}\n";
}
function m4_check_fetch_tenant_id(PDO $pdo, string $slug): int
{
$stmt = $pdo->prepare('SELECT id FROM tenants WHERE slug = ?');
$stmt->execute([$slug]);
$tenantId = $stmt->fetchColumn();
if ($tenantId === false) {
throw new RuntimeException("Tenant '{$slug}' not found.");
}
return (int)$tenantId;
}
/**
* @return array<string, array{total_payments: int, total_costs: int, total_marks: int, balance: int, year_payments: int, year_costs: int, year_marks: int}>
*/
function m4_check_fetch_ledger_summaries(PDO $pdo, int $tenantId, array $emails): array
{
$placeholders = implode(',', array_fill(0, count($emails), '?'));
$year = gm_current_year();
$sql = "
SELECT
p.email_norm AS email,
COALESCE(SUM(CASE WHEN le.type = 'payment' THEN le.amount_cents ELSE 0 END), 0) AS total_payments,
COALESCE(SUM(CASE WHEN le.type = 'consumption' THEN -le.amount_cents ELSE 0 END), 0) AS total_costs,
COALESCE(SUM(CASE WHEN le.type = 'consumption' THEN le.marks_count ELSE 0 END), 0) AS total_marks,
COALESCE(SUM(le.amount_cents), 0) AS balance,
COALESCE(SUM(CASE WHEN le.type = 'payment' AND YEAR(le.booked_at) = ? THEN le.amount_cents ELSE 0 END), 0) AS year_payments,
COALESCE(SUM(CASE WHEN le.type = 'consumption' AND YEAR(le.booked_at) = ? THEN -le.amount_cents ELSE 0 END), 0) AS year_costs,
COALESCE(SUM(CASE WHEN le.type = 'consumption' AND YEAR(le.booked_at) = ? THEN le.marks_count ELSE 0 END), 0) AS year_marks
FROM participants p
LEFT JOIN ledger_entries le
ON le.tenant_id = p.tenant_id
AND le.participant_id = p.id
AND le.voided_at IS NULL
WHERE p.tenant_id = ?
AND p.email_norm IN ({$placeholders})
GROUP BY p.id, p.email_norm
";
$stmt = $pdo->prepare($sql);
$stmt->execute(array_merge([$year, $year, $year, $tenantId], $emails));
$summaries = [];
while ($row = $stmt->fetch()) {
$summaries[(string)$row['email']] = [
'total_payments' => (int)$row['total_payments'],
'total_costs' => (int)$row['total_costs'],
'total_marks' => (int)$row['total_marks'],
'balance' => (int)$row['balance'],
'year_payments' => (int)$row['year_payments'],
'year_costs' => (int)$row['year_costs'],
'year_marks' => (int)$row['year_marks'],
];
}
return $summaries;
}
$pdo = dev_pdo();
$tenantSlug = m4_check_env('M4_DEFAULT_TENANT_SLUG', m4_check_env('M3_DEFAULT_TENANT_SLUG', 'default'));
$tenantId = m4_check_fetch_tenant_id($pdo, $tenantSlug);
$expected = gm_expected_summaries();
$emails = array_keys($expected);
$failures = [];
$passes = 0;
$legacyPaymentCount = (int)$pdo->query('SELECT COUNT(*) FROM kl_Einzahlungen')->fetchColumn();
$legacyConsumptionCount = (int)$pdo->query('SELECT COUNT(*) FROM kl_Kaffeeverbrauch')->fetchColumn();
$stmt = $pdo->prepare(
"SELECT COUNT(*)
FROM ledger_entries
WHERE tenant_id = ?
AND legacy_table IN ('kl_Einzahlungen', 'kl_Kaffeeverbrauch')"
);
$stmt->execute([$tenantId]);
$ledgerLegacyCount = (int)$stmt->fetchColumn();
m4_check_assert_equal(
'legacy ledger row count matches source rows',
$legacyPaymentCount + $legacyConsumptionCount,
$ledgerLegacyCount,
$failures,
$passes
);
$stmt = $pdo->prepare(
"SELECT
COUNT(*) AS total_rows,
COUNT(DISTINCT CONCAT(legacy_table, '#', legacy_id)) AS distinct_legacy_rows
FROM ledger_entries
WHERE tenant_id = ?
AND legacy_table IN ('kl_Einzahlungen', 'kl_Kaffeeverbrauch')"
);
$stmt->execute([$tenantId]);
$legacyDistinct = $stmt->fetch();
m4_check_assert_equal(
'legacy ledger ids are unique',
(int)$legacyDistinct['total_rows'],
(int)$legacyDistinct['distinct_legacy_rows'],
$failures,
$passes
);
$stmt = $pdo->prepare(
"SELECT COUNT(*)
FROM ledger_entries le
LEFT JOIN kl_Einzahlungen e ON e.EinzahlungsID = le.legacy_id
WHERE le.tenant_id = ?
AND le.legacy_table = 'kl_Einzahlungen'
AND e.EinzahlungsID IS NULL"
);
$stmt->execute([$tenantId]);
m4_check_assert_equal('no stale payment ledger rows', 0, (int)$stmt->fetchColumn(), $failures, $passes);
$stmt = $pdo->prepare(
"SELECT COUNT(*)
FROM ledger_entries le
LEFT JOIN kl_Kaffeeverbrauch v ON v.VerbrauchID = le.legacy_id
WHERE le.tenant_id = ?
AND le.legacy_table = 'kl_Kaffeeverbrauch'
AND v.VerbrauchID IS NULL"
);
$stmt->execute([$tenantId]);
m4_check_assert_equal('no stale consumption ledger rows', 0, (int)$stmt->fetchColumn(), $failures, $passes);
$stmt = $pdo->prepare("SELECT COUNT(*) FROM ledger_entries WHERE tenant_id = ? AND type = 'payment' AND amount_cents <= 0");
$stmt->execute([$tenantId]);
m4_check_assert_equal('payment amounts are positive', 0, (int)$stmt->fetchColumn(), $failures, $passes);
$stmt = $pdo->prepare("SELECT COUNT(*) FROM ledger_entries WHERE tenant_id = ? AND type = 'consumption' AND amount_cents >= 0");
$stmt->execute([$tenantId]);
m4_check_assert_equal('consumption amounts are negative', 0, (int)$stmt->fetchColumn(), $failures, $passes);
$stmt = $pdo->prepare(
"SELECT COUNT(*)
FROM ledger_entries
WHERE tenant_id = ?
AND type = 'consumption'
AND (marks_count IS NULL OR unit_price_cents IS NULL)"
);
$stmt->execute([$tenantId]);
m4_check_assert_equal('consumption rows keep marks and unit price', 0, (int)$stmt->fetchColumn(), $failures, $passes);
$webLegacyCount = (int)$pdo
->query('SELECT COUNT(*) FROM kl_Kaffeeverbrauch WHERE Eintragsart = 2')
->fetchColumn();
$stmt = $pdo->prepare(
"SELECT COUNT(*)
FROM ledger_entries
WHERE tenant_id = ?
AND legacy_table = 'kl_Kaffeeverbrauch'
AND source = 'legacy_web'"
);
$stmt->execute([$tenantId]);
m4_check_assert_equal('web consumption source is preserved', $webLegacyCount, (int)$stmt->fetchColumn(), $failures, $passes);
$actualSummaries = m4_check_fetch_ledger_summaries($pdo, $tenantId, $emails);
m4_check_assert_equal('ledger member summary count', count($expected), count($actualSummaries), $failures, $passes);
foreach ($expected as $email => $summary) {
m4_check_assert_equal("ledger summary exists {$email}", true, isset($actualSummaries[$email]), $failures, $passes);
if (!isset($actualSummaries[$email])) {
continue;
}
foreach ($summary as $key => $expectedValue) {
m4_check_assert_equal("ledger {$email} {$key}", $expectedValue, $actualSummaries[$email][$key], $failures, $passes);
}
}
if ($failures !== []) {
echo "\nM4 ledger migration check failed with " . count($failures) . " failure(s):\n";
foreach ($failures as $failure) {
echo "- {$failure}\n";
}
exit(1);
}
echo "\nM4 ledger migration check passed with {$passes} assertions.\n";