diff --git a/app/imports.php b/app/imports.php new file mode 100644 index 0000000..768805b --- /dev/null +++ b/app/imports.php @@ -0,0 +1,265 @@ + 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}> + */ +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}|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]; +} diff --git a/csvupload.php b/csvupload.php index 3fd959a..5e2df43 100644 --- a/csvupload.php +++ b/csvupload.php @@ -1,6 +1,8 @@ Kein Zugriff"; + include "footer.php"; + exit; } function csv_upload_dir(): string { - return APP_ROOT . '/var/uploads'; + return APP_ROOT . '/var/uploads'; } +/** + * @return array{0: ?string, 1: ?string} [Pfad, Fehlermeldung] + */ function csv_prepare_upload(array $file): array { - if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { - return [null, 'Fehler beim Hochladen der Datei.']; - } - - if (($file['size'] ?? 0) <= 0 || $file['size'] > 5 * 1024 * 1024) { - return [null, 'Die CSV-Datei ist leer oder größer als 5 MB.']; - } - - $originalName = (string)($file['name'] ?? ''); - $extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION)); - if ($extension !== 'csv') { - return [null, 'Es sind nur CSV-Dateien erlaubt.']; - } - - $tmpName = (string)($file['tmp_name'] ?? ''); - if (!is_uploaded_file($tmpName)) { - return [null, 'Die hochgeladene Datei konnte nicht verifiziert werden.']; - } - - if (function_exists('finfo_open')) { - $finfo = finfo_open(FILEINFO_MIME_TYPE); - $mime = $finfo ? finfo_file($finfo, $tmpName) : false; - if ($finfo) { - finfo_close($finfo); - } - - $allowedMimeTypes = [ - 'text/plain', - 'text/csv', - 'text/x-csv', - 'application/csv', - 'application/vnd.ms-excel', - 'application/octet-stream', - ]; - if (is_string($mime) && !in_array($mime, $allowedMimeTypes, true)) { - return [null, 'Der Dateityp wurde nicht als CSV erkannt.']; - } - } - - $uploadDir = csv_upload_dir(); - if (!is_dir($uploadDir)) { - @mkdir($uploadDir, 0700, true); - } - if (!is_dir($uploadDir) || !is_writable($uploadDir)) { - return [null, 'Der Upload-Ordner ist nicht beschreibbar.']; - } - - $targetFile = $uploadDir . '/paypal_' . date('Ymd_His') . '_' . bin2hex(random_bytes(8)) . '.csv'; - if (!move_uploaded_file($tmpName, $targetFile)) { - return [null, 'Die CSV-Datei konnte nicht gespeichert werden.']; - } - - return [$targetFile, null]; -} - - -// Funktion zum Überprüfen, ob der Mitarbeiter in der Tabelle vorhanden ist -function isMitarbeiterExist($conn, $name) -{ - $sql = "SELECT MitarbeiterID FROM kl_Mitarbeiter WHERE Name = ? or paypalname = ?"; - $params = array($name, $name); - $stmt = sqlsrv_query($conn, $sql, $params); - - if ($stmt === false) { - die(print_r(sqlsrv_errors(), true)); - } - - $row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC); - - return ($row !== false); -} - -// Funktion zum Überprüfen, ob ein identischer Eintrag bereits vorhanden ist -function isDuplicateEntry($conn, $mitarbeiterID, $betrag, $datum) -{ - // Um nur den gleichen Tag zu vergleichen, konvertieren wir den Datum-String zu einem DateTime-Objekt und verwenden die Funktion CONVERT - $date = date_create($datum); - if ($date === false) { - return false; - } - - $convertedDatum = date_format($date, 'Y-m-d'); - - #$sql = "SELECT count FROM dbo.kl_Einzahlungen WHERE MitarbeiterID = ? AND Betrag = ? AND CONVERT(VARCHAR, Datum, 23) = ?"; - $sql = "SELECT * FROM dbo.kl_Einzahlungen WHERE MitarbeiterID = ? AND Betrag = ? AND CONVERT(VARCHAR, Datum, 23) = ?"; - $params = array($mitarbeiterID, $betrag, $convertedDatum); - $stmt = sqlsrv_query($conn, $sql, $params); - - if ($stmt === false) { - die(print_r(sqlsrv_errors(), true)); - } - - $row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC); - - return $row !== null && (int)$row['MitarbeiterID'] > 0; -} - -// Funktion zum Verarbeiten der CSV-Datei und Rückgabe der Ergebnisse -function processCSV($conn, $file) -{ - $handle = fopen($file, "r"); - $success_entries = []; - $failed_entries = []; - - // Überspringe die erste Zeile (Header) der CSV-Datei - fgetcsv($handle); - - while (($data = fgetcsv($handle, 1000, ",")) !== FALSE) { - if (count($data) < 8) { - $failed_entries[] = array($data[0] ?? '', $data[3] ?? '', $data[7] ?? '', 4); - continue; - } - - $name = $data[3]; // Index 3 entspricht dem "Name"-Feld in der CSV - $betrag = $data[7]; // Index 7 entspricht dem "Brutto"-Feld in der CSV - $betrag = str_replace(",", ".", $betrag); - #echo "
"; - #$Ausgabe = array($data[0], $data[3], $data[7]); - // Überprüfen, ob der Mitarbeiter existiert und der Betrag positiv ist - if (isMitarbeiterExist($conn, $name) && $betrag > 0) { - - $mitarbeiterID = getMitarbeiterID($conn, $name); - $datum = $data[0]; // Index 0 entspricht dem "Datum"-Feld in der CSV - - // Überprüfen, ob ein identischer Eintrag bereits vorhanden ist - if (!isDuplicateEntry($conn, $mitarbeiterID, $betrag, $datum)) { - - // Eintrag in die Tabelle dbo.kl_Einzahlungen einfügen - $sql = "INSERT INTO kl_Einzahlungen (MitarbeiterID, Betrag, Datum) VALUES (?, ?, ?)"; - $params = array($mitarbeiterID, $betrag, $datum); - $stmt = sqlsrv_query($conn, $sql, $params); - - if ($stmt) { - $success_entries[] = array($data[0], $data[3], $data[7], 0); - } else { - $failed_entries[] = array($data[0], $data[3], $data[7], 1); - #die(print_r(sqlsrv_errors(), true)); - } - }else { - #echo "Eintrag bereits vorhanden: " . print_r($data, true) . "\n"; - $failed_entries[] = array($data[0], $data[3], $data[7], 2); - } - - } else { - $failed_entries[] = array($data[0], $data[3], $data[7], 3); - } - - } - - fclose($handle); - - return ['success' => $success_entries, 'failed' => $failed_entries]; -} - -// Funktion zum Abrufen der MitarbeiterID -function getMitarbeiterID($conn, $name) -{ - $sql = "SELECT MitarbeiterID FROM kl_Mitarbeiter WHERE Name = ? or paypalname = ?"; - $params = array($name, $name); - $stmt = sqlsrv_query($conn, $sql, $params); - - - if ($stmt === false) { - die(print_r(sqlsrv_errors(), true)); - } - - while( $row = sqlsrv_fetch_array( $stmt, SQLSRV_FETCH_ASSOC)) - { - return $row['MitarbeiterID']; + if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { + return [null, 'Fehler beim Hochladen der Datei.']; } - #echo $mitarbeiterid; - + + if (($file['size'] ?? 0) <= 0 || $file['size'] > 5 * 1024 * 1024) { + return [null, 'Die CSV-Datei ist leer oder größer als 5 MB.']; + } + + $originalName = (string)($file['name'] ?? ''); + $extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION)); + if ($extension !== 'csv') { + return [null, 'Es sind nur CSV-Dateien erlaubt.']; + } + + $tmpName = (string)($file['tmp_name'] ?? ''); + if (!is_uploaded_file($tmpName)) { + return [null, 'Die hochgeladene Datei konnte nicht verifiziert werden.']; + } + + if (function_exists('finfo_open')) { + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mime = $finfo ? finfo_file($finfo, $tmpName) : false; + if ($finfo) { + finfo_close($finfo); + } + + $allowedMimeTypes = [ + 'text/plain', 'text/csv', 'text/x-csv', 'application/csv', + 'application/vnd.ms-excel', 'application/octet-stream', + ]; + if (is_string($mime) && !in_array($mime, $allowedMimeTypes, true)) { + return [null, 'Der Dateityp wurde nicht als CSV erkannt.']; + } + } + + $uploadDir = csv_upload_dir(); + if (!is_dir($uploadDir)) { + @mkdir($uploadDir, 0700, true); + } + if (!is_dir($uploadDir) || !is_writable($uploadDir)) { + return [null, 'Der Upload-Ordner ist nicht beschreibbar.']; + } + + $targetFile = $uploadDir . '/paypal_' . date('Ymd_His') . '_' . bin2hex(random_bytes(8)) . '.csv'; + if (!move_uploaded_file($tmpName, $targetFile)) { + return [null, 'Die CSV-Datei konnte nicht gespeichert werden.']; + } + + return [$targetFile, null]; } -// Überprüfen, ob das Formular abgeschickt wurde -if ($_SERVER["REQUEST_METHOD"] == "POST") { - // Überprüfen, ob eine Datei hochgeladen wurde - if (isset($_FILES["csv_file"])) { - [$csvFile, $uploadError] = csv_prepare_upload($_FILES["csv_file"]); - - if ($uploadError !== null) { - echo csv_h($uploadError); - } else { - // CSV-Datei verarbeiten - $result = processCSV($conn, $csvFile); - @unlink($csvFile); - - echo "CSV-Datei erfolgreich verarbeitet.\n"; - - echo "

