From eb8d860db45ef17964657c39531540a7de74cae6 Mon Sep 17 00:00:00 2001 From: Clemens Creutzburg Date: Thu, 9 Apr 2026 13:19:12 +0200 Subject: [PATCH] Strichliste Paket 1 --- ...0_000001_create_scan_import_jobs_table.php | 21 ++ ..._000002_create_scan_import_pages_table.php | 18 ++ ...0_000003_create_scan_import_rows_table.php | 26 ++ ..._10_000004_create_scan_templates_table.php | 18 ++ saas-app/public/app-support.php | 232 ++++++++++++++++++ saas-app/public/index.php | 110 +++++++++ 6 files changed, 425 insertions(+) create mode 100644 saas-app/database/migrations/2026_04_10_000001_create_scan_import_jobs_table.php create mode 100644 saas-app/database/migrations/2026_04_10_000002_create_scan_import_pages_table.php create mode 100644 saas-app/database/migrations/2026_04_10_000003_create_scan_import_rows_table.php create mode 100644 saas-app/database/migrations/2026_04_10_000004_create_scan_templates_table.php diff --git a/saas-app/database/migrations/2026_04_10_000001_create_scan_import_jobs_table.php b/saas-app/database/migrations/2026_04_10_000001_create_scan_import_jobs_table.php new file mode 100644 index 0000000..e401936 --- /dev/null +++ b/saas-app/database/migrations/2026_04_10_000001_create_scan_import_jobs_table.php @@ -0,0 +1,21 @@ + $created, 'duplicates' => $duplicates, 'missing' => $missing]; } +function app_scan_import_tables_ready(PDO $pdo): bool +{ + if (!scripts_table_exists($pdo, 'scan_import_jobs')) { + return false; + } + + foreach (['tenant_id', 'uploaded_by_user_id', 'source_filename', 'source_path', 'status', 'template_id', 'processed_at', 'created_at', 'updated_at'] as $columnName) { + if (!app_table_has_column($pdo, 'scan_import_jobs', $columnName)) { + return false; + } + } + + return true; +} + +function app_scan_templates_for_tenant(PDO $pdo, string $tenantId): array +{ + if ($tenantId === '' || !scripts_table_exists($pdo, 'scan_templates')) { + return []; + } + + foreach (['tenant_id', 'id', 'name', 'page_width', 'page_height', 'anchor_json', 'grid_json', 'is_active'] as $columnName) { + if (!app_table_has_column($pdo, 'scan_templates', $columnName)) { + return []; + } + } + + return app_query_all( + $pdo, + 'SELECT id, tenant_id, name, page_width, page_height, anchor_json, grid_json, is_active FROM scan_templates WHERE tenant_id = :tenant_id ORDER BY is_active DESC, name ASC, id ASC', + ['tenant_id' => $tenantId] + ); +} + +function app_scan_import_jobs_for_tenant(PDO $pdo, string $tenantId): array +{ + if ($tenantId === '' || !app_scan_import_tables_ready($pdo)) { + return []; + } + + $supportsTemplates = scripts_table_exists($pdo, 'scan_templates'); + $templateJoin = $supportsTemplates + ? ' LEFT JOIN scan_templates st ON st.id = sj.template_id AND st.tenant_id = sj.tenant_id' + : ''; + $templateSelect = $supportsTemplates + ? 'st.name AS template_name' + : 'NULL AS template_name'; + + return app_query_all( + $pdo, + 'SELECT sj.id, sj.tenant_id, sj.uploaded_by_user_id, sj.source_filename, sj.source_path, sj.status, sj.template_id, sj.error_message, sj.processed_at, sj.posted_at, sj.created_at, sj.updated_at, ' . $templateSelect . ' FROM scan_import_jobs sj' . $templateJoin . ' WHERE sj.tenant_id = :tenant_id ORDER BY sj.created_at DESC, sj.id DESC LIMIT 25', + ['tenant_id' => $tenantId] + ); +} + +function app_scan_import_store_uploaded_pdf(array $file, string $tenantId): array +{ + $tmpName = (string) ($file['tmp_name'] ?? ''); + $originalName = trim((string) ($file['name'] ?? '')); + + if ($tenantId === '') { + throw new RuntimeException('Der Mandant fehlt für den PDF-Upload.'); + } + + if ($tmpName === '') { + throw new RuntimeException('Die PDF-Datei konnte nicht verarbeitet werden.'); + } + + $baseDir = rtrim(dirname(__DIR__), '/\\'); + $directory = $baseDir . '/storage/scan-imports/' . $tenantId . '/' . date('Y') . '/' . date('m') . '/'; + $filename = app_uuid() . '.pdf'; + $targetPath = $directory . $filename; + + if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) { + throw new RuntimeException('Das Upload-Verzeichnis konnte nicht angelegt werden.'); + } + + $stored = false; + if (is_uploaded_file($tmpName)) { + $stored = move_uploaded_file($tmpName, $targetPath); + } else { + $stored = @rename($tmpName, $targetPath); + if (!$stored && is_file($tmpName)) { + $stored = copy($tmpName, $targetPath); + if ($stored) { + @unlink($tmpName); + } + } + } + + if (!$stored) { + throw new RuntimeException('Die PDF-Datei konnte nicht gespeichert werden.'); + } + + return [ + 'original_filename' => $originalName !== '' ? $originalName : $filename, + 'filename' => $filename, + 'path' => $targetPath, + ]; +} + +function app_create_scan_import_job(PDO $pdo, array $auth, array $file, string $templateId = ''): string +{ + $tenantId = trim((string) ($auth['tenant_id'] ?? '')); + + if ($tenantId === '') { + throw new RuntimeException('Der Mandant konnte für den Scan-Import nicht bestimmt werden.'); + } + + if (!app_scan_import_tables_ready($pdo)) { + throw new RuntimeException('Die Scan-Import-Tabellen sind noch nicht angelegt. Bitte zuerst die Migrationen ausführen.'); + } + + $fileError = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE); + if ($fileError !== UPLOAD_ERR_OK) { + throw new RuntimeException('Bitte wähle eine gültige PDF-Datei aus.'); + } + + $fileSize = (int) ($file['size'] ?? 0); + if ($fileSize <= 0 || $fileSize > 15 * 1024 * 1024) { + throw new RuntimeException('Die PDF-Datei darf maximal 15 MB groß sein.'); + } + + $originalName = strtolower(trim((string) ($file['name'] ?? ''))); + $mimeType = strtolower(trim((string) ($file['type'] ?? ''))); + $fileExtension = pathinfo($originalName, PATHINFO_EXTENSION); + if ($fileExtension !== 'pdf' && !str_contains($mimeType, 'pdf')) { + throw new RuntimeException('Es werden nur PDF-Dateien akzeptiert.'); + } + + $templateId = trim($templateId); + if ($templateId !== '') { + if (!scripts_table_exists($pdo, 'scan_templates')) { + throw new RuntimeException('Die Scan-Vorlagen-Tabelle ist noch nicht angelegt. Bitte zuerst die Migrationen ausführen.'); + } + + if (app_query_one( + $pdo, + 'SELECT id FROM scan_templates WHERE tenant_id = :tenant_id AND id = :id AND is_active = 1 LIMIT 1', + [ + 'tenant_id' => $tenantId, + 'id' => $templateId, + ] + ) === null) { + throw new RuntimeException('Die ausgewählte Scan-Vorlage wurde nicht gefunden.'); + } + } else { + $templateId = ''; + } + + $storedFile = app_scan_import_store_uploaded_pdf($file, $tenantId); + $jobId = app_uuid(); + $now = date('Y-m-d H:i:s'); + $uploadedByUserId = trim((string) ($auth['user_id'] ?? '')); + + if ($uploadedByUserId === '') { + throw new RuntimeException('Die Benutzerkennung für den Upload fehlt.'); + } + + $pdo->beginTransaction(); + + try { + app_execute( + $pdo, + 'INSERT INTO scan_import_jobs (id, tenant_id, uploaded_by_user_id, source_filename, source_path, status, template_id, processed_at, created_at, updated_at) VALUES (:id, :tenant_id, :uploaded_by_user_id, :source_filename, :source_path, :status, :template_id, :processed_at, :created_at, :updated_at)', + [ + 'id' => $jobId, + 'tenant_id' => $tenantId, + 'uploaded_by_user_id' => $uploadedByUserId, + 'source_filename' => $storedFile['original_filename'], + 'source_path' => $storedFile['path'], + 'status' => 'uploaded', + 'template_id' => $templateId !== '' ? $templateId : null, + 'processed_at' => null, + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + + $pdo->commit(); + + return $jobId; + } catch (Throwable $exception) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + if (is_file($storedFile['path'] ?? '')) { + @unlink($storedFile['path']); + } + + throw $exception; + } +} + +function app_handle_scan_import_action(PDO $pdo, array $auth): void +{ + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST' || (string) ($_POST['action'] ?? '') !== 'create-scan-import-job') { + return; + } + + if (!app_can_manage_finance($auth)) { + app_flash('Für diesen PDF-Upload brauchst du Finanz- oder Tenant-Rechte.', 'warning'); + app_redirect('/imports/'); + } + + try { + if (!app_scan_import_tables_ready($pdo)) { + throw new RuntimeException('Die Scan-Import-Tabellen sind noch nicht angelegt. Bitte zuerst die Migrationen ausführen.'); + } + + $file = $_FILES['pdf_file'] ?? $_FILES['scan_pdf'] ?? []; + if ($file === [] && !empty($_FILES)) { + foreach ($_FILES as $candidateFile) { + if (is_array($candidateFile)) { + $file = $candidateFile; + break; + } + } + } + + $templateId = trim((string) ($_POST['template_id'] ?? '')); + app_create_scan_import_job($pdo, $auth, $file, $templateId); + + app_flash('Die PDF-Datei wurde hochgeladen und als Import-Job angelegt.', 'success'); + app_redirect('/imports/'); + } catch (Throwable $exception) { + app_flash($exception->getMessage(), 'error'); + app_redirect('/imports/'); + } +} + function app_handle_tenant_action(PDO $pdo, array $auth): void { if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { diff --git a/saas-app/public/index.php b/saas-app/public/index.php index c9b0844..c8d5baa 100644 --- a/saas-app/public/index.php +++ b/saas-app/public/index.php @@ -106,6 +106,8 @@ $ledgerMembers = []; $paymentMembers = []; $reportRows = []; $importResult = ['rows' => [], 'summary' => ['imported' => 0, 'duplicates' => 0, 'unmatched' => 0]]; +$scanImportJobs = []; +$scanTemplates = []; $surveyBoard = ['all' => [], 'published' => []]; $activeSurvey = null; $surveyResults = []; @@ -222,7 +224,16 @@ if ($auth !== null && $pdo instanceof PDO) { } if ($page === 'imports') { + if (function_exists('app_handle_scan_import_action')) { + app_handle_scan_import_action($pdo, $auth); + } $importResult = app_handle_csv_import($pdo, $auth); + if (function_exists('app_scan_templates_for_tenant')) { + $scanTemplates = app_scan_templates_for_tenant($pdo, (string) $auth['tenant_id']); + } + if (function_exists('app_scan_import_jobs_for_tenant')) { + $scanImportJobs = app_scan_import_jobs_for_tenant($pdo, (string) $auth['tenant_id']); + } } if ($page === 'surveys') { @@ -1625,6 +1636,105 @@ $marketing = app_marketing_messages();
ZeitMitgliedMethodeBetragStatusAktion

Bereits storniert
+ +
+
Importe
+

Importe

+

CSV-Zahlungen und PDF-Scan-Jobs werden hier zusammen gebündelt, damit der Importweg im Tenant an einer Stelle landet.

+
+ +
+
+
CSV-Import
+

PayPal-CSV hochladen

+

Importiert eine bestehende PayPal-CSV und ordnet passende Mitglieder automatisch zu.

+
+ + +
+
+
+
+
PDF-Scan
+

Scan-Import anlegen

+

Lade ein PDF hoch und wähle bei Bedarf eine Vorlage für die spätere Auswertung aus.

+
+ + + +
+
+
+
+ + + 0): ?> +
+ +

CSV-Import Ergebnis

+

importiert, Dubletten, ohne Zuordnung.

+
+ + +
+
+
+
Scan-Jobs
+

Letzte PDF-Scans

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
ErstelltDateiStatusTemplateFehler
Noch keine Scan-Jobs vorhanden.
+
+
Hinweise und FAQ

Hinweise und FAQ

Hinweise und häufige Fragen werden pro Tenant gepflegt und direkt an die Mitglieder ausgespielt.