M6: CSV-Import mit Vorschau, Dublettenpruefung und Audit-Trail

csvupload.php verarbeitete CSV-Zeilen bisher sofort beim Upload, ohne
Vorschau, ohne Zugriffskontrolle (nur CSRF) und schrieb ausschliesslich in
die global unscoped kl_Einzahlungen-Tabelle.

- Neue Tabellen payment_import_batches/payment_import_rows protokollieren
  jeden Import: Datei, Pruefsumme, jede Zeile mit Rohwerten, erkanntem
  Mitglied, Status und erzeugter Ledger-Zeile.
- Zweistufiger Ablauf: Hochladen zeigt nur eine Vorschau (matched/
  duplicate/unmatched/invalid), erst "Import bestaetigen" bucht.
- Zuordnung per paypal_name oder display_name (tenant-scoped), Dubletten-
  pruefung gegen bestehende nicht-stornierte Ledger-Zahlungen.
- Buchung folgt dem etablierten Zweig-Muster: Default-Mandant per
  Dual-Write nach kl_Einzahlungen plus Spiegelung, alle anderen Mandanten
  direkt ueber ledger_record_payment().
- Zugriffskontrolle ergaenzt (owner/admin/treasurer + Legacy-Fallback).
- Live getestet: Treffer, unbekannter Name, ungueltiger Betrag korrekt
  klassifiziert; Import bestaetigt mit korrektem Dual-Write; erneuter
  Upload derselben Datei erkennt die Zeile korrekt als Dublette.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 14:48:41 +02:00
