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:
@@ -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;
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -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";
|
||||||
Reference in New Issue
Block a user