diff --git a/README.md b/README.md index 9117cba..267a2b5 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,36 @@ -# Legacy App Archiv +# Kaffeeliste -Dieses Verzeichnis enthält den bisherigen PHP-Bestand der Kaffeeliste. +Dieses Repository enthält den laufenden Umbau der Kaffeelisten-App von einer +Windows/IIS/LDAP-gebundenen Legacy-App zu einer mehrkundenfähigen SaaS- +Anwendung. Der Umbau erfolgt schrittweise im selben Repository, nicht in +einem getrennten Zielverzeichnis: Legacy-Seiten und neue SaaS-Bausteine +liegen nebeneinander, bis eine Seite vollständig auf das neue Modell +umgestellt ist. -Es bleibt aus drei Gründen im Repository: +Den vollständigen Plan mit Zielbild, Datenmodell, Rollenmodell und +Meilensteinen beschreibt `docs/saas-umstrukturierungsplan.md`. Der +Fortschritt je Meilenstein steht in `docs/m2-technical-foundation.md` bis +`docs/m5-app-kern.md`. -- als Referenz für die bestehende Fachlogik -- als Quelle für Datenmigrationen in die SaaS-Version -- als Rückfalloption während der Übergangsphase +## Struktur -## Wichtige Legacy-Bereiche +- Legacy- und SaaS-Seiten liegen als flache PHP-Dateien im Webroot, zum + Beispiel `index.php`, `stricheintragen.php`, `kaffeeliste.php`, + `mitarbeiterverwalten.php`. +- `app/`: zentrale Bausteine für Bootstrap, DB-Zugriff, Auth, Mail und das + neue Ledger-Modell (`app/ledger.php`). +- `database/migrations/`: versionierte Schemaänderungen, anzuwenden über + `scripts/migrate.php`. +- `scripts/`: Migrations-, Backfill- und Prüfskripte (Golden Master, + HTTP-Smoke, M3/M4-Checks). +- `docs/`: Planungs- und Meilensteindokumentation. -- `index.php`: persönliches Dashboard -- `stricheintragen.php`: Sammelerfassung für Kaffee-Striche -- `einzahlung.php`: Sammelerfassung für Einzahlungen -- `kaffeeliste.php`: operative Gesamtübersicht -- `mitarbeiterverwalten.php`: Mitglieder- und Rollenpflege -- `letzteneintraege.php`: Korrektur letzter Buchungen -- `hinweise.php`: Banner/Hinweise +## Umgang mit Legacy-Seiten -## Umgang Mit Dem Archiv - -- Keine neuen Produktfunktionen mehr hier entwickeln. -- Nur noch für Referenz, Datenabgleich oder Notfallbetrieb verwenden. -- Neue Arbeit findet ausschließlich in `../saas-app/` und `../docs/` statt. +- Seiten, die noch direkt auf `kl_*`-Tabellen schreiben, werden schrittweise + auf tenant-sicheres Lesen/Schreiben über `app/ledger.php` und + `app/saas-auth.php` umgestellt; siehe die Meilensteindokumente für den + aktuellen Stand je Seite. +- Neue Produktfunktionen entstehen gegen das neue Modell (Tenants, Users, + Participants, Ledger), nicht mehr direkt gegen die `kl_*`-Tabellen. +- Lokale Entwicklung gegen MySQL ist in `docs/dev-mysql.md` beschrieben. diff --git a/app/ledger.php b/app/ledger.php index 5868954..e6d8b86 100644 --- a/app/ledger.php +++ b/app/ledger.php @@ -319,6 +319,74 @@ function ledger_mirror_legacy_consumption(PDO $pdo, int $tenantId, int $legacyCo } } +function ledger_mirror_legacy_payment(PDO $pdo, int $tenantId, int $legacyPaymentId): void +{ + $stmt = $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 + WHERE e.EinzahlungsID = ? + 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)" + ); + $stmt->execute([$tenantId, $legacyPaymentId]); + + $check = $pdo->prepare( + "SELECT COUNT(*) + FROM ledger_entries + WHERE tenant_id = ? + AND legacy_table = 'kl_Einzahlungen' + AND legacy_id = ?" + ); + $check->execute([$tenantId, $legacyPaymentId]); + if ((int)$check->fetchColumn() !== 1) { + throw new RuntimeException('Die Legacy-Einzahlung konnte nicht ins Ledger gespiegelt werden.'); + } +} + +/** + * Marks the ledger entry mirrored from a legacy row as voided instead of + * deleting it, so corrections stay auditable. Idempotent: voiding an + * already-voided or missing entry is a no-op and returns false. + */ +function ledger_void_entry_by_legacy_id(PDO $pdo, int $tenantId, string $legacyTable, int $legacyId): bool +{ + $stmt = $pdo->prepare( + "UPDATE ledger_entries + SET voided_at = NOW() + WHERE tenant_id = ? + AND legacy_table = ? + AND legacy_id = ? + AND voided_at IS NULL" + ); + $stmt->execute([$tenantId, $legacyTable, $legacyId]); + + return $stmt->rowCount() > 0; +} + /** * Options: * - participant_id: int diff --git a/docs/m5-app-kern.md b/docs/m5-app-kern.md index 5c9038d..caafb00 100644 --- a/docs/m5-app-kern.md +++ b/docs/m5-app-kern.md @@ -1,6 +1,6 @@ # M5 App-Kern -Stand: 2026-07-14 +Stand: 2026-07-14 (fortgeführt) M5 stellt die operativen App-Seiten schrittweise auf das neue tenant-sichere Modell um. Der Start erfolgt bewusst read-only, damit Summen, Links und @@ -67,15 +67,80 @@ Umgesetzter Umfang `index.php`: - Eigene Web-Striche bleiben mit dem bestehenden Legacy-Datensatz kompatibel und werden direkt ins Ledger gespiegelt. +## Fortsetzung: Schreibseiten und Storno + +Umgesetzte Dateien: + +```text +stricheintragen.php +einzahlung.php +letzteneintraege.php +app/ledger.php +scripts/check-m4-ledger-migration.php +``` + +Umgesetzter Umfang `stricheintragen.php` und `einzahlung.php`: + +- Beide Sammelerfassungsseiten waren zuvor ohne jede Zugriffsprüfung + erreichbar (nur CSRF-Schutz, kein `checkKaffeelisteAdmin`- oder Rollen-Check). + Das ist jetzt behoben: Zugriff erfordert SaaS-Rolle `owner`, `admin` oder + `treasurer`, oder im Legacy-/Dev-Fallback `checkKaffeelisteAdmin` mit dem + Default-Tenant. +- Jede eingetragene Legacy-Zeile (`kl_Kaffeeverbrauch` beziehungsweise + `kl_Einzahlungen`) wird innerhalb derselben Transaktion sofort über + `ledger_mirror_legacy_consumption()` beziehungsweise die neue + `ledger_mirror_legacy_payment()` ins Ledger gespiegelt. Schlägt die + Spiegelung fehl, wird die gesamte Sammelerfassung zurückgerollt statt + teilweise gespeichert zu werden. +- Ein vorbestehender Bug wurde nebenbei behoben: Bei einer POST-Anfrage blieb + `$sqlMitarbeiter` unbelegt, was einen PHP-Warning erzeugte und die + Mitarbeiterliste nach dem Speichern leer ließ. + +Umgesetzter Umfang `letzteneintraege.php`: + +- Der Seitenzugriff nutzte bisher ausschließlich den Legacy-Check + `checkKaffeelisteAdmin`, wodurch neu registrierte SaaS-Mandanten (ohne + Legacy-`kl_Mitarbeiter`-Zeile) die Seite nie hätten nutzen können. Jetzt gilt + dieselbe Rollen-/Fallback-Logik wie in `kaffeeliste.php`. +- Löschen erzeugt keinen harten Delete im Ledger mehr. Neue Funktion + `ledger_void_entry_by_legacy_id()` setzt `voided_at` auf dem gespiegelten + Ledger-Eintrag, bevor die Legacy-Zeile aus `kl_Einzahlungen` oder + `kl_Kaffeeverbrauch` entfernt wird (in einer Transaktion). Der Ledger-Eintrag + bleibt damit für die Revision erhalten; alle Lesepfade filtern bereits + konsistent auf `voided_at IS NULL`. +- Zwei nie aufgerufene Funktionen (`berechneGesamtausgabe`, + `berechneGesamtstriche`, `berechneGesamteinzahlungen`) wurden als toten Code + entfernt. + +Angepasstes Check-Skript: + +- `scripts/check-m4-ledger-migration.php` ging bisher davon aus, dass jeder + gespiegelte Ledger-Eintrag eine noch existierende Legacy-Zeile hat. Das ist + durch das Storno-Modell nicht mehr korrekt: Ein stornierter Eintrag hat + absichtlich keine Legacy-Zeile mehr, bleibt aber im Ledger stehen. Die + Prüfungen filtern jetzt zusätzlich auf `voided_at IS NULL`, sodass echte + Dateninkonsistenzen weiterhin erkannt werden, stornierte Einträge aber nicht + mehr als Fehler zählen. + +Alle drei Flows wurden gegen die Remote-Dev-Datenbank live per HTTP getestet +(Sammeleintrag Striche, Sammeleinzahlung, Storno beider Buchungsarten über +`letzteneintraege.php`) und anschließend wieder auf den Ausgangsstand +zurückgesetzt. + ## Noch offen -- Schreibseiten `stricheintragen.php` und `einzahlung.php` erst danach mit - Storno-/Reversal-Strategie vorbereiten. +- Eigene PayPal-/Zahlungsbereich als eigenständiger App-Screen (aktuell nur im + Dashboard integriert). +- Mitgliederverwaltung tenant- und rollenbasiert umsetzen + (`mitarbeiterverwalten.php` ist noch nicht angefasst). +- Hinweise als tenant-spezifische Notices umsetzen. - Export, Mail und Jahresprozesse bleiben M6-Themen. ## Aktueller Prüfstatus -- M4 Ledger-Migration: grün mit 73 Assertions. +- M4 Ledger-Migration: grün mit 73 Assertions (Storno-fähig). - M4 Ledger-Service: grün mit 115 Assertions. - Golden Master: grün mit 104 Assertions. - HTTP-Smoke: grün mit 23 geprüften Seiten. +- Live-Test der Schreibflows gegen die Dev-DB: Sammelstriche, Sammeleinzahlung + und Storno beider Buchungsarten erfolgreich geprüft. diff --git a/einzahlung.php b/einzahlung.php index 9d5eab0..9b46969 100644 --- a/einzahlung.php +++ b/einzahlung.php @@ -1,6 +1,7 @@ Kein Zugriff"; + include "footer.php"; + exit; +} // Verarbeitung des Formulars, wenn es gesendet wurde if ($_SERVER["REQUEST_METHOD"] == "POST" ) { - // Iteriere über alle Mitarbeiter - foreach ($_POST["anzahlBetrag"] as $mitarbeiterID => $anzahlBetrag) { - // Validierung könnte hier hinzugefügt werden - #$anzahlBetrag = $anzahlBetrag; - $anzahlBetrag = floatval($anzahlBetrag); - - if($anzahlBetrag != 0){ - $datum = date("Y-m-d H:i:s"); // Das aktuelle Datum verwenden + $datum = date("Y-m-d H:i:s"); // Das aktuelle Datum verwenden + $eingetragen = 0; + $fehlgeschlagen = false; - // SQL-Abfrage zum Einfügen der Daten - $sql = "INSERT INTO kl_Einzahlungen (MitarbeiterID, Betrag, Datum) VALUES (?, ?, ?)"; - $params = array($mitarbeiterID, $anzahlBetrag, $datum); + try { + $pdo->beginTransaction(); + $insert = $pdo->prepare( + "INSERT INTO kl_Einzahlungen (MitarbeiterID, Betrag, Datum) VALUES (?, ?, ?)" + ); - $stmt = sqlsrv_query($conn, $sql, $params); - - if ($stmt === false) { - die(print_r(sqlsrv_errors(), true)); + foreach ($_POST["anzahlBetrag"] ?? [] as $mitarbeiterID => $anzahlBetrag) { + $mitarbeiterID = (int)$mitarbeiterID; + $anzahlBetrag = floatval($anzahlBetrag); + if ($mitarbeiterID <= 0 || $anzahlBetrag == 0.0) { + continue; } - } - } - echo "Einträge erfolgreich hinzugefügt."; + $insert->execute([$mitarbeiterID, $anzahlBetrag, $datum]); + $legacyPaymentId = (int)$pdo->lastInsertId(); + ledger_mirror_legacy_payment($pdo, $tenantId, $legacyPaymentId); + $eingetragen++; + } + + $pdo->commit(); + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + $fehlgeschlagen = true; + } + + if ($fehlgeschlagen) { + echo "Die Einzahlungen konnten nicht gespeichert werden."; + } else { + echo $eingetragen . " Einträge erfolgreich hinzugefügt."; + } + + // Mitarbeiter aus der Datenbank abrufen und nach Namen sortieren + $sqlMitarbeiter = "SELECT MitarbeiterID, Name FROM kl_Mitarbeiter WHERE aktiv = 1 ORDER BY Name"; }elseif($_SERVER["REQUEST_METHOD"] == "GET" AND isset($_GET["action"])){ if($_GET["action"] == "vorderseite"){ diff --git a/letzteneintraege.php b/letzteneintraege.php index 5e7082a..fc48ca6 100644 --- a/letzteneintraege.php +++ b/letzteneintraege.php @@ -1,6 +1,7 @@ beginTransaction(); - $stmt = sqlsrv_query($conn, $sql, $params); + ledger_void_entry_by_legacy_id($pdo, $tenantId, 'kl_Einzahlungen', $einzahlungID); - if ($stmt === false) { - throw new Exception(print_r(sqlsrv_errors(), true)); - } + $stmt = $pdo->prepare('DELETE FROM kl_Einzahlungen WHERE EinzahlungsID = ?'); + $stmt->execute([$einzahlungID]); + + $pdo->commit(); return true; // Erfolgreich gelöscht - } catch (Exception $e) { - return $e->getMessage(); // Fehlermeldung zurückgeben + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + return 'Die Einzahlung konnte nicht gelöscht werden.'; } } // Überprüfen, ob ein Löschvorgang angefordert wurde if ($_SERVER["REQUEST_METHOD"] == "POST" && isset($_POST["aktion"]) && $_POST["aktion"] == "loescheneinzahlung") { - $einzahlungID = $_POST["einzahlungID"]; + $einzahlungID = (int)$_POST["einzahlungID"]; - $ergebnis = loescheEinzahlung($einzahlungID, $conn); + $ergebnis = loescheEinzahlung($einzahlungID, $pdo, $tenantId); if ($ergebnis === true) { echo "Einzahlung erfolgreich gelöscht."; @@ -136,29 +127,34 @@ $stmtEinzahlungen = sqlsrv_query($conn, $sqlEinzahlungen);

