Legacy-Abbau Schritt 1: Dual-Write in die kl_*-Tabellen entfernt

Die Migration in participants/ledger_entries ist abgeschlossen: kein
Teilnehmer traegt noch eine legacy_mitarbeiter_id, kein Journaleintrag eine
legacy_table. Damit war der jeweils zweite Zweig ("Default-Mandant schreibt
zusaetzlich nach kl_Einzahlungen/kl_Kaffeeverbrauch/kl_Mitarbeiter") in
sechs Dateien nicht mehr erreichbar - er musste aber bei jeder Aenderung
mitgepflegt werden, zuletzt bei der Bemerkung fuer Einzahlungen.

Entfernt:
- Dual-Write beim Buchen: einzahlung.php, stricheintragen.php, index.php,
  jahresauswertung.php, app/imports.php, app/paypal-inbox.php
- Dual-Write in der Mitgliederverwaltung: ledger_create_participant,
  ledger_update_participant, ledger_set_participant_active,
  ledger_anonymize_participant
- die verwaisten Spiegelfunktionen ledger_mirror_legacy_payment,
  ledger_mirror_legacy_consumption und ledger_void_entry_by_legacy_id

Nebenbei behoben: kaffeeliste.php hat die Teilnehmer-Detailseite nur fuer
Mitglieder mit legacy_mitarbeiter_id verlinkt - die hat seit der Migration
niemand mehr, die Seite war also fuer alle unerreichbar. Verlinkt und
adressiert wird jetzt ueber participant_id; "user_id" bleibt als Alias
erhalten. Der Mandanten-Isolationstest prueft jetzt ledger_void_entry, also
den Pfad, den letzteneintraege.php tatsaechlich nutzt.

Pruefskripte unveraendert gegenueber der Baseline vor dem Umbau
(http-smoke 27/6, role-matrix 55/0); die Isolationspruefung steigt von
9 auf 11 PASS bei gleichem vorbestehendem Fehler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 20:57:49 +02:00
co-authored by Claude Opus 4.8
parent dd6e1e77b0
commit eec7a2fef2
11 changed files with 98 additions and 482 deletions
+10 -24
View File
@@ -188,10 +188,8 @@ function imports_fetch_batch(PDO $pdo, int $tenantId, int $batchId): ?array
}
/**
* Commits every 'matched' row of a previewed batch into the ledger: for
* participants with a legacy_mitarbeiter_id (default tenant), a matching
* kl_Einzahlungen row is dual-written and mirrored; every other tenant is
* booked straight into the ledger. Already-committed batches are rejected.
* Commits every 'matched' row of a previewed batch straight into the ledger.
* Already-committed batches are rejected.
*
* @return array{imported: int}
*/
@@ -208,7 +206,7 @@ function imports_commit_batch(PDO $pdo, int $tenantId, int $batchId): array
}
$stmt = $pdo->prepare(
"SELECT r.id, r.participant_id, r.amount_cents, r.booked_at, p.legacy_mitarbeiter_id
"SELECT r.id, r.participant_id, r.amount_cents, r.booked_at
FROM payment_import_rows r
JOIN participants p ON p.id = r.participant_id
WHERE r.batch_id = ? AND r.status = 'matched'"
@@ -220,30 +218,18 @@ function imports_commit_batch(PDO $pdo, int $tenantId, int $batchId): array
$pdo->beginTransaction();
try {
$insertLegacy = $pdo->prepare(
'INSERT INTO kl_Einzahlungen (MitarbeiterID, Betrag, Datum) VALUES (?, ?, ?)'
);
$updateRow = $pdo->prepare(
"UPDATE payment_import_rows SET status = 'imported', ledger_entry_id = ? WHERE id = ?"
);
foreach ($rows as $row) {
$legacyMitarbeiterId = $row['legacy_mitarbeiter_id'] !== null ? (int)$row['legacy_mitarbeiter_id'] : null;
$amountCents = (int)$row['amount_cents'];
if ($legacyMitarbeiterId !== null) {
$insertLegacy->execute([$legacyMitarbeiterId, $amountCents / 100, $row['booked_at']]);
$legacyPaymentId = (int)$pdo->lastInsertId();
ledger_mirror_legacy_payment($pdo, $tenantId, $legacyPaymentId);
$entryStmt = $pdo->prepare(
"SELECT id FROM ledger_entries WHERE tenant_id = ? AND legacy_table = 'kl_Einzahlungen' AND legacy_id = ?"
);
$entryStmt->execute([$tenantId, $legacyPaymentId]);
$ledgerEntryId = (int)$entryStmt->fetchColumn();
} else {
$ledgerEntryId = ledger_record_payment($pdo, $tenantId, (int)$row['participant_id'], $amountCents, 'csv_import');
}
$ledgerEntryId = ledger_record_payment(
$pdo,
$tenantId,
(int)$row['participant_id'],
(int)$row['amount_cents'],
'csv_import'
);
$updateRow->execute([$ledgerEntryId, $row['id']]);
$imported++;
+24 -243
View File
@@ -305,60 +305,6 @@ function ledger_fetch_participant_summary_by_legacy_id(PDO $pdo, int $tenantId,
return $summaries[0] ?? null;
}
function ledger_mirror_legacy_consumption(PDO $pdo, int $tenantId, int $legacyConsumptionId): 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,
'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
WHERE v.VerbrauchID = ?
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, $legacyConsumptionId]);
$check = $pdo->prepare(
"SELECT COUNT(*)
FROM ledger_entries
WHERE tenant_id = ?
AND legacy_table = 'kl_Kaffeeverbrauch'
AND legacy_id = ?"
);
$check->execute([$tenantId, $legacyConsumptionId]);
if ((int)$check->fetchColumn() !== 1) {
throw new RuntimeException('Der Legacy-Strich konnte nicht ins Ledger gespiegelt werden.');
}
}
function ledger_is_default_tenant(PDO $pdo, int $tenantId): bool
{
$tenant = ledger_fetch_default_tenant($pdo);
@@ -412,10 +358,7 @@ function ledger_fetch_participants_for_admin(PDO $pdo, int $tenantId): array
}
/**
* Creates a tenant-scoped participant. For the default tenant, a matching
* kl_Mitarbeiter row is dual-written too, so the still-legacy bulk entry
* pages (stricheintragen.php, einzahlung.php) keep listing the member; other
* tenants have no legacy shadow table and get a participants-only row.
* Creates a tenant-scoped participant.
*
* @throws Throwable on constraint violations (e.g. duplicate email)
*/
@@ -427,35 +370,14 @@ function ledger_create_participant(PDO $pdo, int $tenantId, string $displayName,
$paypalName = $paypalName !== null ? trim($paypalName) : null;
$paypalName = $paypalName !== '' ? $paypalName : null;
$pdo->beginTransaction();
try {
$legacyMitarbeiterId = null;
if (ledger_is_default_tenant($pdo, $tenantId)) {
$stmt = $pdo->prepare(
'INSERT INTO kl_Mitarbeiter (Name, Email, paypalname, aktiv, admin) VALUES (?, ?, ?, ?, 0)'
);
$stmt->execute([$displayName, $email, $paypalName, $active ? 1 : 0]);
$legacyMitarbeiterId = (int)$pdo->lastInsertId();
}
$stmt = $pdo->prepare(
'INSERT INTO participants
(tenant_id, display_name, email, email_norm, paypal_name, active)
VALUES (?, ?, ?, ?, ?, ?)'
);
$stmt->execute([$tenantId, $displayName, $email, $emailNorm, $paypalName, $active ? 1 : 0]);
$stmt = $pdo->prepare(
'INSERT INTO participants
(tenant_id, display_name, email, email_norm, paypal_name, active, legacy_mitarbeiter_id)
VALUES (?, ?, ?, ?, ?, ?, ?)'
);
$stmt->execute([$tenantId, $displayName, $email, $emailNorm, $paypalName, $active ? 1 : 0, $legacyMitarbeiterId]);
$participantId = (int)$pdo->lastInsertId();
$pdo->commit();
return $participantId;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
return (int)$pdo->lastInsertId();
}
function ledger_update_participant(PDO $pdo, int $tenantId, int $participantId, string $displayName, string $email, ?string $paypalName, bool $active): bool
@@ -466,168 +388,42 @@ function ledger_update_participant(PDO $pdo, int $tenantId, int $participantId,
$paypalName = $paypalName !== null ? trim($paypalName) : null;
$paypalName = $paypalName !== '' ? $paypalName : null;
$stmt = $pdo->prepare('SELECT legacy_mitarbeiter_id FROM participants WHERE id = ? AND tenant_id = ?');
$stmt->execute([$participantId, $tenantId]);
$row = $stmt->fetch();
if ($row === false) {
return false;
}
$legacyMitarbeiterId = $row['legacy_mitarbeiter_id'] !== null ? (int)$row['legacy_mitarbeiter_id'] : null;
$stmt = $pdo->prepare(
'UPDATE participants
SET display_name = ?, email = ?, email_norm = ?, paypal_name = ?, active = ?
WHERE id = ? AND tenant_id = ?'
);
$stmt->execute([$displayName, $email, $emailNorm, $paypalName, $active ? 1 : 0, $participantId, $tenantId]);
try {
$pdo->beginTransaction();
if ($legacyMitarbeiterId !== null) {
$stmt = $pdo->prepare(
'UPDATE kl_Mitarbeiter SET Name = ?, Email = ?, paypalname = ?, aktiv = ? WHERE MitarbeiterID = ?'
);
$stmt->execute([$displayName, $email, $paypalName, $active ? 1 : 0, $legacyMitarbeiterId]);
}
$stmt = $pdo->prepare(
'UPDATE participants
SET display_name = ?, email = ?, email_norm = ?, paypal_name = ?, active = ?
WHERE id = ? AND tenant_id = ?'
);
$stmt->execute([$displayName, $email, $emailNorm, $paypalName, $active ? 1 : 0, $participantId, $tenantId]);
$pdo->commit();
return true;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
return false;
}
return $stmt->rowCount() >= 0;
}
function ledger_set_participant_active(PDO $pdo, int $tenantId, int $participantId, bool $active): bool
{
$stmt = $pdo->prepare('SELECT legacy_mitarbeiter_id FROM participants WHERE id = ? AND tenant_id = ?');
$stmt->execute([$participantId, $tenantId]);
$row = $stmt->fetch();
if ($row === false) {
return false;
}
$legacyMitarbeiterId = $row['legacy_mitarbeiter_id'] !== null ? (int)$row['legacy_mitarbeiter_id'] : null;
$stmt = $pdo->prepare('UPDATE participants SET active = ? WHERE id = ? AND tenant_id = ?');
$stmt->execute([$active ? 1 : 0, $participantId, $tenantId]);
try {
$pdo->beginTransaction();
if ($legacyMitarbeiterId !== null) {
$pdo->prepare('UPDATE kl_Mitarbeiter SET aktiv = ? WHERE MitarbeiterID = ?')
->execute([$active ? 1 : 0, $legacyMitarbeiterId]);
}
$pdo->prepare('UPDATE participants SET active = ? WHERE id = ? AND tenant_id = ?')
->execute([$active ? 1 : 0, $participantId, $tenantId]);
$pdo->commit();
return true;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
return false;
}
return true;
}
/**
* Anonymizes a participant (GDPR-style erasure) instead of deleting them:
* name and email are replaced with a non-identifying placeholder and the
* participant is deactivated, but their ledger history stays intact for
* the tenant's bookkeeping. For the default tenant, the linked
* kl_Mitarbeiter row is anonymized the same way (its email column is
* NOT NULL UNIQUE, so it gets a placeholder instead of NULL).
* the tenant's bookkeeping.
*/
function ledger_anonymize_participant(PDO $pdo, int $tenantId, int $participantId): bool
{
$stmt = $pdo->prepare('SELECT legacy_mitarbeiter_id FROM participants WHERE id = ? AND tenant_id = ?');
$stmt->execute([$participantId, $tenantId]);
$row = $stmt->fetch();
if ($row === false) {
return false;
}
$legacyMitarbeiterId = $row['legacy_mitarbeiter_id'] !== null ? (int)$row['legacy_mitarbeiter_id'] : null;
$placeholderName = 'Gelöschter Teilnehmer #' . $participantId;
$placeholderEmail = "deleted-participant-{$participantId}@invalid.local";
try {
$pdo->beginTransaction();
if ($legacyMitarbeiterId !== null) {
$pdo->prepare('UPDATE kl_Mitarbeiter SET Name = ?, Email = ?, paypalname = NULL, aktiv = 0 WHERE MitarbeiterID = ?')
->execute([$placeholderName, $placeholderEmail, $legacyMitarbeiterId]);
}
$pdo->prepare(
'UPDATE participants
SET display_name = ?, email = NULL, email_norm = NULL, paypal_name = NULL, active = 0, user_id = NULL
WHERE id = ? AND tenant_id = ?'
)->execute([$placeholderName, $participantId, $tenantId]);
$pdo->commit();
return true;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
return false;
}
}
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',
e.Bemerkung,
'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)"
'UPDATE participants
SET display_name = ?, email = NULL, email_norm = NULL, paypal_name = NULL, active = 0, user_id = NULL
WHERE id = ? AND tenant_id = ?'
);
$stmt->execute([$tenantId, $legacyPaymentId]);
$stmt->execute([$placeholderName, $participantId, $tenantId]);
$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.');
}
return $stmt->rowCount() > 0;
}
/**
@@ -695,21 +491,6 @@ function ledger_void_entry(PDO $pdo, int $tenantId, int $entryId, ?string $expec
return true;
}
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;
}
/**
* Storniert den juengsten, noch nicht stornierten Web-Selbsteintrag eines
* Teilnehmers (Quelle 'self_entry' bei SaaS-nativen Mandanten bzw. 'legacy_web'
-18
View File
@@ -102,24 +102,6 @@ function paypal_inbox_extract_token(string $address): ?string
*/
function paypal_book_payment(PDO $pdo, int $tenantId, array $participant, int $netCents, ?int $actorUserId, ?string $note = null): int
{
$legacyMitarbeiterId = isset($participant['legacy_mitarbeiter_id']) && $participant['legacy_mitarbeiter_id'] !== null
? (int) $participant['legacy_mitarbeiter_id']
: 0;
if ($legacyMitarbeiterId > 0) {
$stmt = $pdo->prepare('INSERT INTO kl_Einzahlungen (MitarbeiterID, Betrag, Bemerkung, Datum) VALUES (?, ?, ?, ?)');
$stmt->execute([$legacyMitarbeiterId, $netCents / 100, $note, date('Y-m-d H:i:s')]);
$legacyPaymentId = (int) $pdo->lastInsertId();
ledger_mirror_legacy_payment($pdo, $tenantId, $legacyPaymentId);
$idStmt = $pdo->prepare(
"SELECT id FROM ledger_entries WHERE tenant_id = ? AND legacy_table = 'kl_Einzahlungen' AND legacy_id = ? LIMIT 1"
);
$idStmt->execute([$tenantId, $legacyPaymentId]);
return (int) $idStmt->fetchColumn();
}
return ledger_record_payment($pdo, $tenantId, (int) $participant['participant_id'], $netCents, 'paypal_import', $actorUserId, $note);
}