From e4b0c81b48179c0ef97f42bd308db8c1621e4496 Mon Sep 17 00:00:00 2001 From: Clemens Creutzburg Date: Mon, 13 Jul 2026 20:45:28 +0200 Subject: [PATCH] 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 --- .../migrations/0006_saas_ledger_entries.sql | 38 ++++ docs/m3-saas-basis-vorbereitung.md | 16 +- docs/m4-data-migration.md | 150 +++++++++++++ docs/saas-umstrukturierungsplan.md | 28 ++- scripts/backfill-ledger-entries.php | 212 ++++++++++++++++++ scripts/check-m4-ledger-migration.php | 209 +++++++++++++++++ 6 files changed, 640 insertions(+), 13 deletions(-) create mode 100644 database/migrations/0006_saas_ledger_entries.sql create mode 100644 docs/m4-data-migration.md create mode 100644 scripts/backfill-ledger-entries.php create mode 100644 scripts/check-m4-ledger-migration.php diff --git a/database/migrations/0006_saas_ledger_entries.sql b/database/migrations/0006_saas_ledger_entries.sql new file mode 100644 index 0000000..24ca06d --- /dev/null +++ b/database/migrations/0006_saas_ledger_entries.sql @@ -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; diff --git a/docs/m3-saas-basis-vorbereitung.md b/docs/m3-saas-basis-vorbereitung.md index c2db179..b4659bf 100644 --- a/docs/m3-saas-basis-vorbereitung.md +++ b/docs/m3-saas-basis-vorbereitung.md @@ -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 das neue Modell umgestellt. +Status: abgeschlossen fuer den M3-Scope. M4 startet mit der additiven +Migration von Einzahlungen und Kaffeeverbrauch nach `ledger_entries`. + ## Ziel - Einen Default-Tenant fuer den aktuellen Bestand erzeugen. @@ -272,5 +275,14 @@ Umgesetzter Umfang: 16. Golden-Master und HTTP-Smoke ausfuehren: erledigt. 17. Mail-Transport-Abstraktion und Mail-Flow-Kontrollskript bauen: erledigt. 18. Public-Landingpage mit erstem Hero-Asset vorbereiten: erledigt. -19. M3-Abschlusscheck dokumentieren und M4-Datenmigration starten: - naechster Schritt. +19. M3-Abschlusscheck dokumentieren und M4-Datenmigration starten: erledigt. + +## 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. diff --git a/docs/m4-data-migration.md b/docs/m4-data-migration.md new file mode 100644 index 0000000..322e818 --- /dev/null +++ b/docs/m4-data-migration.md @@ -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. diff --git a/docs/saas-umstrukturierungsplan.md b/docs/saas-umstrukturierungsplan.md index 10dcf67..775e542 100644 --- a/docs/saas-umstrukturierungsplan.md +++ b/docs/saas-umstrukturierungsplan.md @@ -283,8 +283,8 @@ Uebersicht: | M0 | Baseline | Bestand, Sicherheit und Designreferenz sind dokumentiert | | 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 | -| M3 | SaaS-Basis | Tenants, User, Registrierung, Login, Rollen, Mail-Links und zentrale Mandantenauswahl funktionieren | -| M4 | Datenmigration | Legacy-Daten sind tenant-sicher im Zielmodell abgebildet | +| M3 | SaaS-Basis | Abgeschlossen: Tenants, User, Registrierung, Login, Rollen, Mail-Links und zentrale Mandantenauswahl funktionieren | +| M4 | Datenmigration | Gestartet: Ledger-Tabelle, Legacy-Backfill und Paritaetscheck sind umgesetzt | | M5 | App-Kern | Dashboard, Striche, Einzahlungen, Mitglieder und Liste laufen | | M6 | Betriebsflows | Import, Export, Mail und Jahresprozesse sind auditierbar | | M7 | Landingpage | Erste werbliche Seite ist oeffentlich nutzbar; spaetere Ausbaustufen folgen | @@ -434,6 +434,7 @@ Ergebnis: - Rollen und Tenant-Kontext sind serverseitig verfuegbar. - Reset- und Verifikationslinks koennen im Dev-Modus geloggt und produktiv ueber einen konfigurierten Mail-Transport versendet werden. +- Status: abgeschlossen fuer den M3-Scope. Abhaengigkeiten: @@ -446,18 +447,23 @@ Bestehende Kaffeelisten-Daten tenant-sicher uebernehmen. Schritte: -- Initialen Tenant fuer den bisherigen Bestand anlegen. -- `kl_Mitarbeiter` nach `participants` migrieren. -- `admin`-Informationen in `tenant_memberships` oder Admin-Rollen ueberfuehren. -- `kl_config` nach `tenant_settings` migrieren. -- `kl_Einzahlungen` und `kl_Kaffeeverbrauch` nach `ledger_entries` migrieren. -- Legacy-IDs speichern. -- Salden gegen Golden-Master vergleichen. +- Initialen Tenant fuer den bisherigen Bestand anlegen: erledigt in M3. +- `kl_Mitarbeiter` nach `participants` migrieren: erledigt in M3. +- `admin`-Informationen in `tenant_memberships` oder Admin-Rollen ueberfuehren: + erledigt in M3. +- `kl_config` nach `tenant_settings` migrieren: erledigt in M3. +- `ledger_entries` als tenant-sichere Buchungstabelle anlegen: erledigt. +- `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: -- Bestehender Datenbestand ist im neuen Modell abgebildet. -- Saldenparitaet ist nachweisbar. +- Bestehender Datenbestand kann im neuen Modell abgebildet werden. +- Saldenparitaet ist ueber `scripts/check-m4-ledger-migration.php` + nachweisbar. +- Dokumentation: `docs/m4-data-migration.md`. Abhaengigkeiten: diff --git a/scripts/backfill-ledger-entries.php b/scripts/backfill-ledger-entries.php new file mode 100644 index 0000000..4f164f8 --- /dev/null +++ b/scripts/backfill-ledger-entries.php @@ -0,0 +1,212 @@ +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"; diff --git a/scripts/check-m4-ledger-migration.php b/scripts/check-m4-ledger-migration.php new file mode 100644 index 0000000..b2aab59 --- /dev/null +++ b/scripts/check-m4-ledger-migration.php @@ -0,0 +1,209 @@ +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 + */ +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";