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:
+265
@@ -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];
|
||||||
|
}
|
||||||
+147
-184
@@ -1,6 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
include "functions.php";
|
include "functions.php";
|
||||||
|
require_once __DIR__ . "/app/ledger.php";
|
||||||
|
require_once __DIR__ . "/app/imports.php";
|
||||||
app_require_csrf();
|
app_require_csrf();
|
||||||
include "header.php";
|
include "header.php";
|
||||||
include "headerline.php";
|
include "headerline.php";
|
||||||
@@ -14,11 +16,29 @@ include "nav.php";
|
|||||||
<div class="content">
|
<div class="content">
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
if(checkKaffeelisteAdmin($conn, $mailadress)){
|
|
||||||
|
|
||||||
function csv_h($value): string
|
$pdo = app_db_pdo();
|
||||||
{
|
$saasUser = saas_current_user($pdo);
|
||||||
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
|
$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
|
function csv_upload_dir(): string
|
||||||
@@ -26,6 +46,9 @@ function csv_upload_dir(): string
|
|||||||
return APP_ROOT . '/var/uploads';
|
return APP_ROOT . '/var/uploads';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: ?string, 1: ?string} [Pfad, Fehlermeldung]
|
||||||
|
*/
|
||||||
function csv_prepare_upload(array $file): array
|
function csv_prepare_upload(array $file): array
|
||||||
{
|
{
|
||||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||||
@@ -55,12 +78,8 @@ function csv_prepare_upload(array $file): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
$allowedMimeTypes = [
|
$allowedMimeTypes = [
|
||||||
'text/plain',
|
'text/plain', 'text/csv', 'text/x-csv', 'application/csv',
|
||||||
'text/csv',
|
'application/vnd.ms-excel', 'application/octet-stream',
|
||||||
'text/x-csv',
|
|
||||||
'application/csv',
|
|
||||||
'application/vnd.ms-excel',
|
|
||||||
'application/octet-stream',
|
|
||||||
];
|
];
|
||||||
if (is_string($mime) && !in_array($mime, $allowedMimeTypes, true)) {
|
if (is_string($mime) && !in_array($mime, $allowedMimeTypes, true)) {
|
||||||
return [null, 'Der Dateityp wurde nicht als CSV erkannt.'];
|
return [null, 'Der Dateityp wurde nicht als CSV erkannt.'];
|
||||||
@@ -83,185 +102,93 @@ function csv_prepare_upload(array $file): array
|
|||||||
return [$targetFile, null];
|
return [$targetFile, null];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function csv_status_label(string $status): string
|
||||||
// Funktion zum Überprüfen, ob der Mitarbeiter in der Tabelle vorhanden ist
|
|
||||||
function isMitarbeiterExist($conn, $name)
|
|
||||||
{
|
{
|
||||||
$sql = "SELECT MitarbeiterID FROM kl_Mitarbeiter WHERE Name = ? or paypalname = ?";
|
return match ($status) {
|
||||||
$params = array($name, $name);
|
'matched' => 'Wird importiert',
|
||||||
$stmt = sqlsrv_query($conn, $sql, $params);
|
'duplicate' => 'Bereits vorhanden',
|
||||||
|
'unmatched' => 'Mitglied nicht gefunden',
|
||||||
if ($stmt === false) {
|
'invalid' => 'Ungültige Zeile',
|
||||||
die(print_r(sqlsrv_errors(), true));
|
'imported' => 'Importiert',
|
||||||
|
default => $status,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
$row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC);
|
$meldung = null;
|
||||||
|
$fehler = null;
|
||||||
|
$vorschauBatchId = null;
|
||||||
|
|
||||||
return ($row !== false);
|
if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||||
}
|
$aktion = $_POST['aktion'] ?? 'hochladen';
|
||||||
|
|
||||||
// Funktion zum Überprüfen, ob ein identischer Eintrag bereits vorhanden ist
|
if ($aktion === 'hochladen' && isset($_FILES["csv_file"])) {
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
$convertedDatum = date_format($date, 'Y-m-d');
|
|
||||||
|
|
||||||
#$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"])) {
|
|
||||||
[$csvFile, $uploadError] = csv_prepare_upload($_FILES["csv_file"]);
|
[$csvFile, $uploadError] = csv_prepare_upload($_FILES["csv_file"]);
|
||||||
|
|
||||||
if ($uploadError !== null) {
|
if ($uploadError !== null) {
|
||||||
echo csv_h($uploadError);
|
$fehler = $uploadError;
|
||||||
} else {
|
} else {
|
||||||
// CSV-Datei verarbeiten
|
try {
|
||||||
$result = processCSV($conn, $csvFile);
|
$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);
|
@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";
|
|
||||||
}
|
}
|
||||||
|
} elseif ($aktion === 'importieren') {
|
||||||
echo "</td>
|
$batchId = (int)($_POST['batch_id'] ?? 0);
|
||||||
</tr>";
|
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,8 +200,55 @@ if ($_SERVER["REQUEST_METHOD"] == "POST") {
|
|||||||
<title>CSV Verarbeitung</title>
|
<title>CSV Verarbeitung</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
<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">
|
<form action="<?php echo $_SERVER["PHP_SELF"]; ?>" method="post" enctype="multipart/form-data">
|
||||||
<?php echo app_csrf_field(); ?>
|
<?php echo app_csrf_field(); ?>
|
||||||
|
<input type="hidden" name="aktion" value="hochladen">
|
||||||
<label for="csv_file">CSV-Datei auswählen:</label>
|
<label for="csv_file">CSV-Datei auswählen:</label>
|
||||||
<input type="file" name="csv_file" accept=".csv" required>
|
<input type="file" name="csv_file" accept=".csv" required>
|
||||||
<button type="submit">Datei hochladen</button>
|
<button type="submit">Datei hochladen</button>
|
||||||
@@ -282,17 +256,6 @@ if ($_SERVER["REQUEST_METHOD"] == "POST") {
|
|||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
<?php
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}else{
|
|
||||||
echo "<h2>Kein Zugriff</h2>";
|
|
||||||
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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;
|
||||||
@@ -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.
|
||||||
@@ -306,7 +306,7 @@ Nicht tun:
|
|||||||
| M3 | SaaS-Basis | Abgeschlossen: Tenants, User, Registrierung, Login, Rollen, Mail-Links und zentrale Mandantenauswahl funktionieren |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| M8 | Härtung | Betrieb, Datenschutz, Monitoring und Isolation sind geprüft |
|
||||||
| M9 | Cutover | Produktivumstellung ist vorbereitet und Legacy ist read-only |
|
| M9 | Cutover | Produktivumstellung ist vorbereitet und Legacy ist read-only |
|
||||||
@@ -565,8 +565,13 @@ Admin- und Treasurer-Flows produktionsreif machen.
|
|||||||
|
|
||||||
Schritte:
|
Schritte:
|
||||||
|
|
||||||
- CSV-Import mit Vorschau, Dublettenprüfung und Audit bauen.
|
- CSV-Import mit Vorschau, Dublettenprüfung und Audit bauen: erledigt.
|
||||||
- Uploads außerhalb des Webroots speichern.
|
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.
|
- PDF-/Listenexport aus neuem Datenmodell bauen.
|
||||||
- Mailversand als nachvollziehbaren Versandjob mit Dry-Run und Versandlog
|
- Mailversand als nachvollziehbaren Versandjob mit Dry-Run und Versandlog
|
||||||
gestalten.
|
gestalten.
|
||||||
@@ -576,6 +581,7 @@ Ergebnis:
|
|||||||
|
|
||||||
- Kassenverwaltung ist für reale Betriebsabläufe vollständig.
|
- Kassenverwaltung ist für reale Betriebsabläufe vollständig.
|
||||||
- Import/Export/Mail sind auditierbar.
|
- Import/Export/Mail sind auditierbar.
|
||||||
|
- Stand: gestartet. Dokumentation: `docs/m6-import-export-mail.md`.
|
||||||
|
|
||||||
Abhängigkeiten:
|
Abhängigkeiten:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user