Auswertung

"; - echo " - - - - - - - "; - - foreach ($result['success'] as $eintrag){ - echo " - - - - - "; - } - foreach ($result['failed'] as $eintrag){ - echo " - - - - - "; - } - echo "
NameDatumBetragErgebnis
" . csv_h($eintrag[0]) . "" . csv_h($eintrag[1]) . "" . csv_h($eintrag[2]) . "Erfolgreich gespeichert
" . csv_h($eintrag[0]) . "" . csv_h($eintrag[1]) . "" . csv_h($eintrag[2]) . ""; - if($eintrag[3] == 1){ - echo "SQL Fehler"; - }elseif($eintrag[3] == 2){ - echo "Eintrag schon vorhanden"; - }elseif($eintrag[3] == 3){ - echo "Benutzer nicht gefunden"; - }elseif($eintrag[3] == 4){ - echo "Ungültige CSV-Zeile"; - } - - echo "
"; - } - - } else { - echo "Fehler beim Hochladen der Datei."; - } +function csv_status_label(string $status): string +{ + return match ($status) { + 'matched' => 'Wird importiert', + 'duplicate' => 'Bereits vorhanden', + 'unmatched' => 'Mitglied nicht gefunden', + 'invalid' => 'Ungültige Zeile', + 'imported' => 'Importiert', + default => $status, + }; } +$meldung = null; +$fehler = null; +$vorschauBatchId = null; + +if ($_SERVER["REQUEST_METHOD"] === "POST") { + $aktion = $_POST['aktion'] ?? 'hochladen'; + + if ($aktion === 'hochladen' && isset($_FILES["csv_file"])) { + [$csvFile, $uploadError] = csv_prepare_upload($_FILES["csv_file"]); + + if ($uploadError !== null) { + $fehler = $uploadError; + } else { + try { + $checksum = hash_file('sha256', $csvFile); + $rows = imports_parse_csv($csvFile); + + $pdo->beginTransaction(); + $batchId = imports_create_batch( + $pdo, + $tenantId, + basename((string)$_FILES["csv_file"]["name"]), + (string)$checksum, + $saasUser['user_id'] ?? null + ); + + foreach ($rows as $row) { + $amountCents = imports_normalize_amount($row['amount_raw']); + $timestamp = $row['date_raw'] !== '' ? strtotime($row['date_raw']) : false; + + if ($amountCents === null || $timestamp === false) { + imports_store_row($pdo, $batchId, $row['row_number'], null, $row['raw_name'], $amountCents ?? 0, date('Y-m-d H:i:s', $timestamp ?: time()), 'invalid', $row['raw']); + continue; + } + + $bookedAt = date('Y-m-d H:i:s', $timestamp); + $participant = imports_find_participant($pdo, $tenantId, $row['raw_name']); + + if ($participant === null) { + imports_store_row($pdo, $batchId, $row['row_number'], null, $row['raw_name'], $amountCents, $bookedAt, 'unmatched', $row['raw']); + continue; + } + + if (imports_is_duplicate($pdo, $tenantId, $participant['participant_id'], $amountCents, date('Y-m-d', $timestamp))) { + imports_store_row($pdo, $batchId, $row['row_number'], $participant['participant_id'], $row['raw_name'], $amountCents, $bookedAt, 'duplicate', $row['raw']); + continue; + } + + imports_store_row($pdo, $batchId, $row['row_number'], $participant['participant_id'], $row['raw_name'], $amountCents, $bookedAt, 'matched', $row['raw']); + } + + $pdo->commit(); + $vorschauBatchId = $batchId; + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + $fehler = 'Die CSV-Datei konnte nicht verarbeitet werden.'; + } finally { + @unlink($csvFile); + } + } + } elseif ($aktion === 'importieren') { + $batchId = (int)($_POST['batch_id'] ?? 0); + try { + $ergebnis = imports_commit_batch($pdo, $tenantId, $batchId); + $meldung = $ergebnis['imported'] . ' Einzahlungen wurden importiert.'; + } catch (Throwable $e) { + $fehler = $e->getMessage(); + $vorschauBatchId = $batchId; + } + } +} + +$vorschau = $vorschauBatchId !== null ? imports_fetch_batch($pdo, $tenantId, $vorschauBatchId) : null; ?> @@ -273,26 +200,62 @@ if ($_SERVER["REQUEST_METHOD"] == "POST") { CSV Verarbeitung -
" method="post" enctype="multipart/form-data"> - - - - -
+ +

