M6: Jahresabschluss als generisches Feature statt AOK-spezifischem Bonus-Skript

jahresauswertung.php verband sich bisher mit fest codierten (kaputten)
Zugangsdaten selbst zur Datenbank statt ueber config.php, hatte keine
Zugriffskontrolle und kein CSRF, und verteilte bei jedem Aufruf sofort
einen hart codierten Bonus-Topf (490 Striche a 0,20 Euro) per PHPMailer
(dessen Quelldateien im Repo fehlen) mit AOK-spezifischem Mailtext.

Nach Abstimmung mit dem Kunden als generisches, mandantenfaehiges Feature
neu gebaut statt nur deaktiviert oder rein lesend umgesetzt:

- Admin gibt einen frei waehlbaren Gesamtbetrag ein, das System verteilt
  ihn proportional zu den Jahresstrichen auf alle aktiven Mitglieder.
- Standardmaessig aktive Dry-Run-Checkbox zeigt die Verteilung, ohne zu
  buchen oder Mails zu verschicken.
- Bestaetigter Lauf bucht ueber dasselbe Zweig-Muster wie ueberall
  (Default-Mandant Dual-Write, andere Mandanten ledger_record_payment)
  und verschickt personalisierte Mails ueber saas_send_mail(), protokolliert
  im outbound_emails-Versandlog.
- Zugriffskontrolle ergaenzt (owner/admin/treasurer + Legacy-Fallback).
- http-smoke.php: jahresauswertung.php jetzt regulaerer Check statt
  uebersprungenem unsicherem Aufruf; damit sind keine Seiten mehr
  uebersprungen oder als bekannter offener Punkt markiert (26/26 gruen).

Live getestet: Dry-Run mit korrekter proportionaler Verteilung (Summe
ergibt exakt den Gesamtbetrag), Live-Lauf bucht und versendet korrekt,
Testdaten anschliessend vollstaendig entfernt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 17:21:34 +02:00
co-authored by Claude Sonnet 5
parent d5195e7f9f
commit 536ef2ead2
5 changed files with 268 additions and 181 deletions
+18
View File
@@ -210,6 +210,24 @@ function saas_fetch_outbound_email_log(PDO $pdo, int $tenantId, int $limit = 50)
return $stmt->fetchAll();
}
function saas_render_year_end_bonus_mail_body(string $displayName, int $yearMarks, int $bonusCents, string $dashboardUrl): string
{
$bonus = saas_format_money_cents($bonusCents) . ' €';
$lines = [
"Hallo {$displayName},",
'',
"vielen Dank für deine Nutzung der Kaffeeliste in diesem Jahr!",
"Du hast dieses Jahr {$yearMarks} Striche gemacht.",
"Dafür wurden dir {$bonus} auf deinem Kaffeelisten-Konto gutgeschrieben.",
'',
"Deinen aktuellen Stand findest du hier: {$dashboardUrl}",
'',
'Deine Kaffeeliste',
];
return implode("\n", $lines);
}
function saas_send_invite_mail(string $to, string $tenantName, string $role, string $token): array
{
$link = saas_app_url('passwort-zuruecksetzen.php?token=' . urlencode($token));
+50 -2
View File
@@ -149,10 +149,58 @@ Live-Versand erzeugt für jeden Empfänger eine Log-Datei mit korrekt
personalisiertem Inhalt (Guthaben- und Schuldenfall geprüft), Versandlog in
der UI zeigt die Einträge. Testdaten anschließend entfernt.
## Jahresabschluss
Umgesetzte Dateien:
```text
app/saas-mail.php (Mailvorlage)
jahresauswertung.php
scripts/http-smoke.php
```
Ziel: Ein generisches, mandantenfähiges Feature statt der ursprünglichen
AOK-spezifischen Alt-Kunden-Logik.
Hintergrund: `jahresauswertung.php` verband sich bisher mit fest codierten
(kaputten) Zugangsdaten selbst zur Datenbank statt über `config.php`, hatte
keinerlei Zugriffskontrolle und kein CSRF, verteilte bei jedem Aufruf sofort
einen hart codierten Bonus-Topf (490 Striche à 0,20&nbsp;€) und verschickte
dabei über PHPMailer (dessen Quelldateien im Repo fehlen) Mails mit
AOK-spezifischem Text. Mit dem Nutzer wurde abgestimmt, daraus ein echtes,
wiederverwendbares Feature zu bauen statt es nur zu deaktivieren oder rein
lesend umzusetzen.
Umfang:
- Admin gibt einen frei wählbaren Gesamtbetrag ein; das System verteilt ihn
proportional zu den in diesem Kalenderjahr gemachten Strichen
(`year_marks` aus `ledger_fetch_participant_summaries()`) auf alle
aktiven Mitglieder.
- Standardmäßig aktive Dry-Run-Checkbox zeigt Name, Jahresstriche, Anteil
und berechneten Bonus, ohne zu buchen oder Mails zu verschicken.
- Bei bestätigtem Lauf wird pro Mitglied mit einem Anteil &gt; 0 eine
Zahlung gebucht (gleiches Zweig-Muster wie überall: Default-Mandant per
Dual-Write nach `kl_Einzahlungen` plus Spiegelung, andere Mandanten
direkt über `ledger_record_payment()` mit `source = year_end_bonus`) und
eine personalisierte Mail über `saas_send_mail()` verschickt und im
Versandlog (`outbound_emails`, Vorlage `year_end_bonus`) protokolliert.
- Zugriffskontrolle ergänzt (owner/admin/treasurer + Legacy-Fallback).
- `scripts/http-smoke.php`: `jahresauswertung.php` ist jetzt ein regulärer
Check statt eines übersprungenen unsicheren Aufrufs, da GET keine
Seiteneffekte mehr hat.
Live getestet: Dry-Run mit 100&nbsp;€ (korrekte proportionale Verteilung,
Summe der Einzelbeträge ergibt exakt den Gesamtbetrag, keine Buchungen oder
Mails), anschließender Live-Lauf bucht korrekt (Dual-Write und
Ledger-Spiegelung stimmen mit den berechneten Beträgen überein) und
verschickt personalisierte Mails; Versandlog zeigt alle Einträge korrekt.
Testdaten anschließend vollständig entfernt.
## Prüfstatus
- Golden Master: grün mit 104 Assertions.
- M4 Ledger-Migration: grün mit 73 Assertions.
- M4 Ledger-Service: grün mit 115 Assertions.
- HTTP-Smoke: grün mit 25 geprüften Seiten (PDF-Export und Mailversand
jetzt regulär statt bekannter offener Punkte).
- HTTP-Smoke: grün mit 26 geprüften Seiten, keine übersprungenen oder
bekannten offenen Punkte mehr.
+11 -3
View File
@@ -306,7 +306,7 @@ Nicht tun:
| M3 | SaaS-Basis | Abgeschlossen: Tenants, User, Registrierung, Login, Rollen, Mail-Links und zentrale Mandantenauswahl funktionieren |
| M4 | Datenmigration | Gestartet: Ledger-Tabelle, Legacy-Backfill, Paritätscheck, Ledger-Service und Preview sind umgesetzt |
| M5 | App-Kern | Weit fortgeschritten: Kernseiten lesen und schreiben tenant-sicher gegen das Ledger, inklusive Hinweise und rollenbasiertem Zugang; offen ist ein eigener Zahlungs-Screen |
| M6 | Betriebsflows | Weit fortgeschritten: CSV-Import, PDF-Export und Mailversand stehen; Jahresprozesse offen |
| M6 | Betriebsflows | Abgeschlossen: Import, Export, Mail und Jahresabschluss sind auditierbar |
| M7 | Landingpage | Public-Seite und Auth-Seiten sind im gemeinsamen Stil nutzbar; spätere Ausbaustufen folgen |
| M8 | Härtung | Betrieb, Datenschutz, Monitoring und Isolation sind geprüft |
| M9 | Cutover | Produktivumstellung ist vorbereitet und Legacy ist read-only |
@@ -585,13 +585,21 @@ Schritte:
PayPal-Link, FAQ-URL). Ersetzt durch die bestehende `saas_send_mail()`-
Abstraktion aus M3, eine neue `outbound_emails`-Tabelle als Versandlog
und eine standardmäßig aktive Dry-Run-Option.
- Jahresauswertung beziehungsweise Jahresbuchungen tenant-sicher abbilden.
- Jahresauswertung beziehungsweise Jahresbuchungen tenant-sicher abbilden:
erledigt. Die ursprüngliche Seite verband sich mit fest codierten,
kaputten Zugangsdaten selbst zur Datenbank (an `config.php` vorbei),
hatte keine Zugriffskontrolle und verteilte bei jedem Aufruf sofort einen
hart codierten Bonus-Topf mit AOK-spezifischem Mailtext. Nach Abstimmung
mit dem Kunden als generisches Feature umgesetzt: frei wählbarer
Gesamtbetrag, proportionale Verteilung nach Jahresstrichen, Dry-Run,
Buchung und Mailversand mit Protokoll.
Ergebnis:
- Kassenverwaltung ist für reale Betriebsabläufe vollständig.
- Import/Export/Mail sind auditierbar.
- Stand: gestartet. Dokumentation: `docs/m6-import-export-mail.md`.
- Stand: abgeschlossen für den M6-Scope. Dokumentation:
`docs/m6-import-export-mail.md`.
Abhängigkeiten:
+184 -172
View File
@@ -1,177 +1,189 @@
<?php
// Einstellungen
// Verbindung zur Datenbank herstellen (ersetze die Platzhalter durch deine Daten)
$serverName = "server";
$connectionOptions = array(
"Database" => "database",
"Uid" => "user",
"PWD" => "password",
"TrustServerCertificate"=>true
);
$stricheAnzupassen = 490; // Anzahl der neuen Striche
$betragProStrich = 0.20; // Betrag pro Strich in Euro
include "functions.php";
require_once __DIR__ . "/app/ledger.php";
require_once __DIR__ . "/app/saas-mail.php";
app_require_csrf();
include "header.php";
include "headerline.php";
include "nav.php";
// Testmodus aktivieren
$testmodus = false;
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\SMTP;
use PHPMailer\PHPMailer\Exception;
require 'PHPMailer/src/Exception.php';
require 'PHPMailer/src/PHPMailer.php';
require 'PHPMailer/src/SMTP.php';
// PHPMailer konfigurieren
function sendeMail($empfaenger, $betreff, $inhalt, $testmodus)
{
$mail = new PHPMailer(true);
try {
// Server-Einstellungen
$mail->isSMTP();
$mail->Host = 'smtpv.aoknds.aok'; // SMTP-Server
$mail->Timeout = 180;
$mail->SMTPAuth = false;
$mail->Port = 25;
// Absender
$mail->setFrom('kaffeelistesb3@nds.aok.de', 'Kaffeeliste');
// Empfänger
if ($testmodus) {
$mail->addAddress('kaffeelistesb3@nds.aok.de'); // Testadresse
} else {
$mail->addAddress($empfaenger); // Tatsächlicher Empfänger
}
// Inhalt
$mail->isHTML(true);
$mail->Subject = $betreff;
$mail->Body = utf8_decode($inhalt);
// Senden
$mail->send();
echo "E-Mail erfolgreich gesendet an: " . ($testmodus ? 'kaffeelistesb3@nds.aok.de' : $empfaenger) . "\n";
} catch (Exception $e) {
echo "E-Mail konnte nicht gesendet werden. Fehler: {$mail->ErrorInfo}\n";
}
}
// Verbindung herstellen
$conn = sqlsrv_connect($serverName, $connectionOptions);
if ($conn === false) {
die(print_r(sqlsrv_errors(), true));
}
// Aktuelles Jahr ermitteln
$currentYear = date("Y");
// SQL-Abfrage: Gesamtanzahl der Striche pro Mitarbeiter im aktuellen Jahr mit Namen und E-Mail
$sql = "
SELECT
m.MitarbeiterID,
m.Name,
m.Email,
SUM(v.AnzahlStriche) AS GesamtStriche
FROM kl_Kaffeeverbrauch v
JOIN kl_Mitarbeiter m ON v.MitarbeiterID = m.MitarbeiterID
WHERE YEAR(v.Datum) = ? AND m.aktiv = 1
GROUP BY m.MitarbeiterID, m.Name, m.Email
";
$params = [$currentYear];
$stmt = sqlsrv_query($conn, $sql, $params);
if ($stmt === false) {
die(print_r(sqlsrv_errors(), true));
}
// Ergebnisse verarbeiten
$mitarbeiterDaten = [];
$gesamtStriche = 0;
while ($row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC)) {
$mitarbeiterDaten[] = $row;
$gesamtStriche += $row['GesamtStriche'];
}
echo $gesamtStriche;
// Neue Striche proportional verteilen
$verteilung = [];
foreach ($mitarbeiterDaten as $mitarbeiter) {
$mitarbeiterID = $mitarbeiter['MitarbeiterID'];
$anteil = $mitarbeiter['GesamtStriche'] / $gesamtStriche;
$neueStriche = round($anteil * $stricheAnzupassen);
$betrag = $neueStriche * $betragProStrich;
$verteilung[] = [
'MitarbeiterID' => $mitarbeiterID,
'Name' => $mitarbeiter['Name'],
'Email' => $mitarbeiter['Email'],
'JahrStriche' => $mitarbeiter['GesamtStriche'],
'NeueStriche' => $neueStriche,
'Betrag' => $betrag
];
// SQL-Befehl vorbereiten
$insertSql = "
INSERT INTO kl_Einzahlungen (MitarbeiterID, Betrag, Datum)
VALUES (?, ?, GETDATE())
";
$insertParams = [$mitarbeiterID, $betrag];
if ($testmodus) {
// SQL-Befehl und Parameter anzeigen
echo "SQL-Befehl: $insertSql\n";
echo "Parameter: " . json_encode($insertParams) . "\n";
// E-Mail vorbereiten
} else {
// SQL-Befehl ausführen
$insertStmt = sqlsrv_query($conn, $insertSql, $insertParams);
if ($insertStmt === false) {
die(print_r(sqlsrv_errors(), true));
}
// E-Mail vorbereiten
}
$betreff = "Kaffeeliste - Weihnachten";
$inhalt = "
<p>Hallo {$mitarbeiter['Name']},</p>
<p>vielen Dank für deine Nutzung der Kaffeeliste in diesem Jahr!</p>
<p>Du hast dieses Jahr {$mitarbeiter['GesamtStriche']} Kaffee bezogen.</p>
<p>Deswegen wurden dir $betrag Euro auf deinem Konto gutgeschrieben.</p>
<br><br>
<p>Wir wünschen dir eine frohe Weihnachtszeit und einen guten Rutsch ins neue Jahr.</p>
<p>Deine ARGE Kaffeeliste</p>
";
$empfaenger = $mitarbeiter['Email'];
// E-Mail senden
sendeMail($empfaenger, $betreff, $inhalt, $testmodus);
}
// Sortiere die Verteilung nach JahrStriche (absteigend)
usort($verteilung, function ($a, $b) {
return $b['JahrStriche'] <=> $a['JahrStriche'];
});
// Ergebnisse ausgeben
header('Content-Type: application/json');
#echo json_encode($verteilung, JSON_PRETTY_PRINT);
// Verbindung schließen
sqlsrv_close($conn);
?>
<!-- Banner -->
<section id="banner">
<div class="content">
<?php
$pdo = app_db_pdo();
$saasUser = saas_current_user($pdo);
$tenantId = 0;
$hasAccess = false;
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 = ledger_fetch_default_tenant($pdo);
if ($tenant !== null) {
$tenantId = (int)$tenant['id'];
$hasAccess = true;
}
}
if (!$hasAccess) {
echo "<h2>Kein Zugriff</h2>";
include "footer.php";
exit;
}
$jahr = (int)date('Y');
$teilnehmer = ledger_fetch_participant_summaries($pdo, $tenantId, ['active_only' => true, 'year' => $jahr]);
$gesamtJahresstriche = array_sum(array_column($teilnehmer, 'year_marks'));
$fehler = null;
$ergebnisse = null;
$dryRun = true;
$gesamtbetragEingabe = '';
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$dryRun = !empty($_POST['dry_run']);
$gesamtbetragEingabe = (string)($_POST['gesamtbetrag'] ?? '');
$gesamtbetrag = filter_var(str_replace(',', '.', $gesamtbetragEingabe), FILTER_VALIDATE_FLOAT);
$totalCents = $gesamtbetrag !== false ? (int)round($gesamtbetrag * 100) : 0;
if ($totalCents <= 0) {
$fehler = 'Bitte einen Gesamtbetrag größer 0 eingeben.';
} elseif ($gesamtJahresstriche <= 0) {
$fehler = "Für {$jahr} gibt es noch keine Striche, daher kann nichts verteilt werden.";
} else {
$ergebnisse = [];
$settings = saas_fetch_tenant_settings($pdo, $tenantId);
$dashboardUrl = saas_app_url('index.php');
$createdByUserId = $saasUser['user_id'] ?? null;
// Legacy-Verknuepfung je Teilnehmer vorab laden (gleiches Zweig-Muster
// wie bei Sammelerfassung und CSV-Import: Default-Mandant bucht
// zusaetzlich in kl_Einzahlungen, alle anderen Mandanten rein Ledger-nativ).
$legacyMap = [];
$stmt = $pdo->prepare('SELECT id, legacy_mitarbeiter_id FROM participants WHERE tenant_id = ?');
$stmt->execute([$tenantId]);
foreach ($stmt->fetchAll() as $row) {
$legacyMap[(int)$row['id']] = $row['legacy_mitarbeiter_id'] !== null ? (int)$row['legacy_mitarbeiter_id'] : null;
}
try {
if (!$dryRun) {
$pdo->beginTransaction();
}
$insertLegacy = $pdo->prepare('INSERT INTO kl_Einzahlungen (MitarbeiterID, Betrag, Datum) VALUES (?, ?, ?)');
$jetzt = date('Y-m-d H:i:s');
foreach ($teilnehmer as $person) {
$yearMarks = (int)$person['year_marks'];
if ($yearMarks <= 0) {
continue;
}
$anteil = $yearMarks / $gesamtJahresstriche;
$bonusCents = (int)round($totalCents * $anteil);
if ($bonusCents <= 0) {
continue;
}
$status = 'vorschau';
if (!$dryRun) {
$legacyMitarbeiterId = $legacyMap[$person['participant_id']] ?? null;
if ($legacyMitarbeiterId !== null) {
$insertLegacy->execute([$legacyMitarbeiterId, $bonusCents / 100, $jetzt]);
$legacyPaymentId = (int)$pdo->lastInsertId();
ledger_mirror_legacy_payment($pdo, $tenantId, $legacyPaymentId);
} else {
ledger_record_payment($pdo, $tenantId, $person['participant_id'], $bonusCents, 'year_end_bonus');
}
$status = 'gebucht';
$email = trim((string)($person['email'] ?? ''));
if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
$subject = "Kaffeeliste - Dein Jahresbonus {$jahr}";
$body = saas_render_year_end_bonus_mail_body($person['display_name'], $yearMarks, $bonusCents, $dashboardUrl);
$sendResult = saas_send_mail($email, $subject, $body);
saas_log_outbound_email(
$pdo, $tenantId, $person['participant_id'], 'year_end_bonus', $subject,
$sendResult['ok'] ? 'sent' : 'failed', $sendResult['error'] ?? null, $createdByUserId
);
}
}
$ergebnisse[] = [
'name' => $person['display_name'],
'year_marks' => $yearMarks,
'anteil' => $anteil,
'bonus_cents' => $bonusCents,
'status' => $status,
];
}
if (!$dryRun) {
$pdo->commit();
}
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
$fehler = 'Die Verteilung konnte nicht gespeichert werden.';
$ergebnisse = null;
}
}
}
?>
<h2>Jahresabschluss <?php echo (int)$jahr; ?></h2>
<p>Verteilt einen frei wählbaren Gesamtbetrag proportional zu den in diesem
Jahr gemachten Strichen als Guthaben an alle aktiven Mitglieder und
benachrichtigt sie per Mail. Im Dry-Run wird nur eine Vorschau berechnet,
es wird nichts gebucht und keine Mail verschickt.</p>
<p>Jahresstriche gesamt: <?php echo number_format($gesamtJahresstriche, 0, ',', '.'); ?></p>
<?php if ($fehler !== null): ?>
<div class="hint-box error"><p><?php echo saas_html($fehler); ?></p></div>
<?php endif; ?>
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
<?php echo app_csrf_field(); ?>
<label for="gesamtbetrag">Gesamtbetrag (€):</label>
<input type="text" name="gesamtbetrag" id="gesamtbetrag" value="<?php echo saas_html($gesamtbetragEingabe); ?>" required>
<br>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="dry_run" id="dry_run" <?php echo $dryRun ? 'checked' : ''; ?>>
<label class="form-check-label" for="dry_run">Dry-Run (nur Vorschau, nichts buchen, keine Mails)</label>
</div>
<button type="submit">Berechnen</button>
</form>
<?php if ($ergebnisse !== null): ?>
<h3><?php echo $dryRun ? 'Vorschau' : 'Gebucht'; ?></h3>
<table>
<tr><th>Name</th><th>Jahresstriche</th><th>Anteil</th><th>Bonus</th><th>Status</th></tr>
<?php foreach ($ergebnisse as $eintrag): ?>
<tr>
<td><?php echo saas_html($eintrag['name']); ?></td>
<td><?php echo number_format($eintrag['year_marks'], 0, ',', '.'); ?></td>
<td><?php echo number_format($eintrag['anteil'] * 100, 1, ',', '.'); ?> %</td>
<td><?php echo saas_html(saas_format_money_cents($eintrag['bonus_cents'])); ?> €</td>
<td><?php echo $eintrag['status'] === 'gebucht' ? 'Gebucht' : 'Vorschau'; ?></td>
</tr>
<?php endforeach; ?>
</table>
<?php endif; ?>
</div>
</section>
<?php include "footer.php"; ?>
+5 -4
View File
@@ -142,16 +142,17 @@ $checks = [
'path' => 'mailversenden.php',
'contains' => ['Info-Mail versenden', 'Dry-Run', 'Versandlog'],
],
[
'label' => 'Jahresabschluss',
'path' => 'jahresauswertung.php',
'contains' => ['Jahresabschluss', 'Dry-Run', 'Jahresstriche gesamt'],
],
];
$knownIssueChecks = [
];
$skippedUnsafe = [
[
'path' => 'jahresauswertung.php',
'reason' => 'GET kann Jahresbuchungen schreiben und E-Mails versenden; PHPMailer-Abhängigkeit ist zudem unvollständig.',
],
];
function smoke_fetch(string $url): array