M5 Kaffeeliste read-only auf Ledger umstellen

- kaffeeliste.php aus ledger_fetch_participant_summaries lesen lassen
- Legacy-Teilnehmerlinks ueber legacy_mitarbeiter_id erhalten
- Sidebar-Zugriff fuer Legacy-Admin und SaaS-Rollen trennen
- Smoke-Test um konkrete Ledger-Werte und Inaktiv-Ausschluss erweitern
- M5-App-Kern-Doku ergaenzen
This commit is contained in:
2026-07-14 21:32:59 +02:00
parent 1aa4a4989e
commit f9544f24fd
8 changed files with 216 additions and 137 deletions
+5 -3
View File
@@ -101,7 +101,7 @@ function ledger_fetch_tenant_totals(PDO $pdo, int $tenantId, ?int $year = null):
* - participant_ids: list<int>
* - email_norms: list<string>
*
* @return list<array{participant_id: int, tenant_id: int, display_name: string, email: ?string, email_norm: ?string, paypal_name: ?string, active: bool, total_payments_cents: int, total_costs_cents: int, total_marks: int, balance_cents: int, year_payments_cents: int, year_costs_cents: int, year_marks: int}>
* @return list<array{participant_id: int, tenant_id: int, legacy_mitarbeiter_id: ?int, display_name: string, email: ?string, email_norm: ?string, paypal_name: ?string, active: bool, total_payments_cents: int, total_costs_cents: int, total_marks: int, balance_cents: int, year_payments_cents: int, year_costs_cents: int, year_marks: int}>
*/
function ledger_fetch_participant_summaries(PDO $pdo, int $tenantId, array $options = []): array
{
@@ -140,6 +140,7 @@ function ledger_fetch_participant_summaries(PDO $pdo, int $tenantId, array $opti
SELECT
p.id AS participant_id,
p.tenant_id,
p.legacy_mitarbeiter_id,
p.display_name,
p.email,
p.email_norm,
@@ -158,7 +159,7 @@ function ledger_fetch_participant_summaries(PDO $pdo, int $tenantId, array $opti
AND le.participant_id = p.id
AND le.voided_at IS NULL
WHERE " . implode(' AND ', $where) . "
GROUP BY p.id, p.tenant_id, p.display_name, p.email, p.email_norm, p.paypal_name, p.active
GROUP BY p.id, p.tenant_id, p.legacy_mitarbeiter_id, p.display_name, p.email, p.email_norm, p.paypal_name, p.active
ORDER BY p.display_name, p.id";
$stmt = $pdo->prepare($sql);
@@ -169,6 +170,7 @@ function ledger_fetch_participant_summaries(PDO $pdo, int $tenantId, array $opti
$summaries[] = [
'participant_id' => (int)$row['participant_id'],
'tenant_id' => (int)$row['tenant_id'],
'legacy_mitarbeiter_id' => $row['legacy_mitarbeiter_id'] !== null ? (int)$row['legacy_mitarbeiter_id'] : null,
'display_name' => (string)$row['display_name'],
'email' => $row['email'] !== null ? (string)$row['email'] : null,
'email_norm' => $row['email_norm'] !== null ? (string)$row['email_norm'] : null,
@@ -188,7 +190,7 @@ function ledger_fetch_participant_summaries(PDO $pdo, int $tenantId, array $opti
}
/**
* @return array{participant_id: int, tenant_id: int, display_name: string, email: ?string, email_norm: ?string, paypal_name: ?string, active: bool, total_payments_cents: int, total_costs_cents: int, total_marks: int, balance_cents: int, year_payments_cents: int, year_costs_cents: int, year_marks: int}|null
* @return array{participant_id: int, tenant_id: int, legacy_mitarbeiter_id: ?int, display_name: string, email: ?string, email_norm: ?string, paypal_name: ?string, active: bool, total_payments_cents: int, total_costs_cents: int, total_marks: int, balance_cents: int, year_payments_cents: int, year_costs_cents: int, year_marks: int}|null
*/
function ledger_fetch_participant_summary(PDO $pdo, int $tenantId, int $participantId, ?int $year = null): ?array
{
+1 -1
View File
@@ -151,7 +151,7 @@ Installation aus `.local/` verwendet werden.
## Aktueller Pruefstatus
- M4 Ledger-Migration: gruen mit 73 Assertions.
- M4 Ledger-Service: gruen mit 102 Assertions.
- M4 Ledger-Service: gruen mit 110 Assertions.
- M3 SaaS-Basis: weiterhin gruen.
- M3 Tenant-Aufloesung: weiterhin gruen.
- M3 Passwort/E-Mail: weiterhin gruen.
+52
View File
@@ -0,0 +1,52 @@
# M5 App-Kern
Stand: 2026-07-14
M5 stellt die operativen App-Seiten schrittweise auf das neue tenant-sichere
Modell um. Der Start erfolgt bewusst read-only, damit Summen, Links und
Darstellung gegen die bestehende Legacy-Oberflaeche vergleichbar bleiben.
## Ziel
- Kernseiten aus `app/ledger.php` lesen lassen.
- Bestehendes Tabellenlayout und Bediengefuehl erhalten.
- Schreibende Legacy-Flows erst nach stabiler Leseparitaet umstellen.
- Tenant-Kontext serverseitig bestimmen, nicht aus Formularfeldern.
## Umgesetzter erster Schritt
Umgesetzte Datei:
```text
kaffeeliste.php
```
Umgesetzter Umfang:
- Die Gesamtuebersicht liest aktive Teilnehmer aus
`ledger_fetch_participant_summaries()`.
- Angezeigte Werte bleiben fachlich gleich: aktueller Stand, Gesamtausgabe,
Gesamtstriche und Gesamteinzahlungen.
- Links zur bestehenden `teilnehmerauswertung.php` bleiben ueber
`participants.legacy_mitarbeiter_id` erhalten.
- Owner, Admin und Treasurer duerfen die SaaS-Ansicht lesen.
- Der lokale Legacy-/Dev-Admin-Fallback nutzt nur ohne SaaS-Session den
Default-Tenant.
- Die Sidebar zeigt Legacy-Schreibseiten weiterhin nur fuer Legacy-Admins;
Ledger-Leseansichten sind fuer passende SaaS-Rollen sichtbar.
- Export, letzte Eintraege, CSV-Upload und Info-Mail bleiben noch Legacy-Flows.
## Noch offen
- `teilnehmerauswertung.php` lesend auf Ledger umstellen.
- `index.php` Dashboard lesend auf Ledger umstellen.
- Schreibseiten `stricheintragen.php` und `einzahlung.php` erst danach mit
Storno-/Reversal-Strategie vorbereiten.
- Export, Mail und Jahresprozesse bleiben M6-Themen.
## Aktueller Pruefstatus
- M4 Ledger-Migration: gruen mit 73 Assertions.
- M4 Ledger-Service: gruen mit 110 Assertions.
- Golden Master: gruen mit 104 Assertions.
- HTTP-Smoke: gruen mit 23 geprueften Seiten.
+4 -2
View File
@@ -305,7 +305,7 @@ Uebersicht:
| M2 | Technisches Fundament | Abgeschlossen: Migrationen, Bootstrap, Session, CSRF-Helper und Legacy-Schreibseitenschutz stehen |
| M3 | SaaS-Basis | Abgeschlossen: Tenants, User, Registrierung, Login, Rollen, Mail-Links und zentrale Mandantenauswahl funktionieren |
| M4 | Datenmigration | Gestartet: Ledger-Tabelle, Legacy-Backfill, Paritaetscheck, Ledger-Service und Preview sind umgesetzt |
| M5 | App-Kern | Dashboard, Striche, Einzahlungen, Mitglieder und Liste laufen |
| M5 | App-Kern | Gestartet: Gesamtuebersicht liest read-only aus dem Ledger |
| M6 | Betriebsflows | Import, Export, Mail und Jahresprozesse sind auditierbar |
| M7 | Landingpage | Public-Seite und Auth-Seiten sind im gemeinsamen Stil nutzbar; spaetere Ausbaustufen folgen |
| M8 | Haertung | Betrieb, Datenschutz, Monitoring und Isolation sind geprueft |
@@ -509,7 +509,8 @@ Schritte:
- Eigene Stricherfassung umsetzen.
- Zahlungs-/PayPal-Bereich umsetzen.
- Mitgliederverwaltung tenant- und rollenbasiert umsetzen.
- Gesamtuebersicht umsetzen.
- Gesamtuebersicht umsetzen: erster read-only Stand in `kaffeeliste.php`
erledigt.
- Letzte Eintraege und Korrekturen als Storno statt Delete umsetzen.
- Hinweise als tenant-spezifische Notices umsetzen.
@@ -517,6 +518,7 @@ Ergebnis:
- Ein Kunde kann seine Kaffeeliste operativ nutzen.
- App sieht weiterhin nach bestehender Kaffeeliste aus.
- Erster M5-Stand ist in `docs/m5-app-kern.md` dokumentiert.
Abhaengigkeiten:
+19 -2
View File
@@ -15,13 +15,31 @@
<?php
if(checkKaffeelisteAdmin($conn, $mailadress)){
$saasNavUser = function_exists('saas_current_user') ? saas_current_user() : null;
$saasCanUseLedgerNav = $saasNavUser !== null
&& function_exists('saas_user_has_role')
&& saas_user_has_role(['owner', 'admin', 'treasurer'], $saasNavUser);
$legacyCanUseAdminNav = checkKaffeelisteAdmin($conn, $mailadress);
if($legacyCanUseAdminNav){
?>
<li><a href="einzahlung.php">Einzahlung eintragen</a></li>
<li><a href="stricheintragen.php"> Striche eintragen</a></li>
<?php
}
?>
<?php
if($saasCanUseLedgerNav || $legacyCanUseAdminNav){
?>
<li><a href="kaffeeliste.php">Kaffeeliste anzeigen</a></li>
<li><a href="ledger-preview.php">Ledger Preview</a></li>
<?php
}
?>
<?php
if($legacyCanUseAdminNav){
?>
<li><a href="mitarbeiterverwalten.php">Mitglieder verwalten</a></li>
<li><a href="hinweise.php">Hinweise verwalten</a></li>
@@ -35,7 +53,6 @@ if(checkKaffeelisteAdmin($conn, $mailadress)){
?>
<li><a href="faq.php">FAQ Kaffeeliste</a></li>
<?php
$saasNavUser = function_exists('saas_current_user') ? saas_current_user() : null;
if ($saasNavUser !== null) {
?>
<li><a href="konto.php">Kundenkonto</a></li>
+108 -128
View File
@@ -1,148 +1,128 @@
<?php
include "functions.php";
require_once __DIR__ . "/app/ledger.php";
$pdo = app_db_pdo();
$saasUser = saas_current_user($pdo);
$tenantId = 0;
$hasAccess = false;
function kaffeeliste_default_tenant(PDO $pdo): ?array
{
$tenantSlug = getenv('M4_DEFAULT_TENANT_SLUG');
if ($tenantSlug === false || trim($tenantSlug) === '') {
$tenantSlug = getenv('M3_DEFAULT_TENANT_SLUG');
}
$tenantSlug = $tenantSlug !== false && trim($tenantSlug) !== ''
? trim($tenantSlug)
: 'default';
$stmt = $pdo->prepare('SELECT id, name, slug FROM tenants WHERE slug = ? LIMIT 1');
$stmt->execute([$tenantSlug]);
$tenant = $stmt->fetch();
return $tenant !== false ? $tenant : null;
}
function kaffeeliste_money(int $cents): string
{
return saas_format_money_cents($cents) . ' €';
}
if ($saasUser !== null && saas_user_has_role(['owner', 'admin', 'treasurer'], $saasUser)) {
$tenantId = (int)$saasUser['tenant_id'];
$hasAccess = true;
}
if (!$hasAccess && $saasUser === null && checkKaffeelisteAdmin($conn, $mailadress)) {
$tenant = kaffeeliste_default_tenant($pdo);
if ($tenant !== null) {
$tenantId = (int)$tenant['id'];
$hasAccess = true;
}
}
$participants = $hasAccess
? ledger_fetch_participant_summaries($pdo, $tenantId, ['active_only' => true])
: [];
include "header.php";
include "headerline.php";
include "nav.php";
?>
<!-- Banner -->
<section id="banner">
<div class="content">
<section id="banner">
<div class="content">
<?php
if(checkKaffeelisteAdmin($conn, $mailadress)){
if ($hasAccess) {
?>
<h2>Aktive Mitarbeiter mit Gesamtstand</h2>
<br>
<ul class="actions">
<li>
<form action="exportKaffeeliste.php" method="get">
<button type="submit">Kaffeeliste exportieren</button>
</form>
</li>
<li>
<form action="letzteneintraege.php" method="get">
<button type="submit">Letzten Einträge</button>
</form>
</li>
<li>
<form action="csvupload.php" method="get">
<button type="submit">CSV Upload</button>
</form>
</li>
</ul>
// Funktion zum Berechnen der Gesamtausgabe und Gesamtstriche pro Mitarbeiter
function berechneGesamtausgabe($mitarbeiterID, $conn) {
// Gesamtausgabe für Kaffeeverbrauch pro Mitarbeiter
$sqlAusgabe = "SELECT SUM(Kosten) AS Gesamtausgabe FROM kl_Kaffeeverbrauch WHERE MitarbeiterID = ?";
$stmtAusgabe = sqlsrv_query($conn, $sqlAusgabe, array($mitarbeiterID));
$rowAusgabe = sqlsrv_fetch_array($stmtAusgabe, SQLSRV_FETCH_ASSOC);
$gesamtausgabe = $rowAusgabe['Gesamtausgabe'] ?? 0;
<br>
<table border="1" class="table table-striped table-bordered">
<tr>
<th>Name</th>
<th>E-Mail</th>
<th>Aktueller Stand</th>
<th>Gesamtausgabe (€)</th>
<th>Gesamtstriche</th>
<th>Gesamteinzahlungen</th>
</tr>
<?php
foreach ($participants as $participant) {
$legacyId = $participant['legacy_mitarbeiter_id'];
$name = saas_html($participant['display_name']);
$email = saas_html((string)($participant['email'] ?? ''));
$balance = kaffeeliste_money((int)$participant['balance_cents']);
$totalCosts = kaffeeliste_money((int)$participant['total_costs_cents']);
$totalMarks = number_format((int)$participant['total_marks'], 0, ',', '.');
$totalPayments = kaffeeliste_money((int)$participant['total_payments_cents']);
return $gesamtausgabe;
}
// Funktion zum Berechnen der Gesamtstriche pro Mitarbeiter
function berechneGesamtstriche($mitarbeiterID, $conn) {
// Gesamtstriche pro Mitarbeiter
$sqlStriche = "SELECT SUM(AnzahlStriche) AS Gesamtstriche FROM kl_Kaffeeverbrauch WHERE MitarbeiterID = ?";
$stmtStriche = sqlsrv_query($conn, $sqlStriche, array($mitarbeiterID));
$rowStriche = sqlsrv_fetch_array($stmtStriche, SQLSRV_FETCH_ASSOC);
$gesamtstriche = $rowStriche['Gesamtstriche'] ?? 0;
return $gesamtstriche;
}
// Funktion zum Berechnen der Gesamteinzahlungen pro Mitarbeiter
function berechneGesamteinzahlungen($mitarbeiterID, $conn) {
// Gesamteinzahlungen pro Mitarbeiter
$sqlEinzahlungen = "SELECT SUM(Betrag) AS Gesamteinzahlungen FROM kl_Einzahlungen WHERE MitarbeiterID = ?";
$stmtEinzahlungen = sqlsrv_query($conn, $sqlEinzahlungen, array($mitarbeiterID));
$rowEinzahlungen = sqlsrv_fetch_array($stmtEinzahlungen, SQLSRV_FETCH_ASSOC);
$gesamteinzahlungen = $rowEinzahlungen['Gesamteinzahlungen'] ?? 0;
return $gesamteinzahlungen;
}
// Mitglieder aus der Datenbank abrufen
$sqlMitglieder = "SELECT MitarbeiterID, Name, Email FROM kl_Mitarbeiter WHERE aktiv = 1 ORDER BY Name";
$stmtMitglieder = sqlsrv_query($conn, $sqlMitglieder);
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kaffeeliste - Aktive Mitarbeiter mit Gesamtstand</title>
</head>
<body>
<h2>Aktive Mitarbeiter mit Gesamtstand</h2>
<br>
<ul class="actions">
<li>
<form action="exportKaffeeliste.php" method="get">
<button type="submit">Kaffeeliste exportieren</button>
</form>
</li>
<li>
<form action="letzteneintraege.php" method="get">
<button type="submit">Letzten Einträge</button>
</form>
</li>
<li>
<form action="csvupload.php" method="get">
<button type="submit">CSV Upload</button>
</form>
</li>
</ul>
<br>
<table border="1" class="table table-striped table-bordered">
<tr>
<th>Name</th>
<th>E-Mail</th>
<th>Aktueller Stand</th>
<th>Gesamtausgabe (€)</th>
<th>Gesamtstriche</th>
<th>Gesamteinzahlungen</th>
</tr>
<?php
while ($row = sqlsrv_fetch_array($stmtMitglieder, SQLSRV_FETCH_ASSOC)) {
$mitarbeiterID = $row['MitarbeiterID'];
$name = $row['Name'];
$email = $row['Email'];
$gesamtausgabe = berechneGesamtausgabe($mitarbeiterID, $conn);
$gesamtstriche = berechneGesamtstriche($mitarbeiterID, $conn);
$gesamteinzahlungen = berechneGesamteinzahlungen($mitarbeiterID, $conn);
$differenztemp = $gesamteinzahlungen - $gesamtausgabe;
$differenz = number_format($differenztemp, 2, ',', '.');
$gesamtausgabe = number_format($gesamtausgabe, 2, ',', '.');
$gesamteinzahlungen = number_format($gesamteinzahlungen, 2, ',', '.');
$gesamtstriche = number_format($gesamtstriche, 0, ',', '.');
echo "<tr>";
echo "<td><a href=teilnehmerauswertung.php?user_id=$mitarbeiterID>{$name}</a></td>";
echo "<td>{$email}</td>";
echo "<td>{$differenz} €</td>";
echo "<td>{$gesamtausgabe} €</td>";
echo "<td>{$gesamtstriche}</td>";
echo "<td>{$gesamteinzahlungen} €</td>";
echo "</tr>";
echo "<tr>";
if ($legacyId !== null) {
echo "<td><a href=\"teilnehmerauswertung.php?user_id=" . (int)$legacyId . "\">{$name}</a></td>";
} else {
echo "<td>{$name}</td>";
}
?>
</table>
<br><br>
<form action="mailversenden.php" method="get">
<button type="submit">Info-Mail versenden</button>
</form>
echo "<td>{$email}</td>";
echo "<td>{$balance}</td>";
echo "<td>{$totalCosts}</td>";
echo "<td>{$totalMarks}</td>";
echo "<td>{$totalPayments}</td>";
echo "</tr>";
}
?>
</table>
<br><br>
<form action="mailversenden.php" method="get">
<button type="submit">Info-Mail versenden</button>
</form>
<?php
}else{
} else {
echo "<h2>Kein Zugriff</h2>";
}
?>
</div>
</div>
</section>
<?php include "footer.php"; ?>
+7
View File
@@ -106,6 +106,13 @@ foreach ($expected as $email => $expectedSummary) {
continue;
}
m4_service_assert(
"ledger service {$email} keeps legacy member id",
$summariesByEmail[$email]['legacy_mitarbeiter_id'] !== null,
$failures,
$passes
);
foreach ($keyMap as $expectedKey => $actualKey) {
m4_service_assert_equal(
"ledger service {$email} {$expectedKey}",
+20 -1
View File
@@ -29,7 +29,18 @@ $checks = [
[
'label' => 'Kaffeeliste',
'path' => 'kaffeeliste.php',
'contains' => ['Aktive Mitarbeiter'],
'contains' => [
'Aktive Mitarbeiter',
'teilnehmerauswertung.php?user_id=',
'GM Negativ',
'gm-negative@test.local',
'-2,00 €',
'GM Positiv',
'23,60 €',
'GM Ohne Buchungen',
'0,00 €',
],
'not_contains' => ['GM Inaktiv'],
],
[
'label' => 'Ledger Preview',
@@ -221,6 +232,14 @@ foreach ($checks as $check) {
}
}
foreach (($check['not_contains'] ?? []) as $needle) {
if (str_contains($response['body'], $needle)) {
$failures[] = "{$label}: unexpected text found: {$needle}";
echo "FAIL {$label}\n";
continue 2;
}
}
$passes++;
echo "PASS {$label}\n";
}