CSV-Import

+ + +

+ + +

+ + + +

Vorschau:

+

Status:

+ + + + + + + + + + + + + + + + + + + + +
ZeileName (CSV)Zugeordnetes MitgliedBetragDatumStatus
+ + +
"> + + + + +
+ +
+ + +
" method="post" enctype="multipart/form-data"> + + + + + +
-Kein Zugriff"; - -} -?> - diff --git a/database/migrations/0008_saas_payment_imports.sql b/database/migrations/0008_saas_payment_imports.sql new file mode 100644 index 0000000..0a91a4f --- /dev/null +++ b/database/migrations/0008_saas_payment_imports.sql @@ -0,0 +1,61 @@ +CREATE TABLE IF NOT EXISTS payment_import_batches ( + id INT AUTO_INCREMENT PRIMARY KEY, + tenant_id INT NOT NULL, + original_filename VARCHAR(255) NOT NULL, + checksum CHAR(64) NOT NULL, + status VARCHAR(30) NOT NULL DEFAULT 'previewed', + created_by_user_id INT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + committed_at DATETIME NULL, + KEY idx_payment_import_batches_tenant (tenant_id, created_at), + CONSTRAINT fk_payment_import_batches_tenant + FOREIGN KEY (tenant_id) REFERENCES tenants(id) + ON DELETE CASCADE, + CONSTRAINT fk_payment_import_batches_created_by_user + FOREIGN KEY (created_by_user_id) REFERENCES users(id) + ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS payment_import_rows ( + id INT AUTO_INCREMENT PRIMARY KEY, + batch_id INT NOT NULL, + row_num INT NOT NULL, + participant_id INT NULL, + raw_name VARCHAR(255) NOT NULL, + amount_cents INT NOT NULL, + booked_at DATETIME NOT NULL, + status VARCHAR(30) NOT NULL, + raw_json TEXT NULL, + ledger_entry_id INT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_payment_import_rows_batch (batch_id), + KEY idx_payment_import_rows_participant (participant_id), + CONSTRAINT fk_payment_import_rows_batch + FOREIGN KEY (batch_id) REFERENCES payment_import_batches(id) + ON DELETE CASCADE, + CONSTRAINT fk_payment_import_rows_participant + FOREIGN KEY (participant_id) REFERENCES participants(id) + ON DELETE SET NULL, + CONSTRAINT fk_payment_import_rows_ledger_entry + FOREIGN KEY (ledger_entry_id) REFERENCES ledger_entries(id) + ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +SET @schema_name = DATABASE(); + +SET @sql = ( + SELECT IF( + EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS + WHERE TABLE_SCHEMA = @schema_name + AND TABLE_NAME = 'ledger_entries' + AND CONSTRAINT_NAME = 'fk_ledger_entries_import_batch' + ), + 'SELECT 1', + 'ALTER TABLE ledger_entries ADD CONSTRAINT fk_ledger_entries_import_batch FOREIGN KEY (import_batch_id) REFERENCES payment_import_batches(id) ON DELETE SET NULL' + ) +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/docs/m6-import-export-mail.md b/docs/m6-import-export-mail.md new file mode 100644 index 0000000..12c9f30 --- /dev/null +++ b/docs/m6-import-export-mail.md @@ -0,0 +1,60 @@ +# M6 Import, Export und Mail + +Stand: 2026-07-15 + +M6 macht die Admin-/Treasurer-Flows produktionsreif: CSV-Import, +PDF-/Listenexport, Mailversand und Jahresprozesse laufen tenant-sicher +gegen das neue Modell statt gegen Legacy-Tabellen direkt. + +## CSV-Import + +Umgesetzte Dateien: + +```text +database/migrations/0008_saas_payment_imports.sql +app/imports.php +csvupload.php +``` + +Ziel: Zahlungsimport mit echter Vorschau, Dublettenprüfung und Audit-Trail, +statt sofortiger Verarbeitung ohne Bestätigungsschritt wie im Legacy-Stand. + +Umfang: + +- Neue Tabellen `payment_import_batches` und `payment_import_rows` (siehe + Kernschema im Umstrukturierungsplan) protokollieren jeden Import: Datei, + Prüfsumme, Zeitpunkt, jede einzelne Zeile mit Rohwerten, erkanntem + Mitglied, Status und – nach Bestätigung – der erzeugten Ledger-Zeile. +- Zweistufiger Ablauf: Hochladen erzeugt eine Vorschau (`status = previewed`) + ohne jede Buchung; erst der zweite Schritt „Import bestätigen“ committet + die als `matched` erkannten Zeilen (`status = committed`). +- Zeilen werden klassifiziert: `matched` (wird gebucht), `duplicate` + (gleicher Teilnehmer, gleicher Betrag, gleicher Tag existiert bereits als + nicht-stornierte Ledger-Zahlung), `unmatched` (kein Mitglied per + `paypal_name` oder `display_name` gefunden), `invalid` (Betrag oder Datum + nicht parsebar). +- Zuordnungsregel entspricht der Legacy-Logik: Name aus der CSV wird gegen + `paypal_name` oder `display_name` verglichen (case-insensitiv). +- Buchung folgt demselben Zweig-Muster wie die übrige Sammelerfassung: Für + Teilnehmer mit `legacy_mitarbeiter_id` (Default-Mandant) wird zusätzlich + eine `kl_Einzahlungen`-Zeile geschrieben und gespiegelt; alle anderen + Mandanten werden direkt über `ledger_record_payment()` gebucht. +- Zugriffskontrolle ergänzt: Die Seite hatte zuvor gar keine Prüfung außer + CSRF. Jetzt gilt dieselbe Rollen-/Legacy-Fallback-Logik wie bei + `einzahlung.php` (`owner`, `admin`, `treasurer`). +- Upload-Härtung aus M2 (Größen-/Typprüfung, Speicherung außerhalb des + direkt erreichbaren Codes unter `var/uploads`, Löschung nach Verarbeitung) + bleibt erhalten. + +Live gegen die Dev-Datenbank getestet: Upload mit drei Testzeilen (Treffer, +unbekannter Name, ungültiger Betrag), Vorschau-Klassifizierung geprüft, +Import bestätigt (Dual-Write und Ledger-Spiegelung korrekt), erneuter Upload +derselben Datei erkennt die bereits importierte Zeile korrekt als Dublette. +Testdaten anschließend 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 23 geprüften Seiten. diff --git a/docs/saas-umstrukturierungsplan.md b/docs/saas-umstrukturierungsplan.md index 307377b..9d95eaf 100644 --- a/docs/saas-umstrukturierungsplan.md +++ b/docs/saas-umstrukturierungsplan.md @@ -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 | Import, Export, Mail und Jahresprozesse sind auditierbar | +| M6 | Betriebsflows | Gestartet: CSV-Import mit Vorschau/Dublettenprüfung steht; Export, Mail und Jahresprozesse offen | | 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 | @@ -565,8 +565,13 @@ Admin- und Treasurer-Flows produktionsreif machen. Schritte: -- CSV-Import mit Vorschau, Dublettenprüfung und Audit bauen. -- Uploads außerhalb des Webroots speichern. +- CSV-Import mit Vorschau, Dublettenprüfung und Audit bauen: erledigt. + Neue Tabellen `payment_import_batches`/`payment_import_rows`, + zweistufiger Ablauf (Vorschau vor Buchung), Zuordnung per PayPal-Name + oder Anzeigename, Dublettenprüfung gegen bestehende Ledger-Zahlungen. + `csvupload.php` hatte zuvor keine Zugriffskontrolle; das ist behoben. +- Uploads außerhalb des Webroots speichern: erledigt (bereits in M2, + weiterhin genutzt). - PDF-/Listenexport aus neuem Datenmodell bauen. - Mailversand als nachvollziehbaren Versandjob mit Dry-Run und Versandlog gestalten. @@ -576,6 +581,7 @@ Ergebnis: - Kassenverwaltung ist für reale Betriebsabläufe vollständig. - Import/Export/Mail sind auditierbar. +- Stand: gestartet. Dokumentation: `docs/m6-import-export-mail.md`. Abhängigkeiten: