M4 Ledger-Service fuer erste App-Abfragen ergaenzen

- zentrale Ledger-Abfragen fuer Tenant- und Teilnehmer-Summen bauen
- letzte Buchungen und Einzelteilnehmer ueber app/ledger.php lesbar machen
- Service-Check und M4-Doku ergaenzen
This commit is contained in:
2026-07-13 21:01:37 +02:00
parent e4b0c81b48
commit 726c5a9508
4 changed files with 495 additions and 5 deletions
+281
View File
@@ -0,0 +1,281 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
/**
* @return list<string>
*/
function ledger_normalize_email_norms(array $emails): array
{
$normalized = [];
foreach ($emails as $email) {
$emailNorm = strtolower(trim((string)$email));
if ($emailNorm !== '') {
$normalized[$emailNorm] = true;
}
}
return array_keys($normalized);
}
/**
* @return list<int>
*/
function ledger_normalize_ids(array $ids): array
{
$normalized = [];
foreach ($ids as $id) {
$id = (int)$id;
if ($id > 0) {
$normalized[$id] = true;
}
}
return array_keys($normalized);
}
function ledger_current_year(): int
{
return (int)date('Y');
}
/**
* @return array{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_empty_totals(): array
{
return [
'total_payments_cents' => 0,
'total_costs_cents' => 0,
'total_marks' => 0,
'balance_cents' => 0,
'year_payments_cents' => 0,
'year_costs_cents' => 0,
'year_marks' => 0,
];
}
/**
* @return array{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_tenant_totals(PDO $pdo, int $tenantId, ?int $year = null): array
{
$year = $year ?? ledger_current_year();
$stmt = $pdo->prepare(
"SELECT
COALESCE(SUM(CASE WHEN type = 'payment' THEN amount_cents ELSE 0 END), 0) AS total_payments_cents,
COALESCE(SUM(CASE WHEN type = 'consumption' THEN -amount_cents ELSE 0 END), 0) AS total_costs_cents,
COALESCE(SUM(CASE WHEN type = 'consumption' THEN marks_count ELSE 0 END), 0) AS total_marks,
COALESCE(SUM(amount_cents), 0) AS balance_cents,
COALESCE(SUM(CASE WHEN type = 'payment' AND YEAR(booked_at) = ? THEN amount_cents ELSE 0 END), 0) AS year_payments_cents,
COALESCE(SUM(CASE WHEN type = 'consumption' AND YEAR(booked_at) = ? THEN -amount_cents ELSE 0 END), 0) AS year_costs_cents,
COALESCE(SUM(CASE WHEN type = 'consumption' AND YEAR(booked_at) = ? THEN marks_count ELSE 0 END), 0) AS year_marks
FROM ledger_entries
WHERE tenant_id = ?
AND voided_at IS NULL"
);
$stmt->execute([$year, $year, $year, $tenantId]);
$row = $stmt->fetch();
if ($row === false) {
return ledger_empty_totals();
}
return [
'total_payments_cents' => (int)$row['total_payments_cents'],
'total_costs_cents' => (int)$row['total_costs_cents'],
'total_marks' => (int)$row['total_marks'],
'balance_cents' => (int)$row['balance_cents'],
'year_payments_cents' => (int)$row['year_payments_cents'],
'year_costs_cents' => (int)$row['year_costs_cents'],
'year_marks' => (int)$row['year_marks'],
];
}
/**
* Options:
* - year: int
* - active_only: bool|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}>
*/
function ledger_fetch_participant_summaries(PDO $pdo, int $tenantId, array $options = []): array
{
$year = isset($options['year']) ? (int)$options['year'] : ledger_current_year();
$where = ['p.tenant_id = ?'];
$params = [$year, $year, $year, $tenantId];
if (array_key_exists('active_only', $options) && $options['active_only'] !== null) {
$where[] = 'p.active = ?';
$params[] = $options['active_only'] ? 1 : 0;
}
if (array_key_exists('participant_ids', $options)) {
$participantIds = is_array($options['participant_ids'])
? ledger_normalize_ids($options['participant_ids'])
: [];
if ($participantIds === []) {
return [];
}
$where[] = 'p.id IN (' . implode(',', array_fill(0, count($participantIds), '?')) . ')';
array_push($params, ...$participantIds);
}
if (array_key_exists('email_norms', $options)) {
$emailNorms = is_array($options['email_norms'])
? ledger_normalize_email_norms($options['email_norms'])
: [];
if ($emailNorms === []) {
return [];
}
$where[] = 'p.email_norm IN (' . implode(',', array_fill(0, count($emailNorms), '?')) . ')';
array_push($params, ...$emailNorms);
}
$sql = "
SELECT
p.id AS participant_id,
p.tenant_id,
p.display_name,
p.email,
p.email_norm,
p.paypal_name,
p.active,
COALESCE(SUM(CASE WHEN le.type = 'payment' THEN le.amount_cents ELSE 0 END), 0) AS total_payments_cents,
COALESCE(SUM(CASE WHEN le.type = 'consumption' THEN -le.amount_cents ELSE 0 END), 0) AS total_costs_cents,
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_cents,
COALESCE(SUM(CASE WHEN le.type = 'payment' AND YEAR(le.booked_at) = ? THEN le.amount_cents ELSE 0 END), 0) AS year_payments_cents,
COALESCE(SUM(CASE WHEN le.type = 'consumption' AND YEAR(le.booked_at) = ? THEN -le.amount_cents ELSE 0 END), 0) AS year_costs_cents,
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 " . implode(' AND ', $where) . "
GROUP BY p.id, p.tenant_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);
$stmt->execute($params);
$summaries = [];
while ($row = $stmt->fetch()) {
$summaries[] = [
'participant_id' => (int)$row['participant_id'],
'tenant_id' => (int)$row['tenant_id'],
'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,
'paypal_name' => $row['paypal_name'] !== null ? (string)$row['paypal_name'] : null,
'active' => (int)$row['active'] === 1,
'total_payments_cents' => (int)$row['total_payments_cents'],
'total_costs_cents' => (int)$row['total_costs_cents'],
'total_marks' => (int)$row['total_marks'],
'balance_cents' => (int)$row['balance_cents'],
'year_payments_cents' => (int)$row['year_payments_cents'],
'year_costs_cents' => (int)$row['year_costs_cents'],
'year_marks' => (int)$row['year_marks'],
];
}
return $summaries;
}
/**
* @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
*/
function ledger_fetch_participant_summary(PDO $pdo, int $tenantId, int $participantId, ?int $year = null): ?array
{
$summaries = ledger_fetch_participant_summaries($pdo, $tenantId, [
'participant_ids' => [$participantId],
'year' => $year ?? ledger_current_year(),
]);
return $summaries[0] ?? null;
}
/**
* Options:
* - participant_id: int
* - type: string
* - include_voided: bool
* - limit: int
*
* @return list<array{id: int, tenant_id: int, participant_id: int, display_name: string, type: string, amount_cents: int, marks_count: ?int, unit_price_cents: ?int, booked_at: string, source: string, note: ?string, legacy_table: ?string, legacy_id: ?int, voided_at: ?string}>
*/
function ledger_fetch_recent_entries(PDO $pdo, int $tenantId, array $options = []): array
{
$limit = isset($options['limit']) ? (int)$options['limit'] : 100;
$limit = max(1, min($limit, 500));
$where = ['le.tenant_id = ?'];
$params = [$tenantId];
if (empty($options['include_voided'])) {
$where[] = 'le.voided_at IS NULL';
}
if (!empty($options['participant_id'])) {
$where[] = 'le.participant_id = ?';
$params[] = (int)$options['participant_id'];
}
if (!empty($options['type'])) {
$where[] = 'le.type = ?';
$params[] = trim((string)$options['type']);
}
$sql = "
SELECT
le.id,
le.tenant_id,
le.participant_id,
p.display_name,
le.type,
le.amount_cents,
le.marks_count,
le.unit_price_cents,
le.booked_at,
le.source,
le.note,
le.legacy_table,
le.legacy_id,
le.voided_at
FROM ledger_entries le
JOIN participants p
ON p.tenant_id = le.tenant_id
AND p.id = le.participant_id
WHERE " . implode(' AND ', $where) . "
ORDER BY le.booked_at DESC, le.id DESC
LIMIT {$limit}";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$entries = [];
while ($row = $stmt->fetch()) {
$entries[] = [
'id' => (int)$row['id'],
'tenant_id' => (int)$row['tenant_id'],
'participant_id' => (int)$row['participant_id'],
'display_name' => (string)$row['display_name'],
'type' => (string)$row['type'],
'amount_cents' => (int)$row['amount_cents'],
'marks_count' => $row['marks_count'] !== null ? (int)$row['marks_count'] : null,
'unit_price_cents' => $row['unit_price_cents'] !== null ? (int)$row['unit_price_cents'] : null,
'booked_at' => (string)$row['booked_at'],
'source' => (string)$row['source'],
'note' => $row['note'] !== null ? (string)$row['note'] : null,
'legacy_table' => $row['legacy_table'] !== null ? (string)$row['legacy_table'] : null,
'legacy_id' => $row['legacy_id'] !== null ? (int)$row['legacy_id'] : null,
'voided_at' => $row['voided_at'] !== null ? (string)$row['voided_at'] : null,
];
}
return $entries;
}
+20 -4
View File
@@ -5,7 +5,8 @@ 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.
vergleichbare, pruefbare Zielstruktur. Erste App-Abfragen koennen nun ueber
einen zentralen Ledger-Service gegen `ledger_entries` laufen.
## Ziel
@@ -40,6 +41,13 @@ Ergaenzende Skripte:
```text
scripts/backfill-ledger-entries.php
scripts/check-m4-ledger-migration.php
scripts/check-m4-ledger-service.php
```
Ergaenzende App-Dateien:
```text
app/ledger.php
```
Ausgefuehrter Dev-Stand:
@@ -48,6 +56,8 @@ Ausgefuehrter Dev-Stand:
- 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.
- `app/ledger.php` stellt zentrale Abfragen fuer Tenant-Summen,
Teilnehmer-Summen, einzelne Teilnehmer und letzte Buchungen bereit.
## Abbildungsregeln
@@ -111,6 +121,7 @@ nicht aus den `kl_*`-Tabellen geloescht.
php scripts/backfill-default-tenant.php
php scripts/backfill-ledger-entries.php
php scripts/check-m4-ledger-migration.php
php scripts/check-m4-ledger-service.php
php scripts/check-golden-master.php
```
@@ -128,10 +139,15 @@ Installation aus `.local/` verwendet werden.
- Web-Striche aus `Eintragsart = 2` bleiben als `source = legacy_web`
erkennbar: erfuellt.
- Ledger-Summen entsprechen dem Golden Master: erfuellt.
- Zentrale Ledger-Abfragen liefern dieselben Teilnehmer-Summen wie der Golden
Master: erfuellt.
- Tenant-Summen, aktive Teilnehmer, Einzelteilnehmer und letzte Buchungen sind
ueber `app/ledger.php` abrufbar: erfuellt.
## Aktueller Pruefstatus
- M4 Ledger-Migration: gruen mit 73 Assertions.
- M4 Ledger-Service: gruen mit 102 Assertions.
- M3 SaaS-Basis: weiterhin gruen.
- M3 Tenant-Aufloesung: weiterhin gruen.
- M3 Passwort/E-Mail: weiterhin gruen.
@@ -143,8 +159,8 @@ Installation aus `.local/` verwendet werden.
## Noch offen in M4
- Neues Repository/Service fuer Ledger-Abfragen der App-Seiten.
- Tenant-sichere Dashboard-, Strich-, Einzahlungs- und Listenqueries auf Basis
des Ledgers.
- Erste read-only App-Seite oder Preview auf Basis von `app/ledger.php`.
- Tenant-sichere Dashboard-, Strich-, Einzahlungs- und Listenqueries schrittweise
von Legacy-Tabellen auf das Ledger umstellen.
- Storno-/Reversal-Modell in der UI statt harter Deletes.
- Delta-Strategie fuer den finalen Cutover.
+5 -1
View File
@@ -284,7 +284,7 @@ Uebersicht:
| 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 | Abgeschlossen: Tenants, User, Registrierung, Login, Rollen, Mail-Links und zentrale Mandantenauswahl funktionieren |
| M4 | Datenmigration | Gestartet: Ledger-Tabelle, Legacy-Backfill und Paritaetscheck sind umgesetzt |
| M4 | Datenmigration | Gestartet: Ledger-Tabelle, Legacy-Backfill, Paritaetscheck und Ledger-Service 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 |
@@ -457,12 +457,16 @@ Schritte:
spiegeln: erster Backfill erledigt.
- Legacy-IDs speichern: erledigt ueber `legacy_table` und `legacy_id`.
- Salden gegen Golden-Master vergleichen: Kontrollskript erledigt.
- Ledger-Service fuer Tenant-Summen, Teilnehmer-Summen und letzte Buchungen:
erledigt.
Ergebnis:
- Bestehender Datenbestand kann im neuen Modell abgebildet werden.
- Saldenparitaet ist ueber `scripts/check-m4-ledger-migration.php`
nachweisbar.
- Erste App-Abfragen koennen ueber `app/ledger.php` tenant-sicher aus dem neuen
Modell lesen.
- Dokumentation: `docs/m4-data-migration.md`.
Abhaengigkeiten:
+189
View File
@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
require __DIR__ . '/dev-db.php';
require __DIR__ . '/golden-master-data.php';
require __DIR__ . '/../app/ledger.php';
function m4_service_env(string $name, string $default): string
{
$value = getenv($name);
if ($value === false || trim($value) === '') {
return $default;
}
return trim($value);
}
function m4_service_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_service_assert(string $label, bool $condition, array &$failures, int &$passes): void
{
m4_service_assert_equal($label, true, $condition, $failures, $passes);
}
function m4_service_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{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 m4_service_totals_from_summaries(array $summaries): array
{
$totals = ledger_empty_totals();
foreach ($summaries as $summary) {
$totals['total_payments_cents'] += (int)$summary['total_payments_cents'];
$totals['total_costs_cents'] += (int)$summary['total_costs_cents'];
$totals['total_marks'] += (int)$summary['total_marks'];
$totals['balance_cents'] += (int)$summary['balance_cents'];
$totals['year_payments_cents'] += (int)$summary['year_payments_cents'];
$totals['year_costs_cents'] += (int)$summary['year_costs_cents'];
$totals['year_marks'] += (int)$summary['year_marks'];
}
return $totals;
}
$pdo = dev_pdo();
dev_apply_schema($pdo);
$tenantSlug = m4_service_env('M4_DEFAULT_TENANT_SLUG', m4_service_env('M3_DEFAULT_TENANT_SLUG', 'default'));
$tenantId = m4_service_fetch_tenant_id($pdo, $tenantSlug);
$expected = gm_expected_summaries();
$emails = array_keys($expected);
$failures = [];
$passes = 0;
$summaries = ledger_fetch_participant_summaries($pdo, $tenantId, [
'email_norms' => $emails,
'year' => gm_current_year(),
]);
$summariesByEmail = [];
foreach ($summaries as $summary) {
if ($summary['email_norm'] !== null) {
$summariesByEmail[$summary['email_norm']] = $summary;
}
}
m4_service_assert_equal('ledger service summary count', count($expected), count($summariesByEmail), $failures, $passes);
$keyMap = [
'total_payments' => 'total_payments_cents',
'total_costs' => 'total_costs_cents',
'total_marks' => 'total_marks',
'balance' => 'balance_cents',
'year_payments' => 'year_payments_cents',
'year_costs' => 'year_costs_cents',
'year_marks' => 'year_marks',
];
foreach ($expected as $email => $expectedSummary) {
m4_service_assert("ledger service summary exists {$email}", isset($summariesByEmail[$email]), $failures, $passes);
if (!isset($summariesByEmail[$email])) {
continue;
}
foreach ($keyMap as $expectedKey => $actualKey) {
m4_service_assert_equal(
"ledger service {$email} {$expectedKey}",
$expectedSummary[$expectedKey],
$summariesByEmail[$email][$actualKey],
$failures,
$passes
);
}
}
$allSummaries = ledger_fetch_participant_summaries($pdo, $tenantId, [
'year' => gm_current_year(),
]);
$tenantTotals = ledger_fetch_tenant_totals($pdo, $tenantId, gm_current_year());
foreach (m4_service_totals_from_summaries($allSummaries) as $key => $value) {
m4_service_assert_equal("ledger service tenant total {$key}", $value, $tenantTotals[$key], $failures, $passes);
}
$stmt = $pdo->prepare('SELECT COUNT(*) FROM participants WHERE tenant_id = ? AND active = 1');
$stmt->execute([$tenantId]);
$activeExpectedCount = (int)$stmt->fetchColumn();
$activeSummaries = ledger_fetch_participant_summaries($pdo, $tenantId, ['active_only' => true]);
m4_service_assert_equal('ledger service active participant count', $activeExpectedCount, count($activeSummaries), $failures, $passes);
$paypalSummary = $summariesByEmail['gm-paypal@test.local'] ?? null;
m4_service_assert('ledger service paypal summary is present', $paypalSummary !== null, $failures, $passes);
if ($paypalSummary !== null) {
$singleSummary = ledger_fetch_participant_summary($pdo, $tenantId, $paypalSummary['participant_id'], gm_current_year());
m4_service_assert('ledger service single participant summary is present', $singleSummary !== null, $failures, $passes);
if ($singleSummary !== null) {
m4_service_assert_equal('ledger service single participant email', 'gm-paypal@test.local', $singleSummary['email_norm'], $failures, $passes);
m4_service_assert_equal('ledger service single participant balance', 650, $singleSummary['balance_cents'], $failures, $passes);
}
$paypalEntries = ledger_fetch_recent_entries($pdo, $tenantId, [
'participant_id' => $paypalSummary['participant_id'],
'limit' => 100,
]);
m4_service_assert_equal('ledger service paypal entry count', 2, count($paypalEntries), $failures, $passes);
foreach ($paypalEntries as $entry) {
m4_service_assert_equal('ledger service paypal entry tenant', $tenantId, $entry['tenant_id'], $failures, $passes);
m4_service_assert_equal('ledger service paypal entry participant', $paypalSummary['participant_id'], $entry['participant_id'], $failures, $passes);
}
}
$recentEntries = ledger_fetch_recent_entries($pdo, $tenantId, ['limit' => 5]);
m4_service_assert_equal('ledger service recent entry limit', 5, count($recentEntries), $failures, $passes);
$previousBookedAt = null;
foreach ($recentEntries as $entry) {
m4_service_assert_equal('ledger service recent entry tenant', $tenantId, $entry['tenant_id'], $failures, $passes);
m4_service_assert('ledger service recent entry has amount', $entry['amount_cents'] !== 0, $failures, $passes);
if ($previousBookedAt !== null) {
m4_service_assert('ledger service recent entries are sorted', $previousBookedAt >= $entry['booked_at'], $failures, $passes);
}
$previousBookedAt = $entry['booked_at'];
}
$legacyConsumptionCount = (int)$pdo->query('SELECT COUNT(*) FROM kl_Kaffeeverbrauch')->fetchColumn();
$consumptionEntries = ledger_fetch_recent_entries($pdo, $tenantId, [
'type' => 'consumption',
'limit' => 100,
]);
m4_service_assert_equal('ledger service consumption entry count', $legacyConsumptionCount, count($consumptionEntries), $failures, $passes);
m4_service_assert_equal('ledger service empty participant id filter', [], ledger_fetch_participant_summaries($pdo, $tenantId, [
'participant_ids' => [],
]), $failures, $passes);
m4_service_assert_equal('ledger service unknown tenant summaries', [], ledger_fetch_participant_summaries($pdo, 0), $failures, $passes);
m4_service_assert_equal('ledger service unknown tenant entries', [], ledger_fetch_recent_entries($pdo, 0), $failures, $passes);
m4_service_assert_equal('ledger service unknown tenant totals', ledger_empty_totals(), ledger_fetch_tenant_totals($pdo, 0), $failures, $passes);
if ($failures !== []) {
echo "\nM4 ledger service check failed with " . count($failures) . " failure(s):\n";
foreach ($failures as $failure) {
echo "- {$failure}\n";
}
exit(1);
}
echo "\nM4 ledger service check passed with {$passes} assertions.\n";