Verbessere Mitgliederfilter und Zahlungszuordnung

This commit is contained in:
2026-08-13 15:40:43 +02:00
parent 0f263c3a19
commit a93e067e04
6 changed files with 892 additions and 29 deletions
+189
View File
@@ -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<array{participant_id:int, display_name:string, email:?string, paypal_name:?string, score:int}>
*/
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<array{id:int, original_filename:string, status:string, created_at:string, committed_at:?string, total_rows:int, matched_rows:int, unresolved_rows:int, imported_rows:int, ignored_rows:int}>
*/
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.
+142
View File
@@ -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%;
}
}
+192 -5
View File
@@ -1,5 +1,7 @@
<?php
ob_start();
include "functions.php";
require_once __DIR__ . "/app/ledger.php";
require_once __DIR__ . "/app/imports.php";
@@ -103,22 +105,88 @@ function csv_status_label(string $status): string
'unmatched' => '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 = '<option value="">- Mitglied wählen -</option>';
if ($suggestions !== []) {
$html .= '<optgroup label="Vorschläge">';
foreach ($suggestions as $suggestion) {
$id = (int)$suggestion['participant_id'];
$html .= '<option value="' . $id . '"' . ($selectedId === $id ? ' selected' : '') . '>'
. saas_html((string)$suggestion['display_name'])
. '</option>';
}
$html .= '</optgroup>';
}
$html .= '<optgroup label="Alle aktiven Mitglieder">';
foreach ($members as $member) {
$id = (int)$member['participant_id'];
if (isset($suggestedIds[$id])) {
continue;
}
$html .= '<option value="' . $id . '"' . ($selectedId === $id ? ' selected' : '') . '>'
. saas_html((string)$member['display_name'])
. '</option>';
}
$html .= '</optgroup>';
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
<?php if ($vorschau !== null): ?>
<h3>Vorschau: <?php echo saas_html($vorschau['batch']['original_filename']); ?></h3>
<p>Status: <?php echo saas_html($vorschau['batch']['status']); ?></p>
<p>Status: <?php echo saas_html($vorschau['batch']['status']); ?> ·
<?php echo (int)($vorschauStatusZaehler['matched'] ?? 0); ?> bereit ·
<?php echo (int)($vorschauStatusZaehler['unmatched'] ?? 0); ?> ohne Zuordnung ·
<?php echo (int)($vorschauStatusZaehler['duplicate'] ?? 0); ?> Duplikatverdacht ·
<?php echo (int)($vorschauStatusZaehler['invalid'] ?? 0); ?> ungültig
</p>
<table>
<div class="table-wrapper">
<table class="admin-table">
<tr>
<th>Zeile</th>
<th>Name (CSV)</th>
@@ -207,18 +328,58 @@ $vorschau = $vorschauBatchId !== null ? imports_fetch_batch($pdo, $tenantId, $vo
<th>Betrag</th>
<th>Datum</th>
<th>Status</th>
<th>Aktion</th>
</tr>
<?php foreach ($vorschau['rows'] as $row): ?>
<?php
$rowStatus = (string)$row['status'];
$suggestions = in_array($rowStatus, ['unmatched', 'duplicate'], true)
? imports_suggest_participants($pdo, $tenantId, (string)$row['raw_name'], null, 4)
: [];
$selectedId = count($suggestions) === 1 ? (int)$suggestions[0]['participant_id'] : 0;
?>
<tr>
<td><?php echo (int)$row['row_num']; ?></td>
<td><?php echo saas_html($row['raw_name']); ?></td>
<td><?php echo saas_html($row['display_name'] ?? '—'); ?></td>
<td><?php echo saas_html(number_format(((int)$row['amount_cents']) / 100, 2, ',', '.')); ?> €</td>
<td><?php echo saas_html($row['booked_at']); ?></td>
<td><?php echo saas_html(csv_status_label($row['status'])); ?></td>
<td><span class="status-badge <?php echo saas_html(csv_status_class($rowStatus)); ?>"><?php echo saas_html(csv_status_label($rowStatus)); ?></span></td>
<td>
<?php if ($vorschau['batch']['status'] === 'previewed' && in_array($rowStatus, ['unmatched', 'duplicate'], true)): ?>
<form method="post" action="csvupload.php" class="inline-admin-form"<?php echo $rowStatus === 'duplicate' ? ' onsubmit="return confirm(\'Diese Zeile wurde als Duplikat erkannt. Trotzdem als neue Einzahlung buchen?\');"' : ''; ?>>
<?php echo app_csrf_field(); ?>
<input type="hidden" name="aktion" value="zeile_zuordnen">
<input type="hidden" name="batch_id" value="<?php echo (int)$vorschau['batch']['id']; ?>">
<input type="hidden" name="row_id" value="<?php echo (int)$row['id']; ?>">
<select name="participant_id" required>
<?php echo csv_select_options($mitglieder, $suggestions, $selectedId); ?>
</select>
<button type="submit">Zuordnen</button>
</form>
<form method="post" action="csvupload.php" class="inline-admin-form" onsubmit="return confirm('Diese Importzeile ignorieren?');">
<?php echo app_csrf_field(); ?>
<input type="hidden" name="aktion" value="zeile_ignorieren">
<input type="hidden" name="batch_id" value="<?php echo (int)$vorschau['batch']['id']; ?>">
<input type="hidden" name="row_id" value="<?php echo (int)$row['id']; ?>">
<button type="submit" class="alt">Ignorieren</button>
</form>
<?php elseif ($vorschau['batch']['status'] === 'previewed' && $rowStatus === 'invalid'): ?>
<form method="post" action="csvupload.php" class="inline-admin-form" onsubmit="return confirm('Diese ungültige Importzeile ignorieren?');">
<?php echo app_csrf_field(); ?>
<input type="hidden" name="aktion" value="zeile_ignorieren">
<input type="hidden" name="batch_id" value="<?php echo (int)$vorschau['batch']['id']; ?>">
<input type="hidden" name="row_id" value="<?php echo (int)$row['id']; ?>">
<button type="submit" class="alt">Ignorieren</button>
</form>
<?php else: ?>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</table>
</div>
<?php if ($vorschau['batch']['status'] === 'previewed'): ?>
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
@@ -231,6 +392,32 @@ $vorschau = $vorschauBatchId !== null ? imports_fetch_batch($pdo, $tenantId, $vo
<br>
<?php endif; ?>
<?php if ($letzteBatches !== []): ?>
<h3>Letzte Importvorschauen</h3>
<div class="table-wrapper">
<table class="admin-table">
<tr>
<th>Datei</th>
<th>Status</th>
<th>Zeilen</th>
<th>Bereit</th>
<th>Offen</th>
<th></th>
</tr>
<?php foreach ($letzteBatches as $batch): ?>
<tr>
<td><?php echo saas_html($batch['original_filename']); ?><br><small><?php echo saas_html($batch['created_at']); ?></small></td>
<td><?php echo saas_html($batch['status']); ?></td>
<td><?php echo (int)$batch['total_rows']; ?></td>
<td><?php echo (int)$batch['matched_rows'] + (int)$batch['imported_rows']; ?></td>
<td><?php echo (int)$batch['unresolved_rows']; ?></td>
<td><a class="button small" href="csvupload.php?batch_id=<?php echo (int)$batch['id']; ?>">Öffnen</a></td>
</tr>
<?php endforeach; ?>
</table>
</div>
<?php endif; ?>
<form action="csvupload.php" method="post" enctype="multipart/form-data">
<?php echo app_csrf_field(); ?>
<input type="hidden" name="aktion" value="hochladen">
+211 -12
View File
@@ -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 '<input type="hidden" name="filter_q" value="' . saas_html((string)$filters['q']) . '">'
. '<input type="hidden" name="filter_status" value="' . saas_html((string)$filters['status']) . '">'
. '<input type="hidden" name="filter_zugang" value="' . saas_html((string)$filters['zugang']) . '">'
. '<input type="hidden" name="filter_paypal" value="' . saas_html((string)$filters['paypal']) . '">';
}
function mitglieder_status_badge(bool $active): string
{
return '<span class="status-badge ' . ($active ? 'success' : 'muted') . '">' . ($active ? 'Aktiv' : 'Inaktiv') . '</span>';
}
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 <a href="mandant-einstellungen.php">Upgrade</a> nötig.
<?php endif; ?></p>
<div class="admin-summary-grid">
<div><strong><?php echo (int)$mitgliederStats['gesamt']; ?></strong><span>Mitglieder</span></div>
<div><strong><?php echo (int)$mitgliederStats['aktiv']; ?></strong><span>aktiv</span></div>
<div><strong><?php echo (int)$mitgliederStats['mit_zugang']; ?></strong><span>mit Zugang</span></div>
<div><strong><?php echo (int)$mitgliederStats['paypal_fehlt']; ?></strong><span>ohne PayPal-Name</span></div>
<div><strong><?php echo (int)$mitgliederStats['paypal_doppelt']; ?></strong><span>PayPal-Konflikte</span></div>
</div>
<?php if ($bearbeitenMitglied !== null): ?>
<h3>Bearbeiten von <?php echo saas_html($bearbeitenMitglied['display_name']); ?></h3>
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
<input type="hidden" name="aktion" value="bearbeitenspeichern">
<input type="hidden" name="mitgliedID" value="<?php echo $bearbeitenMitglied['participant_id']; ?>">
<?php echo app_csrf_field(); ?>
<?php echo mitglieder_filter_fields($filter); ?>
<label for="name">Name:</label>
<input type="text" name="name" id="name" value="<?php echo saas_html($bearbeitenMitglied['display_name']); ?>" required>
@@ -292,6 +443,7 @@ if($hasAccess){
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
<input type="hidden" name="aktion" id="aktion" value="anlegen">
<?php echo app_csrf_field(); ?>
<?php echo mitglieder_filter_fields($filter); ?>
<label for="new-name">Name:</label>
<input type="text" name="name" id="new-name" required>
@@ -316,6 +468,7 @@ if($hasAccess){
<p><a class="button" href="mitglieder-vorlage.php">Vorlage herunterladen</a></p>
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" enctype="multipart/form-data">
<?php echo app_csrf_field(); ?>
<?php echo mitglieder_filter_fields($filter); ?>
<input type="hidden" name="aktion" value="csv_import">
<label for="mitglieder_csv">CSV-Datei mit Mitgliedern:</label>
<input type="file" name="mitglieder_csv" id="mitglieder_csv" accept=".csv" required>
@@ -326,8 +479,38 @@ if($hasAccess){
<p>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.</p>
<form method="get" action="mitarbeiterverwalten.php" class="admin-filter-bar">
<label for="mitglieder-q">Suche</label>
<input type="search" name="q" id="mitglieder-q" value="<?php echo saas_html((string)$filter['q']); ?>" placeholder="Name, E-Mail oder PayPal">
<label for="mitglieder-status">Status</label>
<select name="status" id="mitglieder-status">
<option value="alle" <?php echo $filter['status'] === 'alle' ? 'selected' : ''; ?>>Alle</option>
<option value="aktiv" <?php echo $filter['status'] === 'aktiv' ? 'selected' : ''; ?>>Aktiv</option>
<option value="inaktiv" <?php echo $filter['status'] === 'inaktiv' ? 'selected' : ''; ?>>Inaktiv</option>
</select>
<label for="mitglieder-zugang">Zugang</label>
<select name="zugang" id="mitglieder-zugang">
<option value="alle" <?php echo $filter['zugang'] === 'alle' ? 'selected' : ''; ?>>Alle</option>
<option value="mit_zugang" <?php echo $filter['zugang'] === 'mit_zugang' ? 'selected' : ''; ?>>Mit Zugang</option>
<option value="ohne_zugang" <?php echo $filter['zugang'] === 'ohne_zugang' ? 'selected' : ''; ?>>Ohne Zugang</option>
<option value="entzogen" <?php echo $filter['zugang'] === 'entzogen' ? 'selected' : ''; ?>>Entzogen</option>
</select>
<label for="mitglieder-paypal">PayPal</label>
<select name="paypal" id="mitglieder-paypal">
<option value="alle" <?php echo $filter['paypal'] === 'alle' ? 'selected' : ''; ?>>Alle</option>
<option value="hinterlegt" <?php echo $filter['paypal'] === 'hinterlegt' ? 'selected' : ''; ?>>Hinterlegt</option>
<option value="fehlt" <?php echo $filter['paypal'] === 'fehlt' ? 'selected' : ''; ?>>Fehlt</option>
<option value="doppelt" <?php echo $filter['paypal'] === 'doppelt' ? 'selected' : ''; ?>>Konflikte</option>
</select>
<button type="submit">Filtern</button>
<a class="button alt" href="mitarbeiterverwalten.php">Zurücksetzen</a>
</form>
<p><?php echo count($gefilterteMitglieder); ?> von <?php echo count($mitglieder); ?> Mitgliedern angezeigt.</p>
<!-- Tabelle zur Anzeige und Bearbeitung von Mitgliedern -->
<table class="table table-striped">
<div class="table-wrapper">
<table class="table table-striped admin-table">
<tr>
<th>Name</th>
<th>E-Mail</th>
@@ -336,22 +519,28 @@ if($hasAccess){
<th>Zugang</th>
<th>Aktionen</th>
</tr>
<?php foreach ($mitglieder as $mitglied): ?>
<?php foreach ($gefilterteMitglieder as $mitglied): ?>
<tr>
<td><?php echo saas_html($mitglied['display_name']); ?></td>
<td><?php echo saas_html($mitglied['email'] ?? ''); ?></td>
<td><?php echo saas_html($mitglied['paypal_name'] ?? ''); ?></td>
<td><?php echo $mitglied['active'] ? '1' : '0'; ?></td>
<td>
<?php echo saas_html($mitglied['paypal_name'] ?? ''); ?>
<?php if (!empty($mitglied['paypal_duplicate'])): ?>
<br><span class="status-badge warning">Konflikt</span>
<?php endif; ?>
</td>
<td><?php echo mitglieder_status_badge((bool)$mitglied['active']); ?></td>
<td>
<?php if ($mitglied['membership_status'] === 'active'): ?>
<?php if ($mitglied['role'] === 'owner'): ?>
Inhaber
<span class="status-badge info">Inhaber</span>
<?php else: ?>
<?php echo saas_html($rollenLabels[$mitglied['role']] ?? $mitglied['role']); ?>
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" style="display:inline">
<span class="status-badge success"><?php echo saas_html($rollenLabels[$mitglied['role']] ?? $mitglied['role']); ?></span>
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" class="inline-admin-form">
<input type="hidden" name="aktion" value="zugang_gewaehren">
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
<?php echo app_csrf_field(); ?>
<?php echo mitglieder_filter_fields($filter); ?>
<select name="rolle">
<?php foreach (saas_grantable_roles() as $rolle): ?>
<option value="<?php echo saas_html($rolle); ?>" <?php echo $rolle === $mitglied['role'] ? 'selected' : ''; ?>><?php echo saas_html($rollenLabels[$rolle] ?? $rolle); ?></option>
@@ -359,20 +548,22 @@ if($hasAccess){
</select>
<button type="submit">Rolle ändern</button>
</form>
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" style="display:inline">
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" class="inline-admin-form">
<input type="hidden" name="aktion" value="zugang_entziehen">
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
<?php echo app_csrf_field(); ?>
<?php echo mitglieder_filter_fields($filter); ?>
<button type="submit">Zugang entziehen</button>
</form>
<?php endif; ?>
<?php else: ?>
<?php echo $mitglied['membership_status'] === 'revoked' ? 'Zugang entzogen' : 'Kein Zugang'; ?>
<span class="status-badge <?php echo $mitglied['membership_status'] === 'revoked' ? 'warning' : 'muted'; ?>"><?php echo saas_html(mitglieder_access_label($mitglied, $rollenLabels)); ?></span>
<?php if ($mitglied['email'] !== null && $mitglied['email'] !== ''): ?>
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" style="display:inline">
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" class="inline-admin-form">
<input type="hidden" name="aktion" value="zugang_gewaehren">
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
<?php echo app_csrf_field(); ?>
<?php echo mitglieder_filter_fields($filter); ?>
<select name="rolle">
<?php foreach (saas_grantable_roles() as $rolle): ?>
<option value="<?php echo saas_html($rolle); ?>"><?php echo saas_html($rollenLabels[$rolle] ?? $rolle); ?></option>
@@ -384,12 +575,13 @@ if($hasAccess){
<?php endif; ?>
</td>
<td>
<ul class="actions">
<ul class="actions compact-actions">
<li>
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
<input type="hidden" name="aktion" value="bearbeiten">
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
<?php echo app_csrf_field(); ?>
<?php echo mitglieder_filter_fields($filter); ?>
<button type="submit">Bearbeiten</button>
</form>
</li>
@@ -399,6 +591,7 @@ if($hasAccess){
<input type="hidden" name="aktion" value="deaktivieren">
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
<?php echo app_csrf_field(); ?>
<?php echo mitglieder_filter_fields($filter); ?>
<button type="submit">Deaktivieren</button>
</form>
<?php else: ?>
@@ -406,6 +599,7 @@ if($hasAccess){
<input type="hidden" name="aktion" value="aktivieren">
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
<?php echo app_csrf_field(); ?>
<?php echo mitglieder_filter_fields($filter); ?>
<button type="submit">Aktivieren</button>
</form>
<?php endif; ?>
@@ -416,6 +610,7 @@ if($hasAccess){
<input type="hidden" name="aktion" value="anonymisieren">
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
<?php echo app_csrf_field(); ?>
<?php echo mitglieder_filter_fields($filter); ?>
<button type="submit">Anonymisieren</button>
</form>
</li>
@@ -424,7 +619,11 @@ if($hasAccess){
</td>
</tr>
<?php endforeach; ?>
<?php if ($gefilterteMitglieder === []): ?>
<tr><td colspan="6">Keine Mitglieder für diese Filter gefunden.</td></tr>
<?php endif; ?>
</table>
</div>
<?php
}else{
+94 -12
View File
@@ -21,6 +21,59 @@ if ($hasAccess && isset($_SESSION['flash_paypal'])) {
unset($_SESSION['flash_paypal']);
}
$suchbegriff = trim((string)($_SERVER['REQUEST_METHOD'] === 'POST' ? ($_POST['return_q'] ?? '') : ($_GET['q'] ?? '')));
$returnQuery = $suchbegriff !== '' ? '?q=' . urlencode($suchbegriff) : '';
function paypal_select_options(array $members, array $suggestions = [], int $selectedId = 0): string
{
$suggestedIds = [];
foreach ($suggestions as $suggestion) {
$suggestedIds[(int)$suggestion['participant_id']] = true;
}
$html = '<option value="">- Mitglied wählen -</option>';
if ($suggestions !== []) {
$html .= '<optgroup label="Vorschläge">';
foreach ($suggestions as $suggestion) {
$id = (int)$suggestion['participant_id'];
$html .= '<option value="' . $id . '"' . ($selectedId === $id ? ' selected' : '') . '>'
. saas_html((string)$suggestion['display_name'])
. '</option>';
}
$html .= '</optgroup>';
}
$html .= '<optgroup label="Alle aktiven Mitglieder">';
foreach ($members as $member) {
$id = (int)$member['participant_id'];
if (isset($suggestedIds[$id])) {
continue;
}
$html .= '<option value="' . $id . '"' . ($selectedId === $id ? ' selected' : '') . '>'
. saas_html((string)$member['display_name'])
. '</option>';
}
$html .= '</optgroup>';
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";
<?php endif; ?>
<h3>Offene Zahlungen (<?php echo count($offene); ?>)</h3>
<form method="get" action="paypal-zuordnung.php" class="admin-filter-bar">
<label for="paypal-q">Suche</label>
<input type="search" name="q" id="paypal-q" value="<?php echo saas_html($suchbegriff); ?>" placeholder="Zahler, Mitteilung oder Transaktion">
<button type="submit">Filtern</button>
<a class="button alt" href="paypal-zuordnung.php">Zurücksetzen</a>
</form>
<?php if ($offene === []): ?>
<p>Keine offenen Zahlungen. Alles automatisch zugeordnet. 🎉</p>
<p>Keine offenen Zahlungen. Alles automatisch zugeordnet.</p>
<?php elseif ($offeneGefiltert === []): ?>
<p>Keine offenen Zahlungen für diesen Filter gefunden.</p>
<?php else: ?>
<table>
<tr><th>Eingang</th><th>Zahler (PayPal)</th><th>Mitteilung</th><th>Betrag</th><th>Zuordnen</th><th></th></tr>
<?php foreach ($offene as $z): ?>
<p><?php echo count($offeneGefiltert); ?> von <?php echo count($offene); ?> offenen Zahlungen angezeigt.</p>
<div class="table-wrapper">
<table class="admin-table">
<tr><th>Eingang</th><th>Zahlung</th><th>Betrag</th><th>Vorschläge</th><th>Zuordnen</th><th></th></tr>
<?php foreach ($offeneGefiltert as $z): ?>
<?php
$suggestions = imports_suggest_participants($pdo, $tenantId, (string)$z['payer_name'], (string)($z['note'] ?? ''), 4);
$selectedId = count($suggestions) === 1 ? (int)$suggestions[0]['participant_id'] : 0;
?>
<tr>
<td><?php echo saas_html((string)($z['paid_at'] ?? $z['created_at'])); ?></td>
<td><?php echo saas_html($z['payer_name']); ?></td>
<td><?php echo saas_html((string)($z['note'] ?? '')); ?></td>
<td>
<strong><?php echo saas_html($z['payer_name']); ?></strong>
<?php if (($z['note'] ?? '') !== null && trim((string)$z['note']) !== ''): ?>
<br><small><?php echo saas_html((string)$z['note']); ?></small>
<?php endif; ?>
<br><small>Transaktion: <?php echo saas_html((string)$z['transaction_code']); ?></small>
</td>
<td><?php echo saas_html(saas_format_money_cents((int)$z['net_cents'])); ?> €</td>
<td>
<form method="post" action="paypal-zuordnung.php" style="display:flex;gap:0.5em;align-items:center;margin:0">
<?php if ($suggestions === []): ?>
<span class="status-badge muted">Kein Vorschlag</span>
<?php else: ?>
<?php foreach ($suggestions as $suggestion): ?>
<span class="status-badge info"><?php echo saas_html((string)$suggestion['display_name']); ?></span>
<?php endforeach; ?>
<?php endif; ?>
</td>
<td>
<form method="post" action="paypal-zuordnung.php" class="inline-admin-form">
<?php echo app_csrf_field(); ?>
<input type="hidden" name="aktion" value="zuordnen">
<input type="hidden" name="payment_id" value="<?php echo (int)$z['id']; ?>">
<input type="hidden" name="return_q" value="<?php echo saas_html($suchbegriff); ?>">
<select name="participant_id" required>
<option value=""> Mitglied wählen </option>
<?php foreach ($mitglieder as $m): ?>
<option value="<?php echo (int)$m['participant_id']; ?>"><?php echo saas_html($m['display_name']); ?></option>
<?php endforeach; ?>
<?php echo paypal_select_options($mitglieder, $suggestions, $selectedId); ?>
</select>
<button type="submit">Buchen</button>
</form>
@@ -118,12 +198,14 @@ include "nav.php";
<?php echo app_csrf_field(); ?>
<input type="hidden" name="aktion" value="ignorieren">
<input type="hidden" name="payment_id" value="<?php echo (int)$z['id']; ?>">
<input type="hidden" name="return_q" value="<?php echo saas_html($suchbegriff); ?>">
<button type="submit" class="alt">Ignorieren</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</table>
</div>
<?php endif; ?>
<?php endif; ?>
+64
View File
@@ -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();