Strichliste Paket 1

This commit is contained in:
2026-04-09 13:19:12 +02:00
parent e8bac3c0e9
commit eb8d860db4
6 changed files with 425 additions and 0 deletions
@@ -0,0 +1,21 @@
<?php
return <<<'SQL'
CREATE TABLE scan_import_jobs (
id CHAR(36) NOT NULL PRIMARY KEY,
tenant_id CHAR(36) NOT NULL,
uploaded_by_user_id CHAR(36) NOT NULL,
source_filename VARCHAR(255) NOT NULL,
source_path VARCHAR(255) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'uploaded',
template_id CHAR(36) NULL,
error_message TEXT NULL,
processed_at DATETIME NULL,
posted_at DATETIME NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
INDEX scan_import_jobs_tenant_status_idx (tenant_id, status),
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
FOREIGN KEY (uploaded_by_user_id) REFERENCES users(id)
);
SQL;
@@ -0,0 +1,18 @@
<?php
return <<<'SQL'
CREATE TABLE scan_import_pages (
id CHAR(36) NOT NULL PRIMARY KEY,
job_id CHAR(36) NOT NULL,
page_number INT NOT NULL,
image_path VARCHAR(255) NULL,
image_width INT NULL,
image_height INT NULL,
processing_status VARCHAR(50) NOT NULL DEFAULT 'pending',
confidence DECIMAL(5,2) NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE (job_id, page_number),
FOREIGN KEY (job_id) REFERENCES scan_import_jobs(id)
);
SQL;
@@ -0,0 +1,26 @@
<?php
return <<<'SQL'
CREATE TABLE scan_import_rows (
id CHAR(36) NOT NULL PRIMARY KEY,
job_id CHAR(36) NOT NULL,
page_id CHAR(36) NULL,
row_index INT NOT NULL,
member_id CHAR(36) NULL,
member_name_raw VARCHAR(255) NULL,
front_strokes_detected INT NOT NULL DEFAULT 0,
back_strokes_detected INT NOT NULL DEFAULT 0,
confidence DECIMAL(5,2) NULL,
needs_review TINYINT(1) NOT NULL DEFAULT 1,
corrected_front_strokes INT NULL,
corrected_back_strokes INT NULL,
posted_entry_id CHAR(36) NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
INDEX scan_import_rows_job_id_idx (job_id),
INDEX scan_import_rows_member_id_idx (member_id),
FOREIGN KEY (job_id) REFERENCES scan_import_jobs(id),
FOREIGN KEY (page_id) REFERENCES scan_import_pages(id),
FOREIGN KEY (member_id) REFERENCES members(id)
);
SQL;
@@ -0,0 +1,18 @@
<?php
return <<<'SQL'
CREATE TABLE scan_templates (
id CHAR(36) NOT NULL PRIMARY KEY,
tenant_id CHAR(36) NOT NULL,
name VARCHAR(180) NOT NULL,
page_width INT NOT NULL,
page_height INT NOT NULL,
anchor_json LONGTEXT NULL,
grid_json LONGTEXT NOT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE (tenant_id, name),
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
);
SQL;
+232
View File
@@ -5334,6 +5334,238 @@ function app_import_payment_csv(PDO $pdo, string $tenantId, array $file): array
return ['created' => $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') {
+110
View File
@@ -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();
</form>
</section>
<section class="card" style="margin-top:18px"><div class="table"><table><thead><tr><th>Zeit</th><th>Mitglied</th><th>Methode</th><th>Betrag</th><th>Status</th><th>Aktion</th></tr></thead><tbody><?php foreach ($payments as $entry): ?><tr><td><?= dt((string) ($entry['booked_at'] ?? '')) ?></td><td><?= h((string) ($entry['member_name'] ?? '')) ?></td><td><?= h((string) ($entry['payment_method'] ?? '')) ?></td><td><?= money($entry['amount'] ?? 0) ?></td><td><?php if ((string) ($entry['status'] ?? 'booked') === 'cancelled'): ?><?= badge('Storniert', 'warning') ?><?php else: ?><?= badge('Aktiv', 'success') ?><?php endif; ?><?php if ((string) ($entry['status'] ?? 'booked') === 'cancelled' && (string) ($entry['cancelled_at'] ?? '') !== ''): ?><br><span class="muted"><?= dt((string) ($entry['cancelled_at'] ?? '')) ?></span><?php endif; ?></td><td><?php if ((string) ($entry['status'] ?? 'booked') === 'cancelled'): ?><span class="muted">Bereits storniert</span><?php else: ?><form method="post" action="/payments/"><input type="hidden" name="action" value="delete-payment"><input type="hidden" name="payment_id" value="<?= h((string) ($entry['id'] ?? '')) ?>"><button type="submit" class="button secondary">Stornieren</button></form><?php endif; ?></td></tr><?php endforeach; ?></tbody></table></div></section>
<?php elseif ($page === 'imports'): ?>
<section class="hero">
<div class="eyebrow">Importe</div>
<h1>Importe</h1>
<p>CSV-Zahlungen und PDF-Scan-Jobs werden hier zusammen gebündelt, damit der Importweg im Tenant an einer Stelle landet.</p>
</section>
<section class="grid grid-2">
<article class="card">
<div class="eyebrow">CSV-Import</div>
<h2>PayPal-CSV hochladen</h2>
<p>Importiert eine bestehende PayPal-CSV und ordnet passende Mitglieder automatisch zu.</p>
<form method="post" action="/imports/" enctype="multipart/form-data" class="grid" style="margin-top:16px">
<input type="hidden" name="action" value="import-paypal-csv">
<label>CSV-Datei<input type="file" name="csv_file" accept=".csv,text/csv" required></label>
<div class="actions" style="grid-column:1 / -1"><button type="submit">CSV importieren</button></div>
</form>
</article>
<article class="card">
<div class="eyebrow">PDF-Scan</div>
<h2>Scan-Import anlegen</h2>
<p>Lade ein PDF hoch und wähle bei Bedarf eine Vorlage für die spätere Auswertung aus.</p>
<form method="post" action="/imports/" enctype="multipart/form-data" class="grid" style="margin-top:16px">
<input type="hidden" name="action" value="create-scan-import-job">
<label>PDF-Datei<input type="file" name="scan_pdf" accept="application/pdf" required></label>
<label>Template
<select name="template_id">
<option value="">Ohne Template</option>
<?php foreach ($scanTemplates as $template): ?>
<?php
$templateId = (string) ($template['id'] ?? $template['template_id'] ?? $template['key'] ?? '');
$templateLabel = (string) ($template['title'] ?? $template['label'] ?? $template['name'] ?? $template['template_name'] ?? $templateId);
?>
<option value="<?= h($templateId) ?>"><?= h($templateLabel) ?></option>
<?php endforeach; ?>
</select>
</label>
<div class="actions" style="grid-column:1 / -1"><button type="submit">Scan-Job erstellen</button></div>
</form>
</article>
</section>
<?php
$csvSummary = (array) ($importResult['summary'] ?? []);
$csvImported = (int) ($csvSummary['imported'] ?? 0);
$csvDuplicates = (int) ($csvSummary['duplicates'] ?? 0);
$csvUnmatched = (int) ($csvSummary['unmatched'] ?? 0);
$csvSummaryTotal = $csvImported + $csvDuplicates + $csvUnmatched;
?>
<?php if ($csvSummaryTotal > 0): ?>
<section class="metric" style="margin-top:18px">
<strong><?= num($csvImported) ?></strong>
<h3>CSV-Import Ergebnis</h3>
<p><?= num($csvImported) ?> importiert, <?= num($csvDuplicates) ?> Dubletten, <?= num($csvUnmatched) ?> ohne Zuordnung.</p>
</section>
<?php endif; ?>
<section class="card" style="margin-top:18px">
<div class="actions" style="justify-content:space-between;margin-bottom:14px">
<div>
<div class="eyebrow">Scan-Jobs</div>
<h2 style="margin:0">Letzte PDF-Scans</h2>
</div>
</div>
<div class="table">
<table>
<thead>
<tr>
<th>Erstellt</th>
<th>Datei</th>
<th>Status</th>
<th>Template</th>
<th>Fehler</th>
</tr>
</thead>
<tbody>
<?php foreach ($scanImportJobs as $job): ?>
<?php
$jobCreatedAt = (string) ($job['created_at'] ?? $job['created_at_local'] ?? '');
$jobFileName = (string) ($job['file_name'] ?? $job['filename'] ?? $job['original_filename'] ?? '-');
$jobStatus = (string) ($job['status'] ?? $job['state'] ?? '-');
$jobTemplate = (string) ($job['template_name'] ?? $job['template_label'] ?? $job['template_title'] ?? $job['template_id'] ?? '-');
$jobError = (string) ($job['error_message'] ?? $job['error'] ?? $job['last_error'] ?? '');
?>
<tr>
<td><?= dt($jobCreatedAt !== '' ? $jobCreatedAt : null) ?></td>
<td><?= h($jobFileName !== '' ? $jobFileName : '-') ?></td>
<td><?= h($jobStatus !== '' ? $jobStatus : '-') ?></td>
<td><?= h($jobTemplate !== '' ? $jobTemplate : '-') ?></td>
<td><?= h($jobError !== '' ? $jobError : '-') ?></td>
</tr>
<?php endforeach; ?>
<?php if ($scanImportJobs === []): ?>
<tr><td colspan="5">Noch keine Scan-Jobs vorhanden.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
<?php elseif ($page === 'content'): ?>
<section class="hero"><div class="eyebrow">Hinweise und FAQ</div><h1>Hinweise und FAQ</h1><p>Hinweise und häufige Fragen werden pro Tenant gepflegt und direkt an die Mitglieder ausgespielt.</p></section>
<section class="grid grid-2">