From e01d10a3d0f0a305fde6c32bfb4bde5008dfbd93 Mon Sep 17 00:00:00 2001 From: Clemens Creutzburg Date: Thu, 9 Apr 2026 23:08:50 +0200 Subject: [PATCH] Strichliste Paket 2 --- saas-app/public/app-support.php | 1071 ++++++++++++++++++++++++++++++- saas-app/public/index.php | 142 +++- 2 files changed, 1198 insertions(+), 15 deletions(-) diff --git a/saas-app/public/app-support.php b/saas-app/public/app-support.php index 17bdd20..5b03892 100644 --- a/saas-app/public/app-support.php +++ b/saas-app/public/app-support.php @@ -5529,9 +5529,1011 @@ function app_create_scan_import_job(PDO $pdo, array $auth, array $file, string $ } } +function app_scan_import_pages_table_ready(PDO $pdo): bool +{ + if (!scripts_table_exists($pdo, 'scan_import_pages')) { + return false; + } + + foreach (['id', 'job_id', 'page_number', 'image_path', 'image_width', 'image_height', 'processing_status', 'confidence', 'created_at', 'updated_at'] as $columnName) { + if (!app_table_has_column($pdo, 'scan_import_pages', $columnName)) { + return false; + } + } + + return true; +} + +function app_scan_import_rows_table_ready(PDO $pdo): bool +{ + if (!scripts_table_exists($pdo, 'scan_import_rows')) { + return false; + } + + foreach (['id', 'job_id', 'page_id', 'row_index', 'member_id', 'member_name_raw', 'front_strokes_detected', 'back_strokes_detected', 'confidence', 'needs_review', 'corrected_front_strokes', 'corrected_back_strokes', 'posted_entry_id', 'created_at', 'updated_at'] as $columnName) { + if (!app_table_has_column($pdo, 'scan_import_rows', $columnName)) { + return false; + } + } + + return true; +} + +function app_scan_import_job_for_tenant(PDO $pdo, string $tenantId, string $jobId): ?array +{ + $tenantId = trim($tenantId); + $jobId = trim($jobId); + + if ($tenantId === '' || $jobId === '' || !app_scan_import_tables_ready($pdo)) { + return null; + } + + $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_one( + $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 AND sj.id = :job_id LIMIT 1', + ['tenant_id' => $tenantId, 'job_id' => $jobId] + ); +} + +function app_scan_template_for_tenant(PDO $pdo, string $tenantId, string $templateId): ?array +{ + $tenantId = trim($tenantId); + $templateId = trim($templateId); + + if ($tenantId === '' || $templateId === '' || !scripts_table_exists($pdo, 'scan_templates')) { + return null; + } + + 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 null; + } + } + + return app_query_one( + $pdo, + 'SELECT id, tenant_id, name, page_width, page_height, anchor_json, grid_json, is_active, created_at, updated_at FROM scan_templates WHERE tenant_id = :tenant_id AND id = :id LIMIT 1', + [ + 'tenant_id' => $tenantId, + 'id' => $templateId, + ] + ); +} + +function app_scan_import_rows_for_job(PDO $pdo, string $tenantId, string $jobId): array +{ + $tenantId = trim($tenantId); + $jobId = trim($jobId); + + if ($tenantId === '' || $jobId === '' || !app_scan_import_rows_table_ready($pdo) || !app_scan_import_tables_ready($pdo)) { + return []; + } + + return app_query_all( + $pdo, + <<<'SQL' +SELECT + sr.id, + sr.job_id, + sr.page_id, + sr.row_index, + sr.member_id, + sr.member_name_raw, + sr.front_strokes_detected, + sr.back_strokes_detected, + sr.confidence, + sr.needs_review, + sr.corrected_front_strokes, + sr.corrected_back_strokes, + sr.posted_entry_id, + sr.created_at, + sr.updated_at, + m.display_name +FROM scan_import_rows sr +INNER JOIN scan_import_jobs sj ON sj.id = sr.job_id +LEFT JOIN members m ON m.id = sr.member_id AND m.tenant_id = sj.tenant_id +LEFT JOIN scan_import_pages sp ON sp.id = sr.page_id +WHERE sj.tenant_id = :tenant_id + AND sr.job_id = :job_id +ORDER BY COALESCE(sp.page_number, 1) ASC, sr.row_index ASC, sr.id ASC +SQL, + [ + 'tenant_id' => $tenantId, + 'job_id' => $jobId, + ] + ); +} + +function app_scan_import_shell_command_available(string $command): bool +{ + $command = preg_replace('/[^a-zA-Z0-9._-]+/', '', $command) ?? ''; + if ($command === '') { + return false; + } + + if (app_scan_import_php_function_available('exec')) { + $output = []; + $exitCode = 1; + @exec('command -v ' . escapeshellarg($command) . ' 2>/dev/null', $output, $exitCode); + + return $exitCode === 0 && trim((string) ($output[0] ?? '')) !== ''; + } + + if (!app_scan_import_php_function_available('shell_exec')) { + return false; + } + + $output = shell_exec('command -v ' . escapeshellarg($command) . ' 2>/dev/null'); + + return trim((string) $output) !== ''; +} + +function app_scan_import_php_function_available(string $functionName): bool +{ + if (!function_exists($functionName)) { + return false; + } + + $disabledRaw = strtolower((string) ini_get('disable_functions')); + if ($disabledRaw === '') { + return true; + } + + $disabledList = array_filter(array_map('trim', explode(',', $disabledRaw))); + + return !in_array(strtolower($functionName), $disabledList, true); +} + +function app_scan_import_processor_available(): array +{ + $missing = []; + + if (!app_scan_import_php_function_available('exec') && !app_scan_import_php_function_available('shell_exec')) { + $missing[] = 'php-shell-exec'; + } + + if (!in_array('php-shell-exec', $missing, true) && !app_scan_import_shell_command_available('pdftoppm')) { + $missing[] = 'pdftoppm'; + } + + if (!function_exists('imagecreatefrompng') || !function_exists('imagecolorat')) { + $missing[] = 'php-gd'; + } + + return [ + 'ok' => $missing === [], + 'missing' => $missing, + ]; +} + +function app_scan_import_render_pdf_pages(string $jobId, string $sourcePdfPath): array +{ + $jobId = trim($jobId); + $sourcePdfPath = trim($sourcePdfPath); + + if ($jobId === '' || $sourcePdfPath === '') { + throw new RuntimeException('Die Job-Informationen für die Scan-Verarbeitung sind unvollständig.'); + } + + if (!is_file($sourcePdfPath)) { + throw new RuntimeException('Die hochgeladene PDF-Datei konnte nicht gefunden werden.'); + } + + $processor = app_scan_import_processor_available(); + if (empty($processor['ok'])) { + $missing = implode(', ', (array) ($processor['missing'] ?? [])); + throw new RuntimeException('Die Scan-Verarbeitung benötigt zusätzliche System-Komponenten: ' . $missing . '.'); + } + + $baseDir = rtrim(dirname(__DIR__), '/\\'); + $renderDirectory = $baseDir . '/storage/scan-imports/rendered/' . $jobId . '/'; + + if (!is_dir($renderDirectory) && !mkdir($renderDirectory, 0775, true) && !is_dir($renderDirectory)) { + throw new RuntimeException('Das Render-Verzeichnis konnte nicht angelegt werden.'); + } + + foreach (glob($renderDirectory . 'page-*.png') ?: [] as $existingFile) { + if (is_file($existingFile)) { + @unlink($existingFile); + } + } + + $prefix = $renderDirectory . 'page'; + $command = 'pdftoppm -r 300 -png ' . escapeshellarg($sourcePdfPath) . ' ' . escapeshellarg($prefix); + $commandOutput = []; + + if (app_scan_import_php_function_available('exec')) { + $exitCode = 1; + @exec($command . ' 2>&1', $commandOutput, $exitCode); + + if ($exitCode !== 0) { + $reason = trim((string) ($commandOutput[0] ?? '')); + throw new RuntimeException('Die PDF-Seiten konnten nicht gerendert werden.' . ($reason !== '' ? ' (' . $reason . ')' : '')); + } + } elseif (app_scan_import_php_function_available('shell_exec')) { + $shellOutput = (string) shell_exec($command . ' 2>&1'); + if ($shellOutput !== '') { + $commandOutput = preg_split('/\r?\n/', trim($shellOutput)) ?: []; + } + } else { + throw new RuntimeException('Die Scan-Verarbeitung benötigt eine erlaubte Shell-Funktion (exec oder shell_exec).'); + } + + $imageFiles = glob($prefix . '-*.png') ?: []; + natsort($imageFiles); + + if ($imageFiles === []) { + $reason = trim((string) ($commandOutput[0] ?? '')); + throw new RuntimeException('Es wurden keine Bildseiten aus dem PDF erzeugt.' . ($reason !== '' ? ' (' . $reason . ')' : '')); + } + + $pages = []; + foreach (array_values($imageFiles) as $index => $imagePath) { + $pageNumber = $index + 1; + if (preg_match('/-(\d+)\.png$/', (string) $imagePath, $matches) === 1) { + $pageNumber = max(1, (int) ($matches[1] ?? $pageNumber)); + } + + $width = null; + $height = null; + $imageSize = @getimagesize($imagePath); + if (is_array($imageSize) && isset($imageSize[0], $imageSize[1])) { + $width = (int) $imageSize[0]; + $height = (int) $imageSize[1]; + } + + $pages[] = [ + 'page_number' => $pageNumber, + 'image_path' => $imagePath, + 'image_width' => $width, + 'image_height' => $height, + ]; + } + + usort( + $pages, + static fn(array $left, array $right): int => ((int) ($left['page_number'] ?? 0)) <=> ((int) ($right['page_number'] ?? 0)) + ); + + return $pages; +} + +function app_scan_import_parse_box_definition(mixed $value): ?array +{ + if (!is_array($value)) { + return null; + } + + $x = $value['x'] ?? $value['left'] ?? $value[0] ?? null; + $y = $value['y'] ?? $value['top'] ?? $value[1] ?? null; + $width = $value['width'] ?? $value['w'] ?? $value[2] ?? null; + $height = $value['height'] ?? $value['h'] ?? $value[3] ?? null; + + if (!is_numeric($x) || !is_numeric($y) || !is_numeric($width) || !is_numeric($height)) { + return null; + } + + $parsed = [ + 'x' => (int) round((float) $x), + 'y' => (int) round((float) $y), + 'width' => (int) round((float) $width), + 'height' => (int) round((float) $height), + ]; + + if ($parsed['width'] <= 0 || $parsed['height'] <= 0) { + return null; + } + + return $parsed; +} + +function app_scan_import_parse_template_grid(string $gridJson): array +{ + $gridJson = trim($gridJson); + if ($gridJson === '') { + return []; + } + + $decoded = json_decode($gridJson, true); + if (!is_array($decoded)) { + return []; + } + + $normalizedRows = []; + + $appendRows = static function (array $rows, int $defaultPageNumber = 1) use (&$normalizedRows): void { + foreach ($rows as $index => $row) { + if (!is_array($row)) { + continue; + } + + $rowIndex = $row['row_index'] ?? $row['index'] ?? $index; + if (!is_numeric($rowIndex)) { + $rowIndex = $index; + } + + $normalizedRows[] = [ + 'page_number' => max(1, (int) ($row['page_number'] ?? $defaultPageNumber)), + 'row_index' => max(0, (int) $rowIndex), + 'member_id' => trim((string) ($row['member_id'] ?? '')), + 'member_name_raw' => trim((string) ($row['member_name_raw'] ?? $row['member_name'] ?? $row['name'] ?? '')), + 'front_box' => app_scan_import_parse_box_definition($row['front_box'] ?? $row['front_rect'] ?? $row['front'] ?? null), + 'back_box' => app_scan_import_parse_box_definition($row['back_box'] ?? $row['back_rect'] ?? $row['back'] ?? null), + ]; + } + }; + + if (isset($decoded['rows']) && is_array($decoded['rows'])) { + $appendRows($decoded['rows'], max(1, (int) ($decoded['page_number'] ?? 1))); + } + + if (isset($decoded['pages']) && is_array($decoded['pages'])) { + foreach ($decoded['pages'] as $pageIndex => $page) { + if (!is_array($page)) { + continue; + } + + $pageNumber = max(1, (int) ($page['page_number'] ?? ($pageIndex + 1))); + $rows = $page['rows'] ?? []; + if (!is_array($rows)) { + continue; + } + $appendRows($rows, $pageNumber); + } + } + + usort( + $normalizedRows, + static function (array $left, array $right): int { + $pageComparison = ((int) ($left['page_number'] ?? 0)) <=> ((int) ($right['page_number'] ?? 0)); + if ($pageComparison !== 0) { + return $pageComparison; + } + + return ((int) ($left['row_index'] ?? 0)) <=> ((int) ($right['row_index'] ?? 0)); + } + ); + + return $normalizedRows; +} + +function app_scan_import_count_strokes_in_box(mixed $image, array $box): array +{ + if (!is_resource($image) && !is_object($image)) { + return ['count' => 0, 'confidence' => 0.0]; + } + + $imageWidth = (int) imagesx($image); + $imageHeight = (int) imagesy($image); + + if ($imageWidth <= 0 || $imageHeight <= 0) { + return ['count' => 0, 'confidence' => 0.0]; + } + + $x0 = max(0, min($imageWidth - 1, (int) ($box['x'] ?? 0))); + $y0 = max(0, min($imageHeight - 1, (int) ($box['y'] ?? 0))); + $x1 = max($x0 + 1, min($imageWidth, $x0 + (int) ($box['width'] ?? 0))); + $y1 = max($y0 + 1, min($imageHeight, $y0 + (int) ($box['height'] ?? 0))); + + $width = $x1 - $x0; + $height = $y1 - $y0; + if ($width <= 1 || $height <= 1) { + return ['count' => 0, 'confidence' => 0.0]; + } + + $pixelCount = $width * $height; + $binary = array_fill(0, $pixelCount, false); + $visited = array_fill(0, $pixelCount, false); + $darkPixels = 0; + $threshold = 150; + + for ($y = 0; $y < $height; $y++) { + for ($x = 0; $x < $width; $x++) { + $rgb = (int) imagecolorat($image, $x0 + $x, $y0 + $y); + $red = ($rgb >> 16) & 0xFF; + $green = ($rgb >> 8) & 0xFF; + $blue = $rgb & 0xFF; + $gray = (int) (($red * 30 + $green * 59 + $blue * 11) / 100); + + if ($gray < $threshold) { + $index = $y * $width + $x; + $binary[$index] = true; + $darkPixels++; + } + } + } + + $minComponentPixels = max(8, (int) round($pixelCount * 0.001)); + $strokeCount = 0; + + for ($index = 0; $index < $pixelCount; $index++) { + if (!$binary[$index] || $visited[$index]) { + continue; + } + + $stack = [$index]; + $visited[$index] = true; + $componentPixels = 0; + $minX = $width; + $maxX = 0; + $minY = $height; + $maxY = 0; + + while ($stack !== []) { + $current = array_pop($stack); + if (!is_int($current)) { + continue; + } + + $currentY = intdiv($current, $width); + $currentX = $current - ($currentY * $width); + $componentPixels++; + + if ($currentX < $minX) { + $minX = $currentX; + } + if ($currentX > $maxX) { + $maxX = $currentX; + } + if ($currentY < $minY) { + $minY = $currentY; + } + if ($currentY > $maxY) { + $maxY = $currentY; + } + + $neighbors = []; + if ($currentX > 0) { + $neighbors[] = $current - 1; + } + if ($currentX < $width - 1) { + $neighbors[] = $current + 1; + } + if ($currentY > 0) { + $neighbors[] = $current - $width; + } + if ($currentY < $height - 1) { + $neighbors[] = $current + $width; + } + + foreach ($neighbors as $neighbor) { + if (!$binary[$neighbor] || $visited[$neighbor]) { + continue; + } + + $visited[$neighbor] = true; + $stack[] = $neighbor; + } + } + + $componentWidth = ($maxX - $minX) + 1; + $componentHeight = ($maxY - $minY) + 1; + if ($componentPixels >= $minComponentPixels && ($componentHeight >= 7 || $componentWidth >= 3)) { + $strokeCount++; + } + } + + $inkRatio = $darkPixels / max(1, $pixelCount); + if ($strokeCount === 0) { + $confidence = $inkRatio <= 0.01 ? 95.0 : 56.0; + } else { + $confidence = min(98.0, 62.0 + ($strokeCount * 6.5) + min(18.0, $inkRatio * 160)); + } + + return [ + 'count' => $strokeCount, + 'confidence' => round($confidence, 2), + ]; +} + +function app_scan_import_process_job(PDO $pdo, string $tenantId, string $jobId): array +{ + $tenantId = trim($tenantId); + $jobId = trim($jobId); + + if ($tenantId === '' || $jobId === '') { + throw new RuntimeException('Der Scan-Job konnte nicht verarbeitet werden, weil die Kontextdaten fehlen.'); + } + + if (!app_scan_import_tables_ready($pdo) || !app_scan_import_pages_table_ready($pdo) || !app_scan_import_rows_table_ready($pdo)) { + throw new RuntimeException('Die Scan-Import-Tabellen sind nicht vollständig verfügbar. Bitte Migrationen prüfen.'); + } + + $job = app_scan_import_job_for_tenant($pdo, $tenantId, $jobId); + if ($job === null) { + throw new RuntimeException('Der ausgewählte Scan-Job wurde nicht gefunden.'); + } + + $status = strtolower(trim((string) ($job['status'] ?? 'uploaded'))); + if ($status === 'posted') { + throw new RuntimeException('Dieser Scan-Job wurde bereits verbucht und kann nicht erneut verarbeitet werden.'); + } + + $now = date('Y-m-d H:i:s'); + app_execute( + $pdo, + 'UPDATE scan_import_jobs SET status = :status, error_message = :error_message, updated_at = :updated_at WHERE id = :id AND tenant_id = :tenant_id', + [ + 'status' => 'processing', + 'error_message' => null, + 'updated_at' => $now, + 'id' => $jobId, + 'tenant_id' => $tenantId, + ] + ); + + $openImages = []; + + try { + $renderedPages = app_scan_import_render_pdf_pages($jobId, (string) ($job['source_path'] ?? '')); + $templateId = trim((string) ($job['template_id'] ?? '')); + $template = $templateId !== '' ? app_scan_template_for_tenant($pdo, $tenantId, $templateId) : null; + $templateRows = app_scan_import_parse_template_grid((string) ($template['grid_json'] ?? '')); + + $activeMembers = array_values(array_filter( + app_members_for_tenant($pdo, $tenantId), + static function (array $member): bool { + $status = strtolower(trim((string) ($member['status'] ?? 'active'))); + return !in_array($status, ['inactive', 'disabled', 'archived'], true) && trim((string) ($member['id'] ?? '')) !== ''; + } + )); + $membersById = []; + $membersByName = []; + foreach ($activeMembers as $member) { + $memberId = trim((string) ($member['id'] ?? '')); + $memberName = trim((string) ($member['display_name'] ?? '')); + if ($memberId !== '') { + $membersById[$memberId] = $member; + } + if ($memberName !== '') { + $membersByName[strtolower($memberName)] = $member; + } + } + + $pdo->beginTransaction(); + + app_execute($pdo, 'DELETE FROM scan_import_rows WHERE job_id = :job_id', ['job_id' => $jobId]); + app_execute($pdo, 'DELETE FROM scan_import_pages WHERE job_id = :job_id', ['job_id' => $jobId]); + + $pageIdMap = []; + foreach ($renderedPages as $page) { + $pageId = app_uuid(); + $pageNumber = max(1, (int) ($page['page_number'] ?? 1)); + $pageIdMap[$pageNumber] = $pageId; + + app_execute( + $pdo, + 'INSERT INTO scan_import_pages (id, job_id, page_number, image_path, image_width, image_height, processing_status, confidence, created_at, updated_at) VALUES (:id, :job_id, :page_number, :image_path, :image_width, :image_height, :processing_status, :confidence, :created_at, :updated_at)', + [ + 'id' => $pageId, + 'job_id' => $jobId, + 'page_number' => $pageNumber, + 'image_path' => (string) ($page['image_path'] ?? ''), + 'image_width' => isset($page['image_width']) ? (int) $page['image_width'] : null, + 'image_height' => isset($page['image_height']) ? (int) $page['image_height'] : null, + 'processing_status' => 'processed', + 'confidence' => null, + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + } + + $rowsToInsert = []; + $pageConfidenceBuckets = []; + $defaultPageNumber = (int) ($renderedPages[0]['page_number'] ?? 1); + $rowCounter = 0; + + foreach ($templateRows as $templateRow) { + $pageNumber = max(1, (int) ($templateRow['page_number'] ?? $defaultPageNumber)); + $pageId = $pageIdMap[$pageNumber] ?? ($pageIdMap[$defaultPageNumber] ?? null); + if ($pageId === null) { + continue; + } + + $renderedPage = null; + foreach ($renderedPages as $candidatePage) { + if ((int) ($candidatePage['page_number'] ?? 0) === $pageNumber) { + $renderedPage = $candidatePage; + break; + } + } + if (!is_array($renderedPage)) { + $renderedPage = $renderedPages[0] ?? null; + } + if (!is_array($renderedPage)) { + continue; + } + + $imagePath = (string) ($renderedPage['image_path'] ?? ''); + if ($imagePath === '' || !is_file($imagePath)) { + continue; + } + + if (!isset($openImages[$imagePath])) { + $imageResource = @imagecreatefrompng($imagePath); + if (!is_resource($imageResource) && !is_object($imageResource)) { + throw new RuntimeException('Die gerenderte Seite konnte nicht als PNG geladen werden.'); + } + $openImages[$imagePath] = $imageResource; + } + + $imageResource = $openImages[$imagePath]; + $frontResult = ['count' => 0, 'confidence' => 0.0]; + $backResult = ['count' => 0, 'confidence' => 0.0]; + if (is_array($templateRow['front_box'] ?? null)) { + $frontResult = app_scan_import_count_strokes_in_box($imageResource, (array) $templateRow['front_box']); + } + if (is_array($templateRow['back_box'] ?? null)) { + $backResult = app_scan_import_count_strokes_in_box($imageResource, (array) $templateRow['back_box']); + } + + $memberId = trim((string) ($templateRow['member_id'] ?? '')); + $memberNameRaw = trim((string) ($templateRow['member_name_raw'] ?? '')); + if ($memberId === '' && $memberNameRaw !== '') { + $mappedMember = $membersByName[strtolower($memberNameRaw)] ?? null; + if (is_array($mappedMember)) { + $memberId = trim((string) ($mappedMember['id'] ?? '')); + } + } elseif ($memberId !== '' && !isset($membersById[$memberId])) { + $memberId = ''; + } + + $rowIndex = (int) ($templateRow['row_index'] ?? $rowCounter); + if ($rowIndex < 0) { + $rowIndex = $rowCounter; + } + $rowCounter++; + + $combinedConfidence = round((((float) ($frontResult['confidence'] ?? 0)) + ((float) ($backResult['confidence'] ?? 0))) / 2, 2); + $frontDetected = max(0, (int) ($frontResult['count'] ?? 0)); + $backDetected = max(0, (int) ($backResult['count'] ?? 0)); + $needsReview = ($combinedConfidence < 80.0 || $memberId === '') ? 1 : 0; + + $rowsToInsert[] = [ + 'id' => app_uuid(), + 'job_id' => $jobId, + 'page_id' => $pageId, + 'row_index' => $rowIndex, + 'member_id' => $memberId !== '' ? $memberId : null, + 'member_name_raw' => $memberNameRaw !== '' ? $memberNameRaw : null, + 'front_strokes_detected' => $frontDetected, + 'back_strokes_detected' => $backDetected, + 'confidence' => number_format($combinedConfidence, 2, '.', ''), + 'needs_review' => $needsReview, + 'corrected_front_strokes' => $frontDetected, + 'corrected_back_strokes' => $backDetected, + 'posted_entry_id' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]; + + if (!isset($pageConfidenceBuckets[$pageId])) { + $pageConfidenceBuckets[$pageId] = []; + } + $pageConfidenceBuckets[$pageId][] = $combinedConfidence; + } + + if ($rowsToInsert === []) { + foreach ($activeMembers as $memberIndex => $member) { + $memberId = trim((string) ($member['id'] ?? '')); + if ($memberId === '') { + continue; + } + + $rowsToInsert[] = [ + 'id' => app_uuid(), + 'job_id' => $jobId, + 'page_id' => $pageIdMap[$defaultPageNumber] ?? null, + 'row_index' => $memberIndex, + 'member_id' => $memberId, + 'member_name_raw' => trim((string) ($member['display_name'] ?? '')), + 'front_strokes_detected' => 0, + 'back_strokes_detected' => 0, + 'confidence' => number_format(0, 2, '.', ''), + 'needs_review' => 1, + 'corrected_front_strokes' => 0, + 'corrected_back_strokes' => 0, + 'posted_entry_id' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + } + + foreach ($rowsToInsert as $row) { + app_execute( + $pdo, + 'INSERT INTO scan_import_rows (id, job_id, page_id, row_index, member_id, member_name_raw, front_strokes_detected, back_strokes_detected, confidence, needs_review, corrected_front_strokes, corrected_back_strokes, posted_entry_id, created_at, updated_at) VALUES (:id, :job_id, :page_id, :row_index, :member_id, :member_name_raw, :front_strokes_detected, :back_strokes_detected, :confidence, :needs_review, :corrected_front_strokes, :corrected_back_strokes, :posted_entry_id, :created_at, :updated_at)', + $row + ); + } + + foreach ($pageConfidenceBuckets as $pageId => $confidences) { + if ($confidences === []) { + continue; + } + + $avgConfidence = array_sum($confidences) / count($confidences); + app_execute( + $pdo, + 'UPDATE scan_import_pages SET confidence = :confidence, updated_at = :updated_at WHERE id = :id AND job_id = :job_id', + [ + 'confidence' => number_format($avgConfidence, 2, '.', ''), + 'updated_at' => $now, + 'id' => $pageId, + 'job_id' => $jobId, + ] + ); + } + + app_execute( + $pdo, + 'UPDATE scan_import_jobs SET status = :status, error_message = :error_message, processed_at = :processed_at, updated_at = :updated_at WHERE id = :id AND tenant_id = :tenant_id', + [ + 'status' => 'review', + 'error_message' => null, + 'processed_at' => $now, + 'updated_at' => $now, + 'id' => $jobId, + 'tenant_id' => $tenantId, + ] + ); + + $pdo->commit(); + + foreach ($openImages as $imageResource) { + if (is_resource($imageResource) || is_object($imageResource)) { + imagedestroy($imageResource); + } + } + + return [ + 'page_count' => count($renderedPages), + 'row_count' => count($rowsToInsert), + 'used_template' => $template !== null, + ]; + } catch (Throwable $exception) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + foreach ($openImages as $imageResource) { + if (is_resource($imageResource) || is_object($imageResource)) { + imagedestroy($imageResource); + } + } + + app_execute( + $pdo, + 'UPDATE scan_import_jobs SET status = :status, error_message = :error_message, updated_at = :updated_at WHERE id = :id AND tenant_id = :tenant_id', + [ + 'status' => 'failed', + 'error_message' => mb_substr($exception->getMessage(), 0, 8000), + 'updated_at' => date('Y-m-d H:i:s'), + 'id' => $jobId, + 'tenant_id' => $tenantId, + ] + ); + + throw $exception; + } +} + +function app_scan_import_save_review(PDO $pdo, string $tenantId, string $jobId, array $correctedFront, array $correctedBack): int +{ + $tenantId = trim($tenantId); + $jobId = trim($jobId); + + if ($tenantId === '' || $jobId === '') { + throw new RuntimeException('Der Scan-Job konnte nicht für das Review geladen werden.'); + } + + if (!app_scan_import_rows_table_ready($pdo)) { + throw new RuntimeException('Die Scan-Import-Zeilentabelle fehlt. Bitte zuerst die Migrationen ausführen.'); + } + + $job = app_scan_import_job_for_tenant($pdo, $tenantId, $jobId); + if ($job === null) { + throw new RuntimeException('Der ausgewählte Scan-Job wurde nicht gefunden.'); + } + + if (strtolower(trim((string) ($job['status'] ?? ''))) === 'posted') { + throw new RuntimeException('Der Scan-Job wurde bereits verbucht und kann nicht mehr geändert werden.'); + } + + $rows = app_scan_import_rows_for_job($pdo, $tenantId, $jobId); + if ($rows === []) { + throw new RuntimeException('Für diesen Scan-Job gibt es noch keine Review-Zeilen.'); + } + + $now = date('Y-m-d H:i:s'); + $updated = 0; + $pdo->beginTransaction(); + + try { + foreach ($rows as $row) { + $rowId = trim((string) ($row['id'] ?? '')); + if ($rowId === '') { + continue; + } + + $detectedFront = max(0, (int) ($row['front_strokes_detected'] ?? 0)); + $detectedBack = max(0, (int) ($row['back_strokes_detected'] ?? 0)); + + $existingCorrectedFront = $row['corrected_front_strokes']; + $existingCorrectedBack = $row['corrected_back_strokes']; + $front = $existingCorrectedFront !== null ? max(0, (int) $existingCorrectedFront) : $detectedFront; + $back = $existingCorrectedBack !== null ? max(0, (int) $existingCorrectedBack) : $detectedBack; + + if (array_key_exists($rowId, $correctedFront)) { + $front = max(0, (int) $correctedFront[$rowId]); + } + if (array_key_exists($rowId, $correctedBack)) { + $back = max(0, (int) $correctedBack[$rowId]); + } + + $memberId = trim((string) ($row['member_id'] ?? '')); + $needsReview = ($memberId === '' && ($front > 0 || $back > 0)) ? 1 : 0; + + app_execute( + $pdo, + 'UPDATE scan_import_rows SET corrected_front_strokes = :corrected_front_strokes, corrected_back_strokes = :corrected_back_strokes, needs_review = :needs_review, updated_at = :updated_at WHERE id = :id AND job_id = :job_id', + [ + 'corrected_front_strokes' => $front, + 'corrected_back_strokes' => $back, + 'needs_review' => $needsReview, + 'updated_at' => $now, + 'id' => $rowId, + 'job_id' => $jobId, + ] + ); + $updated++; + } + + app_execute( + $pdo, + 'UPDATE scan_import_jobs SET status = :status, updated_at = :updated_at WHERE id = :id AND tenant_id = :tenant_id AND status <> :posted_status', + [ + 'status' => 'review', + 'updated_at' => $now, + 'id' => $jobId, + 'tenant_id' => $tenantId, + 'posted_status' => 'posted', + ] + ); + + $pdo->commit(); + + return $updated; + } catch (Throwable $exception) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + throw $exception; + } +} + +function app_scan_import_mark_approved(PDO $pdo, string $tenantId, string $jobId): array +{ + $tenantId = trim($tenantId); + $jobId = trim($jobId); + + if ($tenantId === '' || $jobId === '') { + throw new RuntimeException('Der Scan-Job konnte nicht freigegeben werden.'); + } + + if (!app_scan_import_rows_table_ready($pdo)) { + throw new RuntimeException('Die Scan-Import-Zeilentabelle fehlt. Bitte zuerst die Migrationen ausführen.'); + } + + $job = app_scan_import_job_for_tenant($pdo, $tenantId, $jobId); + if ($job === null) { + throw new RuntimeException('Der ausgewählte Scan-Job wurde nicht gefunden.'); + } + + if (strtolower(trim((string) ($job['status'] ?? ''))) === 'posted') { + throw new RuntimeException('Der Scan-Job wurde bereits verbucht und kann nicht erneut freigegeben werden.'); + } + + $rows = app_scan_import_rows_for_job($pdo, $tenantId, $jobId); + if ($rows === []) { + throw new RuntimeException('Der Scan-Job enthält keine Zeilen für die Freigabe.'); + } + + $unresolved = 0; + foreach ($rows as $row) { + $front = $row['corrected_front_strokes'] !== null + ? max(0, (int) $row['corrected_front_strokes']) + : max(0, (int) ($row['front_strokes_detected'] ?? 0)); + $back = $row['corrected_back_strokes'] !== null + ? max(0, (int) $row['corrected_back_strokes']) + : max(0, (int) ($row['back_strokes_detected'] ?? 0)); + $memberId = trim((string) ($row['member_id'] ?? '')); + + if (($front > 0 || $back > 0) && $memberId === '') { + $unresolved++; + } + } + + if ($unresolved > 0) { + throw new RuntimeException('Es gibt noch ' . $unresolved . ' Zeile(n) ohne Mitgliedszuordnung mit Strichwerten. Bitte erst im Review klären.'); + } + + $now = date('Y-m-d H:i:s'); + $pdo->beginTransaction(); + + try { + foreach ($rows as $row) { + $rowId = trim((string) ($row['id'] ?? '')); + if ($rowId === '') { + continue; + } + + $front = $row['corrected_front_strokes'] !== null + ? max(0, (int) $row['corrected_front_strokes']) + : max(0, (int) ($row['front_strokes_detected'] ?? 0)); + $back = $row['corrected_back_strokes'] !== null + ? max(0, (int) $row['corrected_back_strokes']) + : max(0, (int) ($row['back_strokes_detected'] ?? 0)); + + app_execute( + $pdo, + 'UPDATE scan_import_rows SET corrected_front_strokes = :corrected_front_strokes, corrected_back_strokes = :corrected_back_strokes, needs_review = :needs_review, updated_at = :updated_at WHERE id = :id AND job_id = :job_id', + [ + 'corrected_front_strokes' => $front, + 'corrected_back_strokes' => $back, + 'needs_review' => 0, + 'updated_at' => $now, + 'id' => $rowId, + 'job_id' => $jobId, + ] + ); + } + + app_execute( + $pdo, + 'UPDATE scan_import_jobs SET status = :status, updated_at = :updated_at WHERE id = :id AND tenant_id = :tenant_id', + [ + 'status' => 'approved', + 'updated_at' => $now, + 'id' => $jobId, + 'tenant_id' => $tenantId, + ] + ); + + $pdo->commit(); + + return [ + 'row_count' => count($rows), + 'status' => 'approved', + ]; + } catch (Throwable $exception) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + 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') { + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { + return; + } + + $action = trim((string) ($_POST['action'] ?? '')); + if (!in_array($action, ['create-scan-import-job', 'process-scan-import-job', 'save-scan-import-review', 'approve-scan-import-review'], true)) { return; } @@ -5545,23 +6547,66 @@ function app_handle_scan_import_action(PDO $pdo, array $auth): void 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; - } - } + $tenantId = trim((string) ($auth['tenant_id'] ?? '')); + if ($tenantId === '') { + throw new RuntimeException('Der Mandant konnte für den Scan-Import nicht bestimmt werden.'); } - $templateId = trim((string) ($_POST['template_id'] ?? '')); - app_create_scan_import_job($pdo, $auth, $file, $templateId); + if ($action === 'create-scan-import-job') { + $file = $_FILES['pdf_file'] ?? $_FILES['scan_pdf'] ?? []; + if ($file === [] && !empty($_FILES)) { + foreach ($_FILES as $candidateFile) { + if (is_array($candidateFile)) { + $file = $candidateFile; + break; + } + } + } - app_flash('Die PDF-Datei wurde hochgeladen und als Import-Job angelegt.', 'success'); - app_redirect('/imports/'); + $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/'); + } + + $jobId = trim((string) ($_POST['job_id'] ?? '')); + if ($jobId === '') { + throw new RuntimeException('Bitte wähle einen Scan-Job aus.'); + } + + if ($action === 'process-scan-import-job') { + $result = app_scan_import_process_job($pdo, $tenantId, $jobId); + app_flash( + 'Der Scan-Job wurde verarbeitet: ' . (int) ($result['page_count'] ?? 0) . ' Seite(n), ' . (int) ($result['row_count'] ?? 0) . ' Zeile(n).', + 'success' + ); + app_redirect('/imports/?scan_job=' . rawurlencode($jobId)); + } + + if ($action === 'save-scan-import-review') { + $changedRows = app_scan_import_save_review( + $pdo, + $tenantId, + $jobId, + (array) ($_POST['corrected_front'] ?? []), + (array) ($_POST['corrected_back'] ?? []) + ); + app_flash($changedRows . ' Review-Zeile(n) wurden gespeichert.', 'success'); + app_redirect('/imports/?scan_job=' . rawurlencode($jobId)); + } + + if ($action === 'approve-scan-import-review') { + $result = app_scan_import_mark_approved($pdo, $tenantId, $jobId); + app_flash('Der Scan-Job wurde freigegeben (' . (int) ($result['row_count'] ?? 0) . ' Zeile(n)).', 'success'); + app_redirect('/imports/?scan_job=' . rawurlencode($jobId)); + } } catch (Throwable $exception) { app_flash($exception->getMessage(), 'error'); + $jobId = trim((string) ($_POST['job_id'] ?? '')); + if ($jobId !== '') { + app_redirect('/imports/?scan_job=' . rawurlencode($jobId)); + } app_redirect('/imports/'); } } diff --git a/saas-app/public/index.php b/saas-app/public/index.php index c8d5baa..c5b4bc9 100644 --- a/saas-app/public/index.php +++ b/saas-app/public/index.php @@ -108,6 +108,8 @@ $reportRows = []; $importResult = ['rows' => [], 'summary' => ['imported' => 0, 'duplicates' => 0, 'unmatched' => 0]]; $scanImportJobs = []; $scanTemplates = []; +$activeScanJob = null; +$activeScanRows = []; $surveyBoard = ['all' => [], 'published' => []]; $activeSurvey = null; $surveyResults = []; @@ -234,6 +236,15 @@ if ($auth !== null && $pdo instanceof PDO) { if (function_exists('app_scan_import_jobs_for_tenant')) { $scanImportJobs = app_scan_import_jobs_for_tenant($pdo, (string) $auth['tenant_id']); } + $activeScanJobId = trim((string) ($_GET['scan_job'] ?? '')); + if ($activeScanJobId !== '') { + if (function_exists('app_scan_import_job_for_tenant')) { + $activeScanJob = app_scan_import_job_for_tenant($pdo, (string) $auth['tenant_id'], $activeScanJobId); + } + if (is_array($activeScanJob) && function_exists('app_scan_import_rows_for_job')) { + $activeScanRows = app_scan_import_rows_for_job($pdo, (string) $auth['tenant_id'], $activeScanJobId); + } + } } if ($page === 'surveys') { @@ -1684,6 +1695,7 @@ $marketing = app_marketing_messages(); $csvDuplicates = (int) ($csvSummary['duplicates'] ?? 0); $csvUnmatched = (int) ($csvSummary['unmatched'] ?? 0); $csvSummaryTotal = $csvImported + $csvDuplicates + $csvUnmatched; + $selectedScanJobId = is_array($activeScanJob) ? (string) ($activeScanJob['id'] ?? '') : ''; ?> 0): ?>
@@ -1709,16 +1721,21 @@ $marketing = app_marketing_messages(); Status Template Fehler + Aktion @@ -1726,15 +1743,136 @@ $marketing = app_marketing_messages(); + +
+ +
+ + + +
+ + + Review öffnen + +
+ - Noch keine Scan-Jobs vorhanden. + Noch keine Scan-Jobs vorhanden.
+ + + +
+
+
+
Review
+

Scan-Import prüfen

+
+
+ +
+
+ +

Job-ID

+

Aktiver Review-Kontext.

+
+
+ +

Status

+

Aktueller Verarbeitungsstand des Jobs.

+
+
+ +

Datei

+

Quelle des Scan-Imports.

+
+
+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
Mitgliederkannt fronterkannt backconfidencekorrigiert frontkorrigiert back
+ + + +
Für diesen Job liegen noch keine Review-Zeilen vor.
+
+
+ +
+
+ +
+ + +
+ +
+
+
+
Hinweise und FAQ

Hinweise und FAQ

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