M6: CSV-Import mit Vorschau, Dublettenpruefung und Audit-Trail
csvupload.php verarbeitete CSV-Zeilen bisher sofort beim Upload, ohne Vorschau, ohne Zugriffskontrolle (nur CSRF) und schrieb ausschliesslich in die global unscoped kl_Einzahlungen-Tabelle. - Neue Tabellen payment_import_batches/payment_import_rows protokollieren jeden Import: Datei, Pruefsumme, jede Zeile mit Rohwerten, erkanntem Mitglied, Status und erzeugter Ledger-Zeile. - Zweistufiger Ablauf: Hochladen zeigt nur eine Vorschau (matched/ duplicate/unmatched/invalid), erst "Import bestaetigen" bucht. - Zuordnung per paypal_name oder display_name (tenant-scoped), Dubletten- pruefung gegen bestehende nicht-stornierte Ledger-Zahlungen. - Buchung folgt dem etablierten Zweig-Muster: Default-Mandant per Dual-Write nach kl_Einzahlungen plus Spiegelung, alle anderen Mandanten direkt ueber ledger_record_payment(). - Zugriffskontrolle ergaenzt (owner/admin/treasurer + Legacy-Fallback). - Live getestet: Treffer, unbekannter Name, ungueltiger Betrag korrekt klassifiziert; Import bestaetigt mit korrektem Dual-Write; erneuter Upload derselben Datei erkennt die Zeile korrekt als Dublette. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+265
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once __DIR__ . '/ledger.php';
|
||||
|
||||
/**
|
||||
* Parses a decimal amount ("12,34" or "12.34") into cents. Returns null for
|
||||
* anything that is not a plausible positive amount.
|
||||
*/
|
||||
function imports_normalize_amount(string $raw): ?int
|
||||
{
|
||||
$normalized = str_replace(',', '.', trim($raw));
|
||||
if (!preg_match('/^-?[0-9]+(?:\.[0-9]{1,2})?$/', $normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cents = (int)round(((float)$normalized) * 100);
|
||||
|
||||
return $cents > 0 ? $cents : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a CSV name against a participant's paypal_name or display_name
|
||||
* (case-insensitive), the same rule the legacy CSV import used.
|
||||
*
|
||||
* @return array{participant_id: int, legacy_mitarbeiter_id: ?int, display_name: string}|null
|
||||
*/
|
||||
function imports_find_participant(PDO $pdo, int $tenantId, string $name): ?array
|
||||
{
|
||||
$name = trim($name);
|
||||
if ($name === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT id, legacy_mitarbeiter_id, display_name
|
||||
FROM participants
|
||||
WHERE tenant_id = ?
|
||||
AND (LOWER(paypal_name) = LOWER(?) OR LOWER(display_name) = LOWER(?))
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([$tenantId, $name, $name]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
if ($row === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'participant_id' => (int)$row['id'],
|
||||
'legacy_mitarbeiter_id' => $row['legacy_mitarbeiter_id'] !== null ? (int)$row['legacy_mitarbeiter_id'] : null,
|
||||
'display_name' => (string)$row['display_name'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* A payment counts as a duplicate if the same participant already has a
|
||||
* non-voided ledger payment of the same amount on the same day.
|
||||
*/
|
||||
function imports_is_duplicate(PDO $pdo, int $tenantId, int $participantId, int $amountCents, string $bookedAtDate): bool
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT COUNT(*)
|
||||
FROM ledger_entries
|
||||
WHERE tenant_id = ?
|
||||
AND participant_id = ?
|
||||
AND type = 'payment'
|
||||
AND amount_cents = ?
|
||||
AND DATE(booked_at) = ?
|
||||
AND voided_at IS NULL"
|
||||
);
|
||||
$stmt->execute([$tenantId, $participantId, $amountCents, $bookedAtDate]);
|
||||
|
||||
return (int)$stmt->fetchColumn() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a PayPal-style export CSV: column 0 is the booking date, column 3
|
||||
* the payer name, column 7 the gross amount. The first line is a header and
|
||||
* is skipped.
|
||||
*
|
||||
* @return list<array{row_number: int, raw_name: string, amount_raw: string, date_raw: string, raw: list<string>}>
|
||||
*/
|
||||
function imports_parse_csv(string $filePath): array
|
||||
{
|
||||
$handle = fopen($filePath, 'r');
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException('Die CSV-Datei konnte nicht gelesen werden.');
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
$rowNumber = 0;
|
||||
fgetcsv($handle); // Header ueberspringen
|
||||
|
||||
while (($data = fgetcsv($handle, 1000, ',')) !== false) {
|
||||
$rowNumber++;
|
||||
$rows[] = [
|
||||
'row_number' => $rowNumber,
|
||||
'raw_name' => trim((string)($data[3] ?? '')),
|
||||
'amount_raw' => trim((string)($data[7] ?? '')),
|
||||
'date_raw' => trim((string)($data[0] ?? '')),
|
||||
'raw' => $data,
|
||||
];
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
function imports_create_batch(PDO $pdo, int $tenantId, string $originalFilename, string $checksum, ?int $createdByUserId): int
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO payment_import_batches (tenant_id, original_filename, checksum, status, created_by_user_id)
|
||||
VALUES (?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([$tenantId, $originalFilename, $checksum, 'previewed', $createdByUserId]);
|
||||
|
||||
return (int)$pdo->lastInsertId();
|
||||
}
|
||||
|
||||
function imports_store_row(
|
||||
PDO $pdo,
|
||||
int $batchId,
|
||||
int $rowNumber,
|
||||
?int $participantId,
|
||||
string $rawName,
|
||||
int $amountCents,
|
||||
string $bookedAt,
|
||||
string $status,
|
||||
array $rawRow
|
||||
): int {
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO payment_import_rows
|
||||
(batch_id, row_num, participant_id, raw_name, amount_cents, booked_at, status, raw_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([
|
||||
$batchId,
|
||||
$rowNumber,
|
||||
$participantId,
|
||||
$rawName,
|
||||
$amountCents,
|
||||
$bookedAt,
|
||||
$status,
|
||||
json_encode($rawRow, JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
|
||||
return (int)$pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{batch: array{id: int, original_filename: string, status: string, created_at: string}, rows: list<array>}|null
|
||||
*/
|
||||
function imports_fetch_batch(PDO $pdo, int $tenantId, int $batchId): ?array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT id, original_filename, status, created_at
|
||||
FROM payment_import_batches
|
||||
WHERE id = ? AND tenant_id = ?'
|
||||
);
|
||||
$stmt->execute([$batchId, $tenantId]);
|
||||
$batch = $stmt->fetch();
|
||||
if ($batch === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT r.id, r.row_num, r.participant_id, r.raw_name, r.amount_cents, r.booked_at, r.status, p.display_name
|
||||
FROM payment_import_rows r
|
||||
LEFT JOIN participants p ON p.id = r.participant_id
|
||||
WHERE r.batch_id = ?
|
||||
ORDER BY r.row_num'
|
||||
);
|
||||
$stmt->execute([$batchId]);
|
||||
|
||||
return [
|
||||
'batch' => [
|
||||
'id' => (int)$batch['id'],
|
||||
'original_filename' => (string)$batch['original_filename'],
|
||||
'status' => (string)$batch['status'],
|
||||
'created_at' => (string)$batch['created_at'],
|
||||
],
|
||||
'rows' => $stmt->fetchAll(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @return array{imported: int}
|
||||
*/
|
||||
function imports_commit_batch(PDO $pdo, int $tenantId, int $batchId): array
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT status FROM payment_import_batches WHERE id = ? AND tenant_id = ?');
|
||||
$stmt->execute([$batchId, $tenantId]);
|
||||
$status = $stmt->fetchColumn();
|
||||
if ($status === false) {
|
||||
throw new RuntimeException('Der Import wurde nicht gefunden.');
|
||||
}
|
||||
if ($status !== 'previewed') {
|
||||
throw new RuntimeException('Dieser Import wurde bereits verarbeitet.');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT r.id, r.participant_id, r.amount_cents, r.booked_at, p.legacy_mitarbeiter_id
|
||||
FROM payment_import_rows r
|
||||
JOIN participants p ON p.id = r.participant_id
|
||||
WHERE r.batch_id = ? AND r.status = 'matched'"
|
||||
);
|
||||
$stmt->execute([$batchId]);
|
||||
$rows = $stmt->fetchAll();
|
||||
|
||||
$imported = 0;
|
||||
|
||||
$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');
|
||||
}
|
||||
|
||||
$updateRow->execute([$ledgerEntryId, $row['id']]);
|
||||
$imported++;
|
||||
}
|
||||
|
||||
$pdo->prepare("UPDATE payment_import_batches SET status = 'committed', committed_at = NOW() WHERE id = ?")
|
||||
->execute([$batchId]);
|
||||
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return ['imported' => $imported];
|
||||
}
|
||||
Reference in New Issue
Block a user