co-authored by Claude Sonnet 5
parent 4d5f32ae6d
commit 92e124753f
5 changed files with 609 additions and 254 deletions
+265
View File
@@ -0,0 +1,265 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
require_once __DIR__ . '/ledger.php';
/**
* Parses a decimal amount ("12,34" or "12.34") into cents. Returns null for
* anything that is not a plausible positive amount.
*/
function imports_normalize_amount(string $raw): ?int
{
$normalized = str_replace(',', '.', trim($raw));
if (!preg_match('/^-?[0-9]+(?:\.[0-9]{1,2})?$/', $normalized)) {
return null;
}
$cents = (int)round(((float)$normalized) * 100);
return $cents > 0 ? $cents : null;
}
/**
* Matches a CSV name against a participant's paypal_name or display_name
* (case-insensitive), the same rule the legacy CSV import used.
*
* @return array{participant_id: int, legacy_mitarbeiter_id: ?int, display_name: string}|null
*/
function imports_find_participant(PDO $pdo, int $tenantId, string $name): ?array
{
$name = trim($name);
if ($name === '') {
return null;
}
$stmt = $pdo->prepare(
'SELECT id, legacy_mitarbeiter_id, display_name
FROM participants
WHERE tenant_id = ?
AND (LOWER(paypal_name) = LOWER(?) OR LOWER(display_name) = LOWER(?))
LIMIT 1'
);
$stmt->execute([$tenantId, $name, $name]);
$row = $stmt->fetch();
if ($row === false) {
return null;
}
return [
'participant_id' => (int)$row['id'],
'legacy_mitarbeiter_id' => $row['legacy_mitarbeiter_id'] !== null ? (int)$row['legacy_mitarbeiter_id'] : null,
'display_name' => (string)$row['display_name'],
];
}
/**
* 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.
*/
function imports_is_duplicate(PDO $pdo, int $tenantId, int $participantId, int $amountCents, string $bookedAtDate): bool
{
$stmt = $pdo->prepare(
"SELECT COUNT(*)
FROM ledger_entries
WHERE tenant_id = ?
AND participant_id = ?
AND type = 'payment'
AND amount_cents = ?
AND DATE(booked_at) = ?
AND voided_at IS NULL"
);
$stmt->execute([$tenantId, $participantId, $amountCents, $bookedAtDate]);
return (int)$stmt->fetchColumn() > 0;
}
/**
* Parses a PayPal-style export CSV: column 0 is the booking date, column 3
* the payer name, column 7 the gross amount. The first line is a header and
* is skipped.
*
* @return list<array{row_number: int, raw_name: string, amount_raw: string, date_raw: string, raw: list<string>}>
*/
function imports_parse_csv(string $filePath): array
{
$handle = fopen($filePath, 'r');
if ($handle === false) {
throw new RuntimeException('Die CSV-Datei konnte nicht gelesen werden.');
}
$rows = [];
$rowNumber = 0;
fgetcsv($handle); // Header ueberspringen
while (($data = fgetcsv($handle, 1000, ',')) !== false) {
$rowNumber++;
$rows[] = [
'row_number' => $rowNumber,
'raw_name' => trim((string)($data[3] ?? '')),
'amount_raw' => trim((string)($data[7] ?? '')),
'date_raw' => trim((string)($data[0] ?? '')),
'raw' => $data,
];
}
fclose($handle);
return $rows;
}
function imports_create_batch(PDO $pdo, int $tenantId, string $originalFilename, string $checksum, ?int $createdByUserId): int
{
$stmt = $pdo->prepare(
'INSERT INTO payment_import_batches (tenant_id, original_filename, checksum, status, created_by_user_id)
VALUES (?, ?, ?, ?, ?)'
);
$stmt->execute([$tenantId, $originalFilename, $checksum, 'previewed', $createdByUserId]);
return (int)$pdo->lastInsertId();
}
function imports_store_row(
PDO $pdo,
int $batchId,
int $rowNumber,
?int $participantId,
string $rawName,
int $amountCents,
string $bookedAt,
string $status,
array $rawRow
): int {
$stmt = $pdo->prepare(
'INSERT INTO payment_import_rows
(batch_id, row_num, participant_id, raw_name, amount_cents, booked_at, status, raw_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
);
$stmt->execute([
$batchId,
$rowNumber,
$participantId,
$rawName,
$amountCents,
$bookedAt,
$status,
json_encode($rawRow, JSON_UNESCAPED_UNICODE),
]);
return (int)$pdo->lastInsertId();
}
/**
* @return array{batch: array{id: int, original_filename: string, status: string, created_at: string}, rows: list<array>}|null
*/
function imports_fetch_batch(PDO $pdo, int $tenantId, int $batchId): ?array
{
$stmt = $pdo->prepare(
'SELECT id, original_filename, status, created_at
FROM payment_import_batches
WHERE id = ? AND tenant_id = ?'
);
$stmt->execute([$batchId, $tenantId]);
$batch = $stmt->fetch();
if ($batch === false) {
return null;
}
$stmt = $pdo->prepare(
'SELECT r.id, r.row_num, r.participant_id, r.raw_name, r.amount_cents, r.booked_at, r.status, p.display_name
FROM payment_import_rows r
LEFT JOIN participants p ON p.id = r.participant_id
WHERE r.batch_id = ?
ORDER BY r.row_num'
);
$stmt->execute([$batchId]);
return [
'batch' => [
'id' => (int)$batch['id'],
'original_filename' => (string)$batch['original_filename'],
'status' => (string)$batch['status'],
'created_at' => (string)$batch['created_at'],
],
'rows' => $stmt->fetchAll(),
];
}
/**
* Commits every 'matched' row of a previewed batch into the ledger: for
* participants with a legacy_mitarbeiter_id (default tenant), a matching
* kl_Einzahlungen row is dual-written and mirrored; every other tenant is
* booked straight into the ledger. Already-committed batches are rejected.
*
* @return array{imported: int}
*/
function imports_commit_batch(PDO $pdo, int $tenantId, int $batchId): array
{
$stmt = $pdo->prepare('SELECT status FROM payment_import_batches WHERE id = ? AND tenant_id = ?');
$stmt->execute([$batchId, $tenantId]);
$status = $stmt->fetchColumn();
if ($status === false) {
throw new RuntimeException('Der Import wurde nicht gefunden.');
}
if ($status !== 'previewed') {
throw new RuntimeException('Dieser Import wurde bereits verarbeitet.');
}
$stmt = $pdo->prepare(
"SELECT r.id, r.participant_id, r.amount_cents, r.booked_at, p.legacy_mitarbeiter_id
FROM payment_import_rows r
JOIN participants p ON p.id = r.participant_id
WHERE r.batch_id = ? AND r.status = 'matched'"
);
$stmt->execute([$batchId]);
$rows = $stmt->fetchAll();
$imported = 0;
$pdo->beginTransaction();
try {
$insertLegacy = $pdo->prepare(
'INSERT INTO kl_Einzahlungen (MitarbeiterID, Betrag, Datum) VALUES (?, ?, ?)'
);
$updateRow = $pdo->prepare(
"UPDATE payment_import_rows SET status = 'imported', ledger_entry_id = ? WHERE id = ?"
);
foreach ($rows as $row) {
$legacyMitarbeiterId = $row['legacy_mitarbeiter_id'] !== null ? (int)$row['legacy_mitarbeiter_id'] : null;
$amountCents = (int)$row['amount_cents'];
if ($legacyMitarbeiterId !== null) {
$insertLegacy->execute([$legacyMitarbeiterId, $amountCents / 100, $row['booked_at']]);
$legacyPaymentId = (int)$pdo->lastInsertId();
ledger_mirror_legacy_payment($pdo, $tenantId, $legacyPaymentId);
$entryStmt = $pdo->prepare(
"SELECT id FROM ledger_entries WHERE tenant_id = ? AND legacy_table = 'kl_Einzahlungen' AND legacy_id = ?"
);
$entryStmt->execute([$tenantId, $legacyPaymentId]);
$ledgerEntryId = (int)$entryStmt->fetchColumn();
} else {
$ledgerEntryId = ledger_record_payment($pdo, $tenantId, (int)$row['participant_id'], $amountCents, 'csv_import');
}
$updateRow->execute([$ledgerEntryId, $row['id']]);
$imported++;
}
$pdo->prepare("UPDATE payment_import_batches SET status = 'committed', committed_at = NOW() WHERE id = ?")
->execute([$batchId]);
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
return ['imported' => $imported];
}
+149 -186
View File
@@ -1,6 +1,8 @@
<?php
include "functions.php";
require_once __DIR__ . "/app/ledger.php";
require_once __DIR__ . "/app/imports.php";
app_require_csrf();
include "header.php";
include "headerline.php";
@@ -14,11 +16,29 @@ include "nav.php";
<div class="content">
<?php
if(checkKaffeelisteAdmin($conn, $mailadress)){
function csv_h($value): string
{
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
$pdo = app_db_pdo();
$saasUser = saas_current_user($pdo);
$tenantId = 0;
$hasAccess = false;
if ($saasUser !== null && saas_user_has_role(['owner', 'admin', 'treasurer'], $saasUser)) {
$tenantId = (int)$saasUser['tenant_id'];
$hasAccess = true;
}
if (!$hasAccess && $saasUser === null && checkKaffeelisteAdmin($conn, $mailadress)) {
$tenant = ledger_fetch_default_tenant($pdo);
if ($tenant !== null) {
$tenantId = (int)$tenant['id'];
$hasAccess = true;
}
}
if (!$hasAccess) {
echo "<h2>Kein Zugriff</h2>";
include "footer.php";
exit;
}
function csv_upload_dir(): string
@@ -26,6 +46,9 @@ function csv_upload_dir(): string
return APP_ROOT . '/var/uploads';
}
/**
* @return array{0: ?string, 1: ?string} [Pfad, Fehlermeldung]
*/
function csv_prepare_upload(array $file): array
{
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
@@ -55,12 +78,8 @@ function csv_prepare_upload(array $file): array
}
$allowedMimeTypes = [
'text/plain',
'text/csv',
'text/x-csv',
'application/csv',
'application/vnd.ms-excel',
'application/octet-stream',
'text/plain', 'text/csv', 'text/x-csv', 'application/csv',
'application/vnd.ms-excel', 'application/octet-stream',
];
if (is_string($mime) && !in_array($mime, $allowedMimeTypes, true)) {
return [null, 'Der Dateityp wurde nicht als CSV erkannt.'];
@@ -83,185 +102,93 @@ function csv_prepare_upload(array $file): array
return [$targetFile, null];
}
// Funktion zum Überprüfen, ob der Mitarbeiter in der Tabelle vorhanden ist
function isMitarbeiterExist($conn, $name)
function csv_status_label(string $status): string
{
$sql = "SELECT MitarbeiterID FROM kl_Mitarbeiter WHERE Name = ? or paypalname = ?";
$params = array($name, $name);
$stmt = sqlsrv_query($conn, $sql, $params);
if ($stmt === false) {
die(print_r(sqlsrv_errors(), true));
}
$row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC);
return ($row !== false);
return match ($status) {
'matched' => 'Wird importiert',
'duplicate' => 'Bereits vorhanden',
'unmatched' => 'Mitglied nicht gefunden',
'invalid' => 'Ungültige Zeile',
'imported' => 'Importiert',
default => $status,
};
}
// Funktion zum Überprüfen, ob ein identischer Eintrag bereits vorhanden ist
function isDuplicateEntry($conn, $mitarbeiterID, $betrag, $datum)
{
// Um nur den gleichen Tag zu vergleichen, konvertieren wir den Datum-String zu einem DateTime-Objekt und verwenden die Funktion CONVERT
$date = date_create($datum);
if ($date === false) {
return false;
}
$meldung = null;
$fehler = null;
$vorschauBatchId = null;
$convertedDatum = date_format($date, 'Y-m-d');
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$aktion = $_POST['aktion'] ?? 'hochladen';
#$sql = "SELECT count FROM dbo.kl_Einzahlungen WHERE MitarbeiterID = ? AND Betrag = ? AND CONVERT(VARCHAR, Datum, 23) = ?";
$sql = "SELECT * FROM dbo.kl_Einzahlungen WHERE MitarbeiterID = ? AND Betrag = ? AND CONVERT(VARCHAR, Datum, 23) = ?";
$params = array($mitarbeiterID, $betrag, $convertedDatum);
$stmt = sqlsrv_query($conn, $sql, $params);
if ($stmt === false) {
die(print_r(sqlsrv_errors(), true));
}
$row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC);
return $row !== null && (int)$row['MitarbeiterID'] > 0;
}
// Funktion zum Verarbeiten der CSV-Datei und Rückgabe der Ergebnisse
function processCSV($conn, $file)
{
$handle = fopen($file, "r");
$success_entries = [];
$failed_entries = [];
// Überspringe die erste Zeile (Header) der CSV-Datei
fgetcsv($handle);
while (($data = fgetcsv($handle, 1000, ",")) !== FALSE) {
if (count($data) < 8) {
$failed_entries[] = array($data[0] ?? '', $data[3] ?? '', $data[7] ?? '', 4);
continue;
}
$name = $data[3]; // Index 3 entspricht dem "Name"-Feld in der CSV
$betrag = $data[7]; // Index 7 entspricht dem "Brutto"-Feld in der CSV
$betrag = str_replace(",", ".", $betrag);
#echo "<br>";
#$Ausgabe = array($data[0], $data[3], $data[7]);
// Überprüfen, ob der Mitarbeiter existiert und der Betrag positiv ist
if (isMitarbeiterExist($conn, $name) && $betrag > 0) {
$mitarbeiterID = getMitarbeiterID($conn, $name);
$datum = $data[0]; // Index 0 entspricht dem "Datum"-Feld in der CSV
// Überprüfen, ob ein identischer Eintrag bereits vorhanden ist
if (!isDuplicateEntry($conn, $mitarbeiterID, $betrag, $datum)) {
// Eintrag in die Tabelle dbo.kl_Einzahlungen einfügen
$sql = "INSERT INTO kl_Einzahlungen (MitarbeiterID, Betrag, Datum) VALUES (?, ?, ?)";
$params = array($mitarbeiterID, $betrag, $datum);
$stmt = sqlsrv_query($conn, $sql, $params);
if ($stmt) {
$success_entries[] = array($data[0], $data[3], $data[7], 0);
} else {
$failed_entries[] = array($data[0], $data[3], $data[7], 1);
#die(print_r(sqlsrv_errors(), true));
}
}else {
#echo "Eintrag bereits vorhanden: " . print_r($data, true) . "\n";
$failed_entries[] = array($data[0], $data[3], $data[7], 2);
}
} else {
$failed_entries[] = array($data[0], $data[3], $data[7], 3);
}
}
fclose($handle);
return ['success' => $success_entries, 'failed' => $failed_entries];
}
// Funktion zum Abrufen der MitarbeiterID
function getMitarbeiterID($conn, $name)
{
$sql = "SELECT MitarbeiterID FROM kl_Mitarbeiter WHERE Name = ? or paypalname = ?";
$params = array($name, $name);
$stmt = sqlsrv_query($conn, $sql, $params);
if ($stmt === false) {
die(print_r(sqlsrv_errors(), true));
}
while( $row = sqlsrv_fetch_array( $stmt, SQLSRV_FETCH_ASSOC))
{
return $row['MitarbeiterID'];
}
#echo $mitarbeiterid;
}
// Überprüfen, ob das Formular abgeschickt wurde
if ($_SERVER["REQUEST_METHOD"] == "POST") {
// Überprüfen, ob eine Datei hochgeladen wurde
if (isset($_FILES["csv_file"])) {
if ($aktion === 'hochladen' && isset($_FILES["csv_file"])) {
[$csvFile, $uploadError] = csv_prepare_upload($_FILES["csv_file"]);
if ($uploadError !== null) {
echo csv_h($uploadError);
$fehler = $uploadError;
} else {
// CSV-Datei verarbeiten
$result = processCSV($conn, $csvFile);
try {
$checksum = hash_file('sha256', $csvFile);
$rows = imports_parse_csv($csvFile);
$pdo->beginTransaction();
$batchId = imports_create_batch(
$pdo,
$tenantId,
basename((string)$_FILES["csv_file"]["name"]),
(string)$checksum,
$saasUser['user_id'] ?? null
);
foreach ($rows as $row) {
$amountCents = imports_normalize_amount($row['amount_raw']);
$timestamp = $row['date_raw'] !== '' ? strtotime($row['date_raw']) : false;
if ($amountCents === null || $timestamp === false) {
imports_store_row($pdo, $batchId, $row['row_number'], null, $row['raw_name'], $amountCents ?? 0, date('Y-m-d H:i:s', $timestamp ?: time()), 'invalid', $row['raw']);
continue;
}
$bookedAt = date('Y-m-d H:i:s', $timestamp);
$participant = imports_find_participant($pdo, $tenantId, $row['raw_name']);
if ($participant === null) {
imports_store_row($pdo, $batchId, $row['row_number'], null, $row['raw_name'], $amountCents, $bookedAt, 'unmatched', $row['raw']);
continue;
}
if (imports_is_duplicate($pdo, $tenantId, $participant['participant_id'], $amountCents, date('Y-m-d', $timestamp))) {
imports_store_row($pdo, $batchId, $row['row_number'], $participant['participant_id'], $row['raw_name'], $amountCents, $bookedAt, 'duplicate', $row['raw']);
continue;
}
imports_store_row($pdo, $batchId, $row['row_number'], $participant['participant_id'], $row['raw_name'], $amountCents, $bookedAt, 'matched', $row['raw']);
}
$pdo->commit();
$vorschauBatchId = $batchId;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
$fehler = 'Die CSV-Datei konnte nicht verarbeitet werden.';
} finally {
@unlink($csvFile);
echo "CSV-Datei erfolgreich verarbeitet.\n";
echo "<h3>Auswertung</h3>";
echo "<table>
<tr>
<th>Name</th>
<th>Datum</th>
<th>Betrag</th>
<th>Ergebnis</th>
</tr>";
foreach ($result['success'] as $eintrag){
echo "<tr>
<td>" . csv_h($eintrag[0]) . "</td>
<td>" . csv_h($eintrag[1]) . "</td>
<td>" . csv_h($eintrag[2]) . "</td>
<td>Erfolgreich gespeichert</td>
</tr>";
}
foreach ($result['failed'] as $eintrag){
echo "<tr>
<td>" . csv_h($eintrag[0]) . "</td>
<td>" . csv_h($eintrag[1]) . "</td>
<td>" . csv_h($eintrag[2]) . "</td>
<td>";
if($eintrag[3] == 1){
echo "SQL Fehler";
}elseif($eintrag[3] == 2){
echo "Eintrag schon vorhanden";
}elseif($eintrag[3] == 3){
echo "Benutzer nicht gefunden";
}elseif($eintrag[3] == 4){
echo "Ungültige CSV-Zeile";
}
echo "</td>
</tr>";
} elseif ($aktion === 'importieren') {
$batchId = (int)($_POST['batch_id'] ?? 0);
try {
$ergebnis = imports_commit_batch($pdo, $tenantId, $batchId);
$meldung = $ergebnis['imported'] . ' Einzahlungen wurden importiert.';
} catch (Throwable $e) {
$fehler = $e->getMessage();
$vorschauBatchId = $batchId;
}
echo "</table>";
}
} else {
echo "Fehler beim Hochladen der Datei.";
}
}
$vorschau = $vorschauBatchId !== null ? imports_fetch_batch($pdo, $tenantId, $vorschauBatchId) : null;
?>
@@ -273,26 +200,62 @@ if ($_SERVER["REQUEST_METHOD"] == "POST") {
<title>CSV Verarbeitung</title>
</head>
<body>
<form action="<?php echo $_SERVER["PHP_SELF"]; ?>" method="post" enctype="multipart/form-data">
<h2>CSV-Import</h2>
<?php if ($meldung !== null): ?>
<div class="hint-box success"><p><?php echo saas_html($meldung); ?></p></div>
<?php endif; ?>
<?php if ($fehler !== null): ?>
<div class="hint-box error"><p><?php echo saas_html($fehler); ?></p></div>
<?php endif; ?>
<?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>
<table>
<tr>
<th>Zeile</th>
<th>Name (CSV)</th>
<th>Zugeordnetes Mitglied</th>
<th>Betrag</th>
<th>Datum</th>
<th>Status</th>
</tr>
<?php foreach ($vorschau['rows'] as $row): ?>
<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>
</tr>
<?php endforeach; ?>
</table>
<?php if ($vorschau['batch']['status'] === 'previewed'): ?>
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
<?php echo app_csrf_field(); ?>
<input type="hidden" name="aktion" value="importieren">
<input type="hidden" name="batch_id" value="<?php echo (int)$vorschau['batch']['id']; ?>">
<button type="submit">Import bestätigen</button>
</form>
<?php endif; ?>
<br>
<?php endif; ?>
<form action="<?php echo $_SERVER["PHP_SELF"]; ?>" method="post" enctype="multipart/form-data">
<?php echo app_csrf_field(); ?>
<input type="hidden" name="aktion" value="hochladen">
<label for="csv_file">CSV-Datei auswählen:</label>
<input type="file" name="csv_file" accept=".csv" required>
<button type="submit">Datei hochladen</button>
</form>
</form>
</body>
</html>
<?php
}else{
echo "<h2>Kein Zugriff</h2>";
}
?>
</div>
</section>
@@ -0,0 +1,61 @@
CREATE TABLE IF NOT EXISTS payment_import_batches (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
original_filename VARCHAR(255) NOT NULL,
checksum CHAR(64) NOT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'previewed',
created_by_user_id INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
committed_at DATETIME NULL,
KEY idx_payment_import_batches_tenant (tenant_id, created_at),
CONSTRAINT fk_payment_import_batches_tenant
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
ON DELETE CASCADE,
CONSTRAINT fk_payment_import_batches_created_by_user
FOREIGN KEY (created_by_user_id) REFERENCES users(id)
ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS payment_import_rows (
id INT AUTO_INCREMENT PRIMARY KEY,
batch_id INT NOT NULL,
row_num INT NOT NULL,
participant_id INT NULL,
raw_name VARCHAR(255) NOT NULL,
amount_cents INT NOT NULL,
booked_at DATETIME NOT NULL,
status VARCHAR(30) NOT NULL,
raw_json TEXT NULL,
ledger_entry_id INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_payment_import_rows_batch (batch_id),
KEY idx_payment_import_rows_participant (participant_id),
CONSTRAINT fk_payment_import_rows_batch
FOREIGN KEY (batch_id) REFERENCES payment_import_batches(id)
ON DELETE CASCADE,
CONSTRAINT fk_payment_import_rows_participant
FOREIGN KEY (participant_id) REFERENCES participants(id)
ON DELETE SET NULL,
CONSTRAINT fk_payment_import_rows_ledger_entry
FOREIGN KEY (ledger_entry_id) REFERENCES ledger_entries(id)
ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SET @schema_name = DATABASE();
SET @sql = (
SELECT IF(
EXISTS (
SELECT 1
FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA = @schema_name
AND TABLE_NAME = 'ledger_entries'
AND CONSTRAINT_NAME = 'fk_ledger_entries_import_batch'
),
'SELECT 1',
'ALTER TABLE ledger_entries ADD CONSTRAINT fk_ledger_entries_import_batch FOREIGN KEY (import_batch_id) REFERENCES payment_import_batches(id) ON DELETE SET NULL'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
+60
View File
@@ -0,0 +1,60 @@
# M6 Import, Export und Mail
Stand: 2026-07-15
M6 macht die Admin-/Treasurer-Flows produktionsreif: CSV-Import,
PDF-/Listenexport, Mailversand und Jahresprozesse laufen tenant-sicher
gegen das neue Modell statt gegen Legacy-Tabellen direkt.
## CSV-Import
Umgesetzte Dateien:
```text
database/migrations/0008_saas_payment_imports.sql
app/imports.php
csvupload.php
```
Ziel: Zahlungsimport mit echter Vorschau, Dublettenprüfung und Audit-Trail,
statt sofortiger Verarbeitung ohne Bestätigungsschritt wie im Legacy-Stand.
Umfang:
- Neue Tabellen `payment_import_batches` und `payment_import_rows` (siehe
Kernschema im Umstrukturierungsplan) protokollieren jeden Import: Datei,
Prüfsumme, Zeitpunkt, jede einzelne Zeile mit Rohwerten, erkanntem
Mitglied, Status und nach Bestätigung der erzeugten Ledger-Zeile.
- Zweistufiger Ablauf: Hochladen erzeugt eine Vorschau (`status = previewed`)
ohne jede Buchung; erst der zweite Schritt „Import bestätigen“ committet
die als `matched` erkannten Zeilen (`status = committed`).
- Zeilen werden klassifiziert: `matched` (wird gebucht), `duplicate`
(gleicher Teilnehmer, gleicher Betrag, gleicher Tag existiert bereits als
nicht-stornierte Ledger-Zahlung), `unmatched` (kein Mitglied per
`paypal_name` oder `display_name` gefunden), `invalid` (Betrag oder Datum
nicht parsebar).
- Zuordnungsregel entspricht der Legacy-Logik: Name aus der CSV wird gegen
`paypal_name` oder `display_name` verglichen (case-insensitiv).
- Buchung folgt demselben Zweig-Muster wie die übrige Sammelerfassung: Für
Teilnehmer mit `legacy_mitarbeiter_id` (Default-Mandant) wird zusätzlich
eine `kl_Einzahlungen`-Zeile geschrieben und gespiegelt; alle anderen
Mandanten werden direkt über `ledger_record_payment()` gebucht.
- Zugriffskontrolle ergänzt: Die Seite hatte zuvor gar keine Prüfung außer
CSRF. Jetzt gilt dieselbe Rollen-/Legacy-Fallback-Logik wie bei
`einzahlung.php` (`owner`, `admin`, `treasurer`).
- Upload-Härtung aus M2 (Größen-/Typprüfung, Speicherung außerhalb des
direkt erreichbaren Codes unter `var/uploads`, Löschung nach Verarbeitung)
bleibt erhalten.
Live gegen die Dev-Datenbank getestet: Upload mit drei Testzeilen (Treffer,
unbekannter Name, ungültiger Betrag), Vorschau-Klassifizierung geprüft,
Import bestätigt (Dual-Write und Ledger-Spiegelung korrekt), erneuter Upload
derselben Datei erkennt die bereits importierte Zeile korrekt als Dublette.
Testdaten anschließend entfernt.
## Prüfstatus
- Golden Master: grün mit 104 Assertions.
- M4 Ledger-Migration: grün mit 73 Assertions.
- M4 Ledger-Service: grün mit 115 Assertions.
- HTTP-Smoke: grün mit 23 geprüften Seiten.
+9 -3
View File
@@ -306,7 +306,7 @@ Nicht tun:
| M3 | SaaS-Basis | Abgeschlossen: Tenants, User, Registrierung, Login, Rollen, Mail-Links und zentrale Mandantenauswahl funktionieren |
| M4 | Datenmigration | Gestartet: Ledger-Tabelle, Legacy-Backfill, Paritätscheck, Ledger-Service und Preview sind umgesetzt |
| M5 | App-Kern | Weit fortgeschritten: Kernseiten lesen und schreiben tenant-sicher gegen das Ledger, inklusive Hinweise und rollenbasiertem Zugang; offen ist ein eigener Zahlungs-Screen |
| M6 | Betriebsflows | Import, Export, Mail und Jahresprozesse sind auditierbar |
| M6 | Betriebsflows | Gestartet: CSV-Import mit Vorschau/Dublettenprüfung steht; Export, Mail und Jahresprozesse offen |
| M7 | Landingpage | Public-Seite und Auth-Seiten sind im gemeinsamen Stil nutzbar; spätere Ausbaustufen folgen |
| M8 | Härtung | Betrieb, Datenschutz, Monitoring und Isolation sind geprüft |
| M9 | Cutover | Produktivumstellung ist vorbereitet und Legacy ist read-only |
@@ -565,8 +565,13 @@ Admin- und Treasurer-Flows produktionsreif machen.
Schritte:
- CSV-Import mit Vorschau, Dublettenprüfung und Audit bauen.
- Uploads außerhalb des Webroots speichern.
- CSV-Import mit Vorschau, Dublettenprüfung und Audit bauen: erledigt.
Neue Tabellen `payment_import_batches`/`payment_import_rows`,
zweistufiger Ablauf (Vorschau vor Buchung), Zuordnung per PayPal-Name
oder Anzeigename, Dublettenprüfung gegen bestehende Ledger-Zahlungen.
`csvupload.php` hatte zuvor keine Zugriffskontrolle; das ist behoben.
- Uploads außerhalb des Webroots speichern: erledigt (bereits in M2,
weiterhin genutzt).
- PDF-/Listenexport aus neuem Datenmodell bauen.
- Mailversand als nachvollziehbaren Versandjob mit Dry-Run und Versandlog
gestalten.
@@ -576,6 +581,7 @@ Ergebnis:
- Kassenverwaltung ist für reale Betriebsabläufe vollständig.
- Import/Export/Mail sind auditierbar.
- Stand: gestartet. Dokumentation: `docs/m6-import-export-mail.md`.
Abhängigkeiten: