M4 Ledger-Migration fuer Legacy-Buchungen starten
- ledger_entries als tenant-sichere Buchungstabelle ergaenzen - Legacy-Einzahlungen und Kaffeeverbrauch idempotent spiegeln - M4-Paritaetscheck und Datenmigrationsdoku ergaenzen
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/dev-db.php';
|
||||
|
||||
function m4_env(string $name, string $default): string
|
||||
{
|
||||
$value = getenv($name);
|
||||
if ($value === false || trim($value) === '') {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return trim($value);
|
||||
}
|
||||
|
||||
function m4_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}' was not found. Run scripts/backfill-default-tenant.php first.");
|
||||
}
|
||||
|
||||
return (int)$tenantId;
|
||||
}
|
||||
|
||||
function m4_count_missing_payment_participants(PDO $pdo, int $tenantId): int
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT COUNT(*)
|
||||
FROM kl_Einzahlungen e
|
||||
LEFT JOIN participants p
|
||||
ON p.tenant_id = ?
|
||||
AND p.legacy_mitarbeiter_id = e.MitarbeiterID
|
||||
WHERE p.id IS NULL'
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
|
||||
return (int)$stmt->fetchColumn();
|
||||
}
|
||||
|
||||
function m4_count_missing_consumption_participants(PDO $pdo, int $tenantId): int
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT COUNT(*)
|
||||
FROM kl_Kaffeeverbrauch v
|
||||
LEFT JOIN participants p
|
||||
ON p.tenant_id = ?
|
||||
AND p.legacy_mitarbeiter_id = v.MitarbeiterID
|
||||
WHERE p.id IS NULL'
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
|
||||
return (int)$stmt->fetchColumn();
|
||||
}
|
||||
|
||||
$pdo = dev_pdo();
|
||||
$applied = dev_apply_migrations($pdo);
|
||||
$tenantSlug = m4_env('M4_DEFAULT_TENANT_SLUG', m4_env('M3_DEFAULT_TENANT_SLUG', 'default'));
|
||||
|
||||
$stats = [
|
||||
'applied_migrations' => count($applied),
|
||||
'stale_payments_removed' => 0,
|
||||
'stale_consumptions_removed' => 0,
|
||||
'payments_mirrored' => 0,
|
||||
'consumptions_mirrored' => 0,
|
||||
];
|
||||
|
||||
try {
|
||||
$tenantId = m4_fetch_tenant_id($pdo, $tenantSlug);
|
||||
$missingPayments = m4_count_missing_payment_participants($pdo, $tenantId);
|
||||
$missingConsumptions = m4_count_missing_consumption_participants($pdo, $tenantId);
|
||||
if ($missingPayments > 0 || $missingConsumptions > 0) {
|
||||
throw new RuntimeException(
|
||||
"Missing participant mappings for {$missingPayments} payment(s) and {$missingConsumptions} consumption(s)."
|
||||
);
|
||||
}
|
||||
|
||||
$pdo->beginTransaction();
|
||||
|
||||
$removeStalePayments = $pdo->prepare(
|
||||
"DELETE le
|
||||
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 e.EinzahlungsID IS NULL"
|
||||
);
|
||||
$removeStalePayments->execute([$tenantId]);
|
||||
$stats['stale_payments_removed'] = $removeStalePayments->rowCount();
|
||||
|
||||
$removeStaleConsumptions = $pdo->prepare(
|
||||
"DELETE le
|
||||
FROM ledger_entries le
|
||||
LEFT JOIN kl_Kaffeeverbrauch v
|
||||
ON v.VerbrauchID = le.legacy_id
|
||||
WHERE le.tenant_id = ?
|
||||
AND le.legacy_table = 'kl_Kaffeeverbrauch'
|
||||
AND v.VerbrauchID IS NULL"
|
||||
);
|
||||
$removeStaleConsumptions->execute([$tenantId]);
|
||||
$stats['stale_consumptions_removed'] = $removeStaleConsumptions->rowCount();
|
||||
|
||||
$insertPayments = $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
|
||||
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)"
|
||||
);
|
||||
$insertPayments->execute([$tenantId]);
|
||||
|
||||
$insertConsumptions = $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
|
||||
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)"
|
||||
);
|
||||
$insertConsumptions->execute([$tenantId]);
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT COUNT(*)
|
||||
FROM ledger_entries
|
||||
WHERE tenant_id = ?
|
||||
AND legacy_table = 'kl_Einzahlungen'"
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
$stats['payments_mirrored'] = (int)$stmt->fetchColumn();
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT COUNT(*)
|
||||
FROM ledger_entries
|
||||
WHERE tenant_id = ?
|
||||
AND legacy_table = 'kl_Kaffeeverbrauch'"
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
$stats['consumptions_mirrored'] = (int)$stmt->fetchColumn();
|
||||
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
|
||||
fwrite(STDERR, "M4 ledger backfill failed: {$e->getMessage()}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "M4 ledger backfill complete.\n";
|
||||
echo "Tenant: {$tenantSlug}\n";
|
||||
echo "Applied migrations: {$stats['applied_migrations']}\n";
|
||||
echo "Stale payments removed: {$stats['stale_payments_removed']}\n";
|
||||
echo "Stale consumptions removed: {$stats['stale_consumptions_removed']}\n";
|
||||
echo "Payments mirrored: {$stats['payments_mirrored']}\n";
|
||||
echo "Consumptions mirrored: {$stats['consumptions_mirrored']}\n";
|
||||
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/dev-db.php';
|
||||
require __DIR__ . '/golden-master-data.php';
|
||||
|
||||
function m4_check_env(string $name, string $default): string
|
||||
{
|
||||
$value = getenv($name);
|
||||
if ($value === false || trim($value) === '') {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return trim($value);
|
||||
}
|
||||
|
||||
function m4_check_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_check_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<string, array{total_payments: int, total_costs: int, total_marks: int, balance: int, year_payments: int, year_costs: int, year_marks: int}>
|
||||
*/
|
||||
function m4_check_fetch_ledger_summaries(PDO $pdo, int $tenantId, array $emails): array
|
||||
{
|
||||
$placeholders = implode(',', array_fill(0, count($emails), '?'));
|
||||
$year = gm_current_year();
|
||||
$sql = "
|
||||
SELECT
|
||||
p.email_norm AS email,
|
||||
COALESCE(SUM(CASE WHEN le.type = 'payment' THEN le.amount_cents ELSE 0 END), 0) AS total_payments,
|
||||
COALESCE(SUM(CASE WHEN le.type = 'consumption' THEN -le.amount_cents ELSE 0 END), 0) AS total_costs,
|
||||
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,
|
||||
COALESCE(SUM(CASE WHEN le.type = 'payment' AND YEAR(le.booked_at) = ? THEN le.amount_cents ELSE 0 END), 0) AS year_payments,
|
||||
COALESCE(SUM(CASE WHEN le.type = 'consumption' AND YEAR(le.booked_at) = ? THEN -le.amount_cents ELSE 0 END), 0) AS year_costs,
|
||||
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 p.tenant_id = ?
|
||||
AND p.email_norm IN ({$placeholders})
|
||||
GROUP BY p.id, p.email_norm
|
||||
";
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute(array_merge([$year, $year, $year, $tenantId], $emails));
|
||||
|
||||
$summaries = [];
|
||||
while ($row = $stmt->fetch()) {
|
||||
$summaries[(string)$row['email']] = [
|
||||
'total_payments' => (int)$row['total_payments'],
|
||||
'total_costs' => (int)$row['total_costs'],
|
||||
'total_marks' => (int)$row['total_marks'],
|
||||
'balance' => (int)$row['balance'],
|
||||
'year_payments' => (int)$row['year_payments'],
|
||||
'year_costs' => (int)$row['year_costs'],
|
||||
'year_marks' => (int)$row['year_marks'],
|
||||
];
|
||||
}
|
||||
|
||||
return $summaries;
|
||||
}
|
||||
|
||||
$pdo = dev_pdo();
|
||||
$tenantSlug = m4_check_env('M4_DEFAULT_TENANT_SLUG', m4_check_env('M3_DEFAULT_TENANT_SLUG', 'default'));
|
||||
$tenantId = m4_check_fetch_tenant_id($pdo, $tenantSlug);
|
||||
$expected = gm_expected_summaries();
|
||||
$emails = array_keys($expected);
|
||||
$failures = [];
|
||||
$passes = 0;
|
||||
|
||||
$legacyPaymentCount = (int)$pdo->query('SELECT COUNT(*) FROM kl_Einzahlungen')->fetchColumn();
|
||||
$legacyConsumptionCount = (int)$pdo->query('SELECT COUNT(*) FROM kl_Kaffeeverbrauch')->fetchColumn();
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT COUNT(*)
|
||||
FROM ledger_entries
|
||||
WHERE tenant_id = ?
|
||||
AND legacy_table IN ('kl_Einzahlungen', 'kl_Kaffeeverbrauch')"
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
$ledgerLegacyCount = (int)$stmt->fetchColumn();
|
||||
m4_check_assert_equal(
|
||||
'legacy ledger row count matches source rows',
|
||||
$legacyPaymentCount + $legacyConsumptionCount,
|
||||
$ledgerLegacyCount,
|
||||
$failures,
|
||||
$passes
|
||||
);
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT
|
||||
COUNT(*) AS total_rows,
|
||||
COUNT(DISTINCT CONCAT(legacy_table, '#', legacy_id)) AS distinct_legacy_rows
|
||||
FROM ledger_entries
|
||||
WHERE tenant_id = ?
|
||||
AND legacy_table IN ('kl_Einzahlungen', 'kl_Kaffeeverbrauch')"
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
$legacyDistinct = $stmt->fetch();
|
||||
m4_check_assert_equal(
|
||||
'legacy ledger ids are unique',
|
||||
(int)$legacyDistinct['total_rows'],
|
||||
(int)$legacyDistinct['distinct_legacy_rows'],
|
||||
$failures,
|
||||
$passes
|
||||
);
|
||||
|
||||
$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 e.EinzahlungsID IS NULL"
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
m4_check_assert_equal('no stale payment ledger rows', 0, (int)$stmt->fetchColumn(), $failures, $passes);
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT COUNT(*)
|
||||
FROM ledger_entries le
|
||||
LEFT JOIN kl_Kaffeeverbrauch v ON v.VerbrauchID = le.legacy_id
|
||||
WHERE le.tenant_id = ?
|
||||
AND le.legacy_table = 'kl_Kaffeeverbrauch'
|
||||
AND v.VerbrauchID IS NULL"
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
m4_check_assert_equal('no stale 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]);
|
||||
m4_check_assert_equal('payment amounts are positive', 0, (int)$stmt->fetchColumn(), $failures, $passes);
|
||||
|
||||
$stmt = $pdo->prepare("SELECT COUNT(*) FROM ledger_entries WHERE tenant_id = ? AND type = 'consumption' AND amount_cents >= 0");
|
||||
$stmt->execute([$tenantId]);
|
||||
m4_check_assert_equal('consumption amounts are negative', 0, (int)$stmt->fetchColumn(), $failures, $passes);
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT COUNT(*)
|
||||
FROM ledger_entries
|
||||
WHERE tenant_id = ?
|
||||
AND type = 'consumption'
|
||||
AND (marks_count IS NULL OR unit_price_cents IS NULL)"
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
m4_check_assert_equal('consumption rows keep marks and unit price', 0, (int)$stmt->fetchColumn(), $failures, $passes);
|
||||
|
||||
$webLegacyCount = (int)$pdo
|
||||
->query('SELECT COUNT(*) FROM kl_Kaffeeverbrauch WHERE Eintragsart = 2')
|
||||
->fetchColumn();
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT COUNT(*)
|
||||
FROM ledger_entries
|
||||
WHERE tenant_id = ?
|
||||
AND legacy_table = 'kl_Kaffeeverbrauch'
|
||||
AND source = 'legacy_web'"
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
m4_check_assert_equal('web consumption source is preserved', $webLegacyCount, (int)$stmt->fetchColumn(), $failures, $passes);
|
||||
|
||||
$actualSummaries = m4_check_fetch_ledger_summaries($pdo, $tenantId, $emails);
|
||||
m4_check_assert_equal('ledger member summary count', count($expected), count($actualSummaries), $failures, $passes);
|
||||
|
||||
foreach ($expected as $email => $summary) {
|
||||
m4_check_assert_equal("ledger summary exists {$email}", true, isset($actualSummaries[$email]), $failures, $passes);
|
||||
if (!isset($actualSummaries[$email])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($summary as $key => $expectedValue) {
|
||||
m4_check_assert_equal("ledger {$email} {$key}", $expectedValue, $actualSummaries[$email][$key], $failures, $passes);
|
||||
}
|
||||
}
|
||||
|
||||
if ($failures !== []) {
|
||||
echo "\nM4 ledger migration check failed with " . count($failures) . " failure(s):\n";
|
||||
foreach ($failures as $failure) {
|
||||
echo "- {$failure}\n";
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "\nM4 ledger migration check passed with {$passes} assertions.\n";
|
||||
Reference in New Issue
Block a user