From a93e067e040850ae5d30e9cb351d1a4eb146750b Mon Sep 17 00:00:00 2001 From: Clemens Creutzburg Date: Thu, 13 Aug 2026 15:40:43 +0200 Subject: [PATCH] Verbessere Mitgliederfilter und Zahlungszuordnung --- app/imports.php | 189 ++++++++++++++++++++++++ assets/css/main.css | 142 ++++++++++++++++++ csvupload.php | 197 ++++++++++++++++++++++++- mitarbeiterverwalten.php | 223 +++++++++++++++++++++++++++-- paypal-zuordnung.php | 106 ++++++++++++-- scripts/check-payment-matching.php | 64 +++++++++ 6 files changed, 892 insertions(+), 29 deletions(-) diff --git a/app/imports.php b/app/imports.php index c7bef11..1596a69 100644 --- a/app/imports.php +++ b/app/imports.php @@ -56,6 +56,99 @@ function imports_find_participant(PDO $pdo, int $tenantId, string $name): ?array ]; } +function imports_normalize_match_text(string $value): string +{ + $value = trim($value); + $value = preg_replace('/\s+/', ' ', $value) ?? $value; + + return function_exists('mb_strtolower') + ? mb_strtolower($value, 'UTF-8') + : strtolower($value); +} + +/** + * Liefert wahrscheinliche Teilnehmer fuer eine manuelle Zuordnung. Die + * automatische Buchung bleibt strenger; diese Vorschlaege sind nur eine Hilfe + * fuer Admins/Kassenwarte in CSV- und PayPal-Warteschlangen. + * + * @return list + */ +function imports_suggest_participants(PDO $pdo, int $tenantId, string $rawName, ?string $note = null, int $limit = 5): array +{ + $haystack = imports_normalize_match_text(trim($rawName . ' ' . (string)$note)); + if ($haystack === '') { + return []; + } + + $tokens = array_values(array_unique(array_filter( + preg_split('/\s+/', $haystack) ?: [], + static fn(string $token): bool => strlen($token) >= 3 + ))); + + $stmt = $pdo->prepare( + 'SELECT id, display_name, email, paypal_name + FROM participants + WHERE tenant_id = ? AND active = 1 + ORDER BY display_name' + ); + $stmt->execute([$tenantId]); + + $candidates = []; + foreach ($stmt->fetchAll() as $row) { + $displayName = (string)$row['display_name']; + $paypalName = (string)($row['paypal_name'] ?? ''); + $email = (string)($row['email'] ?? ''); + $displayNorm = imports_normalize_match_text($displayName); + $paypalNorm = imports_normalize_match_text($paypalName); + $emailNorm = imports_normalize_match_text($email); + $score = 0; + + if ($displayNorm !== '' && $displayNorm === imports_normalize_match_text($rawName)) { + $score += 90; + } + if ($paypalNorm !== '' && $paypalNorm === imports_normalize_match_text($rawName)) { + $score += 100; + } + if ($displayNorm !== '' && str_contains($haystack, $displayNorm)) { + $score += 45; + } + if ($paypalNorm !== '' && str_contains($haystack, $paypalNorm)) { + $score += 55; + } + + foreach ($tokens as $token) { + if ($displayNorm !== '' && str_contains($displayNorm, $token)) { + $score += 12; + } + if ($paypalNorm !== '' && str_contains($paypalNorm, $token)) { + $score += 16; + } + if ($emailNorm !== '' && str_contains($emailNorm, $token)) { + $score += 6; + } + } + + if ($score <= 0) { + continue; + } + + $candidates[] = [ + 'participant_id' => (int)$row['id'], + 'display_name' => $displayName, + 'email' => $row['email'] !== null ? (string)$row['email'] : null, + 'paypal_name' => $row['paypal_name'] !== null ? (string)$row['paypal_name'] : null, + 'score' => $score, + ]; + } + + usort($candidates, static function (array $a, array $b): int { + return $b['score'] <=> $a['score'] + ?: strcasecmp((string)$a['display_name'], (string)$b['display_name']); + }); + + return array_slice($candidates, 0, max(1, $limit)); +} + /** * 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. @@ -188,6 +281,102 @@ function imports_fetch_batch(PDO $pdo, int $tenantId, int $batchId): ?array ]; } +/** + * @return list + */ +function imports_fetch_recent_batches(PDO $pdo, int $tenantId, int $limit = 10): array +{ + $stmt = $pdo->prepare( + "SELECT b.id, b.original_filename, b.status, b.created_at, b.committed_at, + COUNT(r.id) AS total_rows, + SUM(CASE WHEN r.status = 'matched' THEN 1 ELSE 0 END) AS matched_rows, + SUM(CASE WHEN r.status IN ('unmatched', 'duplicate', 'invalid') THEN 1 ELSE 0 END) AS unresolved_rows, + SUM(CASE WHEN r.status = 'imported' THEN 1 ELSE 0 END) AS imported_rows, + SUM(CASE WHEN r.status = 'ignored' THEN 1 ELSE 0 END) AS ignored_rows + FROM payment_import_batches b + LEFT JOIN payment_import_rows r ON r.batch_id = b.id + WHERE b.tenant_id = ? + GROUP BY b.id, b.original_filename, b.status, b.created_at, b.committed_at + ORDER BY b.created_at DESC, b.id DESC + LIMIT " . max(1, min(50, $limit)) + ); + $stmt->execute([$tenantId]); + + return array_map(static function (array $row): array { + return [ + 'id' => (int)$row['id'], + 'original_filename' => (string)$row['original_filename'], + 'status' => (string)$row['status'], + 'created_at' => (string)$row['created_at'], + 'committed_at' => $row['committed_at'] !== null ? (string)$row['committed_at'] : null, + 'total_rows' => (int)$row['total_rows'], + 'matched_rows' => (int)$row['matched_rows'], + 'unresolved_rows' => (int)$row['unresolved_rows'], + 'imported_rows' => (int)$row['imported_rows'], + 'ignored_rows' => (int)$row['ignored_rows'], + ]; + }, $stmt->fetchAll()); +} + +/** + * @return array{ok:bool, error?:string} + */ +function imports_assign_row(PDO $pdo, int $tenantId, int $batchId, int $rowId, int $participantId): array +{ + $batch = imports_fetch_batch($pdo, $tenantId, $batchId); + if ($batch === null || $batch['batch']['status'] !== 'previewed') { + return ['ok' => false, 'error' => 'Dieser Import kann nicht mehr bearbeitet werden.']; + } + + $summaries = ledger_fetch_participant_summaries($pdo, $tenantId, ['participant_ids' => [$participantId]]); + $participant = $summaries[0] ?? null; + if ($participant === null || empty($participant['active'])) { + return ['ok' => false, 'error' => 'Das gewählte Mitglied wurde nicht gefunden.']; + } + + $stmt = $pdo->prepare( + "UPDATE payment_import_rows r + JOIN payment_import_batches b ON b.id = r.batch_id + SET r.participant_id = ?, r.status = 'matched' + WHERE r.id = ? + AND r.batch_id = ? + AND b.tenant_id = ? + AND b.status = 'previewed' + AND r.status IN ('unmatched', 'duplicate')" + ); + $stmt->execute([$participantId, $rowId, $batchId, $tenantId]); + + if ($stmt->rowCount() !== 1) { + return ['ok' => false, 'error' => 'Diese Zeile kann nicht zugeordnet werden.']; + } + + return ['ok' => true]; +} + +/** + * @return array{ok:bool, error?:string} + */ +function imports_ignore_row(PDO $pdo, int $tenantId, int $batchId, int $rowId): array +{ + $stmt = $pdo->prepare( + "UPDATE payment_import_rows r + JOIN payment_import_batches b ON b.id = r.batch_id + SET r.participant_id = NULL, r.status = 'ignored' + WHERE r.id = ? + AND r.batch_id = ? + AND b.tenant_id = ? + AND b.status = 'previewed' + AND r.status IN ('unmatched', 'duplicate', 'invalid')" + ); + $stmt->execute([$rowId, $batchId, $tenantId]); + + if ($stmt->rowCount() !== 1) { + return ['ok' => false, 'error' => 'Diese Zeile kann nicht ignoriert werden.']; + } + + return ['ok' => true]; +} + /** * Commits every 'matched' row of a previewed batch straight into the ledger. * Already-committed batches are rejected. diff --git a/assets/css/main.css b/assets/css/main.css index 40656cc..34970cd 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -2293,3 +2293,145 @@ button, border-left: 5px solid #4caf50; color: #1b5e20; } + +.hint-box.error { + background: #fdeaea; + border-left: 5px solid #c0392b; + color: #641e16; +} + +input[type="submit"].alt, +input[type="reset"].alt, +input[type="button"].alt, +button.alt, +.button.alt { + box-shadow: inset 0 0 0 2px #9fa3a6; + color: #5f676d !important; +} + +.admin-summary-grid { + display: grid; + gap: 0.75em; + grid-template-columns: repeat(auto-fit, minmax(8.5em, 1fr)); + margin: 1.25em 0 1.75em; +} + +.admin-summary-grid > div { + border: solid 1px rgba(210, 215, 217, 0.85); + border-radius: 6px; + padding: 0.9em 1em; +} + +.admin-summary-grid strong { + color: #38761d; + display: block; + font-family: "Roboto Slab", serif; + font-size: 1.45em; + line-height: 1.1; +} + +.admin-summary-grid span { + color: #7f888f; + display: block; + font-size: 0.85em; + margin-top: 0.2em; +} + +.admin-filter-bar { + align-items: flex-end; + display: flex; + flex-wrap: wrap; + gap: 0.75em; + margin: 1.25em 0; +} + +.admin-filter-bar label { + font-size: 0.85em; + margin: 0; +} + +.admin-filter-bar input[type="search"], +.admin-filter-bar select { + min-width: 11em; +} + +.admin-table td, +.admin-table th { + vertical-align: top; +} + +.status-badge { + border-radius: 6px; + display: inline-block; + font-size: 0.78em; + font-weight: 700; + line-height: 1.4; + margin: 0 0.25em 0.25em 0; + padding: 0.2em 0.55em; + white-space: nowrap; +} + +.status-badge.success { + background: #e8f5e9; + color: #1b5e20; +} + +.status-badge.warning { + background: #fff4e5; + color: #663c00; +} + +.status-badge.error { + background: #fdeaea; + color: #641e16; +} + +.status-badge.info { + background: #e8f4ff; + color: #0d3c61; +} + +.status-badge.muted { + background: #edf0f2; + color: #5f676d; +} + +.inline-admin-form { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.5em; + margin: 0 0 0.5em 0; +} + +.inline-admin-form select { + min-width: 13em; +} + +.compact-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5em; + margin: 0; + padding-left: 0; +} + +.compact-actions li { + padding: 0; +} + +@media screen and (max-width: 736px) { + .admin-filter-bar { + align-items: stretch; + flex-direction: column; + } + + .admin-filter-bar input[type="search"], + .admin-filter-bar select, + .admin-filter-bar button, + .admin-filter-bar .button, + .inline-admin-form select, + .inline-admin-form button { + width: 100%; + } +} diff --git a/csvupload.php b/csvupload.php index fd9f51c..dc0091f 100644 --- a/csvupload.php +++ b/csvupload.php @@ -1,5 +1,7 @@ 'Mitglied nicht gefunden', 'invalid' => 'Ungültige Zeile', 'imported' => 'Importiert', + 'ignored' => 'Ignoriert', default => $status, }; } +function csv_status_class(string $status): string +{ + return match ($status) { + 'matched', 'imported' => 'success', + 'duplicate' => 'warning', + 'unmatched', 'invalid' => 'error', + 'ignored' => 'muted', + default => 'muted', + }; +} + +function csv_redirect_to_batch(?int $batchId = null): void +{ + $target = 'csvupload.php'; + if ($batchId !== null && $batchId > 0) { + $target .= '?batch_id=' . urlencode((string)$batchId); + } + header('Location: ' . $target); + exit; +} + +function csv_select_options(array $members, array $suggestions = [], int $selectedId = 0): string +{ + $suggestedIds = []; + foreach ($suggestions as $suggestion) { + $suggestedIds[(int)$suggestion['participant_id']] = true; + } + + $html = ''; + if ($suggestions !== []) { + $html .= ''; + foreach ($suggestions as $suggestion) { + $id = (int)$suggestion['participant_id']; + $html .= ''; + } + $html .= ''; + } + + $html .= ''; + foreach ($members as $member) { + $id = (int)$member['participant_id']; + if (isset($suggestedIds[$id])) { + continue; + } + $html .= ''; + } + $html .= ''; + + return $html; +} + $meldung = null; $fehler = null; -$vorschauBatchId = null; +$vorschauBatchId = isset($_GET['batch_id']) ? (int)$_GET['batch_id'] : null; + +if (isset($_SESSION['flash_csvupload']) && is_array($_SESSION['flash_csvupload'])) { + $flash = $_SESSION['flash_csvupload']; + unset($_SESSION['flash_csvupload']); + $meldung = isset($flash['meldung']) ? (string)$flash['meldung'] : null; + $fehler = isset($flash['fehler']) ? (string)$flash['fehler'] : null; +} if ($_SERVER["REQUEST_METHOD"] === "POST") { $aktion = $_POST['aktion'] ?? 'hochladen'; + $vorschauBatchId = (int)($_POST['batch_id'] ?? 0); if ($aktion === 'hochladen' && isset($_FILES["csv_file"])) { [$csvFile, $uploadError] = csv_prepare_upload($_FILES["csv_file"]); if ($uploadError !== null) { $fehler = $uploadError; + $_SESSION['flash_csvupload'] = ['fehler' => $fehler]; + csv_redirect_to_batch(); } else { try { $checksum = hash_file('sha256', $csvFile); @@ -160,6 +228,7 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") { $pdo->commit(); $vorschauBatchId = $batchId; + $meldung = 'CSV-Datei wurde eingelesen. Bitte die Vorschau prüfen.'; } catch (Throwable $e) { if ($pdo->inTransaction()) { $pdo->rollBack(); @@ -168,9 +237,43 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") { } finally { @unlink($csvFile); } + + $_SESSION['flash_csvupload'] = ['meldung' => $meldung, 'fehler' => $fehler]; + csv_redirect_to_batch($vorschauBatchId); } + } elseif ($aktion === 'zeile_zuordnen') { + $rowId = (int)($_POST['row_id'] ?? 0); + $participantId = (int)($_POST['participant_id'] ?? 0); + if ($vorschauBatchId <= 0 || $rowId <= 0 || $participantId <= 0) { + $fehler = 'Bitte eine Importzeile und ein Mitglied auswählen.'; + } else { + $result = imports_assign_row($pdo, $tenantId, $vorschauBatchId, $rowId, $participantId); + if ($result['ok']) { + $meldung = 'Importzeile wurde zugeordnet.'; + } else { + $fehler = $result['error'] ?? 'Die Importzeile konnte nicht zugeordnet werden.'; + } + } + + $_SESSION['flash_csvupload'] = ['meldung' => $meldung, 'fehler' => $fehler]; + csv_redirect_to_batch($vorschauBatchId); + } elseif ($aktion === 'zeile_ignorieren') { + $rowId = (int)($_POST['row_id'] ?? 0); + if ($vorschauBatchId <= 0 || $rowId <= 0) { + $fehler = 'Bitte eine Importzeile auswählen.'; + } else { + $result = imports_ignore_row($pdo, $tenantId, $vorschauBatchId, $rowId); + if ($result['ok']) { + $meldung = 'Importzeile wurde ignoriert.'; + } else { + $fehler = $result['error'] ?? 'Die Importzeile konnte nicht ignoriert werden.'; + } + } + + $_SESSION['flash_csvupload'] = ['meldung' => $meldung, 'fehler' => $fehler]; + csv_redirect_to_batch($vorschauBatchId); } elseif ($aktion === 'importieren') { - $batchId = (int)($_POST['batch_id'] ?? 0); + $batchId = $vorschauBatchId; try { $ergebnis = imports_commit_batch($pdo, $tenantId, $batchId); app_audit_log($pdo, $tenantId, $saasUser['user_id'] ?? null, 'csv_import.committed', 'payment_import_batch', $batchId, ['imported' => $ergebnis['imported']]); @@ -179,10 +282,22 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") { $fehler = $e->getMessage(); $vorschauBatchId = $batchId; } + + $_SESSION['flash_csvupload'] = ['meldung' => $meldung, 'fehler' => $fehler]; + csv_redirect_to_batch($vorschauBatchId); } } $vorschau = $vorschauBatchId !== null ? imports_fetch_batch($pdo, $tenantId, $vorschauBatchId) : null; +$letzteBatches = imports_fetch_recent_batches($pdo, $tenantId, 8); +$mitglieder = ledger_fetch_participant_summaries($pdo, $tenantId, ['active_only' => true]); +$vorschauStatusZaehler = []; +if ($vorschau !== null) { + foreach ($vorschau['rows'] as $row) { + $status = (string)$row['status']; + $vorschauStatusZaehler[$status] = ($vorschauStatusZaehler[$status] ?? 0) + 1; + } +} ?> @@ -197,9 +312,15 @@ $vorschau = $vorschauBatchId !== null ? imports_fetch_batch($pdo, $tenantId, $vo

Vorschau:

-

Status:

+

Status: · + bereit · + ohne Zuordnung · + Duplikatverdacht · + ungültig +

- +
+
@@ -207,18 +328,58 @@ $vorschau = $vorschauBatchId !== null ? imports_fetch_batch($pdo, $tenantId, $vo + + - + +
Zeile Name (CSV)Betrag Datum StatusAktion
+ +
> + + + + + + +
+
+ + + + + +
+ +
+ + + + + +
+ + — + +
+
"> @@ -231,6 +392,32 @@ $vorschau = $vorschauBatchId !== null ? imports_fetch_batch($pdo, $tenantId, $vo
+ +

Letzte Importvorschauen

+
+ + + + + + + + + + + + + + + + + + + +
DateiStatusZeilenBereitOffen

Öffnen
+
+ + diff --git a/mitarbeiterverwalten.php b/mitarbeiterverwalten.php index 1ebe633..f5a9465 100644 --- a/mitarbeiterverwalten.php +++ b/mitarbeiterverwalten.php @@ -33,6 +33,66 @@ if ($saasUser !== null && saas_user_has_role(['owner', 'admin'], $saasUser)) { $hasAccess = true; } +function mitglieder_parse_filters(array $source): array +{ + $status = (string)($source['status'] ?? 'alle'); + if (!in_array($status, ['alle', 'aktiv', 'inaktiv'], true)) { + $status = 'alle'; + } + + $zugang = (string)($source['zugang'] ?? 'alle'); + if (!in_array($zugang, ['alle', 'mit_zugang', 'ohne_zugang', 'entzogen'], true)) { + $zugang = 'alle'; + } + + $paypal = (string)($source['paypal'] ?? 'alle'); + if (!in_array($paypal, ['alle', 'hinterlegt', 'fehlt', 'doppelt'], true)) { + $paypal = 'alle'; + } + + return [ + 'q' => trim((string)($source['q'] ?? '')), + 'status' => $status, + 'zugang' => $zugang, + 'paypal' => $paypal, + ]; +} + +function mitglieder_build_filter_query(array $filters): string +{ + $params = []; + foreach (['q', 'status', 'zugang', 'paypal'] as $key) { + $value = (string)($filters[$key] ?? ''); + if ($value !== '' && !in_array($value, ['alle'], true)) { + $params[$key] = $value; + } + } + + return http_build_query($params); +} + +function mitglieder_filter_fields(array $filters): string +{ + return '' + . '' + . '' + . ''; +} + +function mitglieder_status_badge(bool $active): string +{ + return '' . ($active ? 'Aktiv' : 'Inaktiv') . ''; +} + +function mitglieder_access_label(array $mitglied, array $rollenLabels): string +{ + if ($mitglied['membership_status'] === 'active') { + return $rollenLabels[$mitglied['role']] ?? (string)$mitglied['role']; + } + + return $mitglied['membership_status'] === 'revoked' ? 'Zugang entzogen' : 'Kein Zugang'; +} + if($hasAccess){ $meldung = null; @@ -40,6 +100,17 @@ if($hasAccess){ $einladungslink = null; $bearbeitenId = null; $actorUserId = $saasUser['user_id'] ?? null; + $filterSource = $_SERVER['REQUEST_METHOD'] === 'POST' + ? [ + 'q' => $_POST['filter_q'] ?? '', + 'status' => $_POST['filter_status'] ?? 'alle', + 'zugang' => $_POST['filter_zugang'] ?? 'alle', + 'paypal' => $_POST['filter_paypal'] ?? 'alle', + ] + : $_GET; + $filter = mitglieder_parse_filters($filterSource); + $filterQuery = mitglieder_build_filter_query($filter); + $filterSuffix = $filterQuery !== '' ? '?' . $filterQuery : ''; if (isset($_SESSION['flash_mitglieder']) && is_array($_SESSION['flash_mitglieder'])) { $flash = $_SESSION['flash_mitglieder']; @@ -224,7 +295,7 @@ if($hasAccess){ 'fehler' => $fehler, 'einladungslink' => $einladungslink, ]; - header('Location: mitarbeiterverwalten.php'); + header('Location: mitarbeiterverwalten.php' . $filterSuffix); exit; } } @@ -235,6 +306,77 @@ if($hasAccess){ $billingPlan = billing_plans()[$billingInfo['plan_code']] ?? null; $aktiveMitgliederAnzahl = billing_count_active_participants($pdo, $tenantId); $rollenLabels = ['owner' => 'Inhaber', 'admin' => 'Administrator', 'treasurer' => 'Kassenwart', 'member' => 'Mitglied', 'viewer' => 'Betrachter']; + $paypalNameCounts = []; + foreach ($mitglieder as $mitglied) { + $paypalNorm = imports_normalize_match_text((string)($mitglied['paypal_name'] ?? '')); + if ($paypalNorm !== '') { + $paypalNameCounts[$paypalNorm] = ($paypalNameCounts[$paypalNorm] ?? 0) + 1; + } + } + $mitgliederStats = [ + 'gesamt' => count($mitglieder), + 'aktiv' => 0, + 'inaktiv' => 0, + 'mit_zugang' => 0, + 'ohne_zugang' => 0, + 'paypal_fehlt' => 0, + 'paypal_doppelt' => 0, + ]; + $gefilterteMitglieder = []; + foreach ($mitglieder as $mitglied) { + $isActive = (bool)$mitglied['active']; + $hasAccessAccount = $mitglied['membership_status'] === 'active'; + $paypalName = trim((string)($mitglied['paypal_name'] ?? '')); + $paypalNorm = imports_normalize_match_text($paypalName); + $paypalDuplicate = $paypalNorm !== '' && ($paypalNameCounts[$paypalNorm] ?? 0) > 1; + + $mitgliederStats[$isActive ? 'aktiv' : 'inaktiv']++; + $mitgliederStats[$hasAccessAccount ? 'mit_zugang' : 'ohne_zugang']++; + if ($paypalName === '') { + $mitgliederStats['paypal_fehlt']++; + } + if ($paypalDuplicate) { + $mitgliederStats['paypal_doppelt']++; + } + + $matches = true; + $q = imports_normalize_match_text((string)$filter['q']); + if ($q !== '') { + $searchText = imports_normalize_match_text( + (string)$mitglied['display_name'] . ' ' . (string)($mitglied['email'] ?? '') . ' ' . $paypalName + ); + $matches = str_contains($searchText, $q); + } + if ($matches && $filter['status'] === 'aktiv') { + $matches = $isActive; + } + if ($matches && $filter['status'] === 'inaktiv') { + $matches = !$isActive; + } + if ($matches && $filter['zugang'] === 'mit_zugang') { + $matches = $hasAccessAccount; + } + if ($matches && $filter['zugang'] === 'ohne_zugang') { + $matches = $mitglied['membership_status'] === null || $mitglied['membership_status'] === 'pending'; + } + if ($matches && $filter['zugang'] === 'entzogen') { + $matches = $mitglied['membership_status'] === 'revoked'; + } + if ($matches && $filter['paypal'] === 'hinterlegt') { + $matches = $paypalName !== ''; + } + if ($matches && $filter['paypal'] === 'fehlt') { + $matches = $paypalName === ''; + } + if ($matches && $filter['paypal'] === 'doppelt') { + $matches = $paypalDuplicate; + } + + if ($matches) { + $mitglied['paypal_duplicate'] = $paypalDuplicate; + $gefilterteMitglieder[] = $mitglied; + } + } $bearbeitenMitglied = null; if ($bearbeitenId !== null) { foreach ($mitglieder as $m) { @@ -263,12 +405,21 @@ if($hasAccess){ Limit erreicht – für weitere aktive Mitglieder ist ein Upgrade nötig.

+
+
Mitglieder
+
aktiv
+
mit Zugang
+
ohne PayPal-Name
+
PayPal-Konflikte
+
+

Bearbeiten von

"> + @@ -292,6 +443,7 @@ if($hasAccess){ "> + @@ -316,6 +468,7 @@ if($hasAccess){

Vorlage herunterladen

" enctype="multipart/form-data"> + @@ -326,8 +479,38 @@ if($hasAccess){

Name und E-Mail dienen der Kaffeeliste und Benachrichtigungen. Ein Login-Zugang ist davon unabhängig und wird separat je Mitglied gewährt oder entzogen.

+ + + + + + + + + + + Zurücksetzen +
+ +

von Mitgliedern angezeigt.

+ - +
+
@@ -336,22 +519,28 @@ if($hasAccess){ - + - - + + + + +
Name E-MailZugang Aktionen
+ + +
Konflikt + +
- Inhaber + Inhaber - -
" style="display:inline"> + + " class="inline-admin-form"> +
-
" style="display:inline"> + " class="inline-admin-form"> +
- + -
" style="display:inline"> + " class="inline-admin-form"> +
-
    +
    • "> +
    • @@ -399,6 +591,7 @@ if($hasAccess){ + @@ -406,6 +599,7 @@ if($hasAccess){ + @@ -416,6 +610,7 @@ if($hasAccess){ + @@ -424,7 +619,11 @@ if($hasAccess){
Keine Mitglieder für diese Filter gefunden.
+ - Mitglied wählen -'; + if ($suggestions !== []) { + $html .= ''; + foreach ($suggestions as $suggestion) { + $id = (int)$suggestion['participant_id']; + $html .= ''; + } + $html .= ''; + } + + $html .= ''; + foreach ($members as $member) { + $id = (int)$member['participant_id']; + if (isset($suggestedIds[$id])) { + continue; + } + $html .= ''; + } + $html .= ''; + + return $html; +} + +function paypal_payment_matches_query(array $payment, string $query): bool +{ + $query = imports_normalize_match_text($query); + if ($query === '') { + return true; + } + + $text = imports_normalize_match_text( + (string)$payment['payer_name'] . ' ' + . (string)($payment['note'] ?? '') . ' ' + . (string)($payment['transaction_code'] ?? '') + ); + + return str_contains($text, $query); +} + if ($hasAccess && $_SERVER['REQUEST_METHOD'] === 'POST') { $aktion = (string)($_POST['aktion'] ?? ''); $paymentId = (int)($_POST['payment_id'] ?? 0); @@ -45,7 +98,7 @@ if ($hasAccess && $_SERVER['REQUEST_METHOD'] === 'POST') { // Post-Redirect-Get gegen Doppelbuchung per Refresh. $_SESSION['flash_paypal'] = $flash; - header('Location: paypal-zuordnung.php'); + header('Location: paypal-zuordnung.php' . $returnQuery); exit; } @@ -58,6 +111,7 @@ if ($hasAccess) { $offene = paypal_fetch_unmatched($pdo, $tenantId); $mitglieder = ledger_fetch_participant_summaries($pdo, $tenantId, ['active_only' => true]); } +$offeneGefiltert = array_values(array_filter($offene, static fn(array $payment): bool => paypal_payment_matches_query($payment, $suchbegriff))); include "header.php"; include "headerline.php"; @@ -88,27 +142,53 @@ include "nav.php";

Offene Zahlungen ()

+
+ + + + Zurücksetzen +
-

Keine offenen Zahlungen. Alles automatisch zugeordnet. 🎉

+

Keine offenen Zahlungen. Alles automatisch zugeordnet.

+ +

Keine offenen Zahlungen für diesen Filter gefunden.

- - - +

von offenen Zahlungen angezeigt.

+
+
EingangZahler (PayPal)MitteilungBetragZuordnen
+ + + - - + +
EingangZahlungBetragVorschlägeZuordnen
+ + +
+ +
Transaktion: +
-
+ + Kein Vorschlag + + + + + +
+ + @@ -118,12 +198,14 @@ include "nav.php"; +
+ diff --git a/scripts/check-payment-matching.php b/scripts/check-payment-matching.php index 393944e..b3ddd6c 100644 --- a/scripts/check-payment-matching.php +++ b/scripts/check-payment-matching.php @@ -43,6 +43,12 @@ function payment_matching_insert_participant(PDO $pdo, int $tenantId, string $di function payment_matching_cleanup(PDO $pdo, array $tenantIds): void { foreach ($tenantIds as $tenantId) { + $pdo->prepare( + 'DELETE r FROM payment_import_rows r + JOIN payment_import_batches b ON b.id = r.batch_id + WHERE b.tenant_id = ?' + )->execute([$tenantId]); + $pdo->prepare('DELETE FROM payment_import_batches WHERE tenant_id = ?')->execute([$tenantId]); $pdo->prepare('DELETE FROM participants WHERE tenant_id = ?')->execute([$tenantId]); $pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([$tenantId]); } @@ -139,6 +145,64 @@ try { $failures, $passes ); + + $suggestions = imports_suggest_participants($pdo, $tenantId, "paypal unique {$suffix}", null, 3); + payment_matching_assert( + 'Vorschlaege enthalten den passenden Teilnehmer', + $suggestions !== [] && $suggestions[0]['participant_id'] === $uniqueId, + $failures, + $passes + ); + + $batchId = imports_create_batch($pdo, $tenantId, 'matching-test.csv', hash('sha256', $suffix), null); + $unmatchedRowId = imports_store_row( + $pdo, + $batchId, + 1, + null, + "PayPal Unique {$suffix}", + 500, + date('Y-m-d H:i:s'), + 'unmatched', + ['row' => 1] + ); + $invalidRowId = imports_store_row( + $pdo, + $batchId, + 2, + null, + "Kaputt {$suffix}", + 0, + date('Y-m-d H:i:s'), + 'invalid', + ['row' => 2] + ); + + $assignResult = imports_assign_row($pdo, $tenantId, $batchId, $unmatchedRowId, $uniqueId); + $stmt = $pdo->prepare('SELECT participant_id, status FROM payment_import_rows WHERE id = ?'); + $stmt->execute([$unmatchedRowId]); + $assignedRow = $stmt->fetch(); + payment_matching_assert( + 'unmatched CSV-Zeile kann manuell zugeordnet werden', + $assignResult['ok'] === true + && (int)$assignedRow['participant_id'] === $uniqueId + && (string)$assignedRow['status'] === 'matched', + $failures, + $passes + ); + + $ignoreResult = imports_ignore_row($pdo, $tenantId, $batchId, $invalidRowId); + $stmt = $pdo->prepare('SELECT participant_id, status FROM payment_import_rows WHERE id = ?'); + $stmt->execute([$invalidRowId]); + $ignoredRow = $stmt->fetch(); + payment_matching_assert( + 'ungueltige CSV-Zeile kann ignoriert werden', + $ignoreResult['ok'] === true + && $ignoredRow['participant_id'] === null + && (string)$ignoredRow['status'] === 'ignored', + $failures, + $passes + ); } catch (Throwable $e) { if ($pdo->inTransaction()) { $pdo->rollBack();