beginTransaction(); - $stmt = sqlsrv_query($conn, $sql, $params); + ledger_void_entry_by_legacy_id($pdo, $tenantId, 'kl_Kaffeeverbrauch', $strichID); - if ($stmt === false) { - throw new Exception(print_r(sqlsrv_errors(), true)); - } + $stmt = $pdo->prepare('DELETE FROM kl_Kaffeeverbrauch WHERE VerbrauchID = ?'); + $stmt->execute([$strichID]); + + $pdo->commit(); return true; // Erfolgreich gelöscht - } catch (Exception $e) { - return $e->getMessage(); // Fehlermeldung zurückgeben + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + return 'Der Strich-Eintrag konnte nicht gelöscht werden.'; } } // Überprüfen, ob ein Löschvorgang angefordert wurde if ($_SERVER["REQUEST_METHOD"] == "POST" && isset($_POST["aktion"]) && $_POST["aktion"] == "loeschen") { - $strichID = $_POST["strichID"]; + $strichID = (int)$_POST["strichID"]; - $ergebnis = loescheStrichEintrag($strichID, $conn); + $ergebnis = loescheStrichEintrag($strichID, $pdo, $tenantId); if ($ergebnis === true) { echo "Strich-Eintrag erfolgreich gelöscht."; diff --git a/scripts/check-m4-ledger-migration.php b/scripts/check-m4-ledger-migration.php index b2aab59..b06d644 100644 --- a/scripts/check-m4-ledger-migration.php +++ b/scripts/check-m4-ledger-migration.php @@ -101,12 +101,13 @@ $stmt = $pdo->prepare( "SELECT COUNT(*) FROM ledger_entries WHERE tenant_id = ? - AND legacy_table IN ('kl_Einzahlungen', 'kl_Kaffeeverbrauch')" + AND legacy_table IN ('kl_Einzahlungen', 'kl_Kaffeeverbrauch') + AND voided_at IS NULL" ); $stmt->execute([$tenantId]); $ledgerLegacyCount = (int)$stmt->fetchColumn(); m4_check_assert_equal( - 'legacy ledger row count matches source rows', + 'active legacy ledger row count matches source rows', $legacyPaymentCount + $legacyConsumptionCount, $ledgerLegacyCount, $failures, @@ -131,16 +132,20 @@ m4_check_assert_equal( $passes ); +// Ledger rows without a matching legacy row are only expected when they were +// voided through the storno flow in letzteneintraege.php. A missing legacy row +// on a non-voided ledger entry indicates real drift between the tables. $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 le.voided_at IS NULL AND e.EinzahlungsID IS NULL" ); $stmt->execute([$tenantId]); -m4_check_assert_equal('no stale payment ledger rows', 0, (int)$stmt->fetchColumn(), $failures, $passes); +m4_check_assert_equal('no stale non-voided payment ledger rows', 0, (int)$stmt->fetchColumn(), $failures, $passes); $stmt = $pdo->prepare( "SELECT COUNT(*) @@ -148,10 +153,11 @@ $stmt = $pdo->prepare( LEFT JOIN kl_Kaffeeverbrauch v ON v.VerbrauchID = le.legacy_id WHERE le.tenant_id = ? AND le.legacy_table = 'kl_Kaffeeverbrauch' + AND le.voided_at IS NULL AND v.VerbrauchID IS NULL" ); $stmt->execute([$tenantId]); -m4_check_assert_equal('no stale consumption ledger rows', 0, (int)$stmt->fetchColumn(), $failures, $passes); +m4_check_assert_equal('no stale non-voided 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]); diff --git a/stricheintragen.php b/stricheintragen.php index 6792707..ccb4f37 100644 --- a/stricheintragen.php +++ b/stricheintragen.php @@ -1,6 +1,7 @@ Kein Zugriff"; + include "footer.php"; + exit; +} // Verarbeitung des Formulars, wenn es gesendet wurde if ($_SERVER["REQUEST_METHOD"] == "POST") { - // Kosten pro Strich übernehmen: - $kostenproStrich = $_POST["kostenproStrich"]; - - // Iteriere über alle Mitarbeiter - foreach ($_POST["anzahlStriche"] as $mitarbeiterID => $anzahlStriche) { - // Validierung könnte hier hinzugefügt werden - $anzahlStriche = (int)$anzahlStriche; - $kostenproStrich = floatval($kostenproStrich); - $kosten = floatval($anzahlStriche * $kostenproStrich); - $datum = date("Y-m-d H:i:s"); // Das aktuelle Datum verwenden - if($anzahlStriche != 0){ - // SQL-Abfrage zum Einfügen der Daten - $sql = "INSERT INTO kl_Kaffeeverbrauch (MitarbeiterID, AnzahlStriche, Kosten, KostenproStrich, Datum) VALUES (?, ?, ?, ?, ?)"; - $params = array($mitarbeiterID, $anzahlStriche, $kosten, $kostenproStrich, $datum); + $kostenproStrich = floatval($_POST["kostenproStrich"] ?? 0); + $datum = date("Y-m-d H:i:s"); // Das aktuelle Datum verwenden + $eingetragen = 0; + $fehlgeschlagen = false; - $stmt = sqlsrv_query($conn, $sql, $params); + try { + $pdo->beginTransaction(); + $insert = $pdo->prepare( + "INSERT INTO kl_Kaffeeverbrauch (MitarbeiterID, AnzahlStriche, Kosten, KostenproStrich, Datum) VALUES (?, ?, ?, ?, ?)" + ); - if ($stmt === false) { - die(print_r(sqlsrv_errors(), true)); + foreach ($_POST["anzahlStriche"] ?? [] as $mitarbeiterID => $anzahlStriche) { + $mitarbeiterID = (int)$mitarbeiterID; + $anzahlStriche = (int)$anzahlStriche; + if ($mitarbeiterID <= 0 || $anzahlStriche === 0) { + continue; } - } - } - echo "Einträge erfolgreich hinzugefügt."; + $kosten = $anzahlStriche * $kostenproStrich; + $insert->execute([$mitarbeiterID, $anzahlStriche, $kosten, $kostenproStrich, $datum]); + $legacyConsumptionId = (int)$pdo->lastInsertId(); + ledger_mirror_legacy_consumption($pdo, $tenantId, $legacyConsumptionId); + $eingetragen++; + } + + $pdo->commit(); + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + $fehlgeschlagen = true; + } + + if ($fehlgeschlagen) { + echo "Die Einträge konnten nicht gespeichert werden."; + } else { + echo $eingetragen . " Einträge erfolgreich hinzugefügt."; + } + + // Mitarbeiter aus der Datenbank abrufen und nach Namen sortieren + $sqlMitarbeiter = "SELECT MitarbeiterID, Name FROM kl_Mitarbeiter WHERE aktiv = 1 ORDER BY Name"; }elseif($_SERVER["REQUEST_METHOD"] == "GET" AND isset($_GET["action"])){ if($_GET["action"] == "vorderseite"){