From 1b532b937fc5e4f18c757d7590c47aac61919439 Mon Sep 17 00:00:00 2001 From: ZeroIchiro Date: Tue, 15 Sep 2026 10:47:36 +0000 Subject: [PATCH] Import current PCAT application --- .gitignore | 11 + .htaccess | 5 + README_SETUP.txt | 90 ++++ admin.php | 167 +++++++ author_search.php | 24 + billing.php | 484 +++++++++++++++++++ css/admin.css | 36 ++ css/index.html | 2 + inc/.htaccess | 1 + inc/admin_bootstrap.php | 83 ++++ inc/db.inc.php.example | 12 + inc/index.html | 2 + index.html | 2 + migrations/001_add_ebook_price.sql | 3 + migrations/002_add_author_payment_fields.sql | 4 + scripts/admin.js | 56 +++ scripts/index.html | 2 + tat_schema.sql | 75 +++ 18 files changed, 1059 insertions(+) create mode 100644 .gitignore create mode 100644 .htaccess create mode 100644 README_SETUP.txt create mode 100644 admin.php create mode 100644 author_search.php create mode 100644 billing.php create mode 100644 css/admin.css create mode 100644 css/index.html create mode 100644 inc/.htaccess create mode 100644 inc/admin_bootstrap.php create mode 100644 inc/db.inc.php.example create mode 100644 inc/index.html create mode 100644 index.html create mode 100644 migrations/001_add_ebook_price.sql create mode 100644 migrations/002_add_author_payment_fields.sql create mode 100644 scripts/admin.js create mode 100644 scripts/index.html create mode 100644 tat_schema.sql diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b47ce9f --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Local/runtime configuration +/inc/db.inc.php + +# Local temporary and import files +/tmp/ +*.log + +# Editor and OS files +.DS_Store +.idea/ +.vscode/ diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..ea22c3f --- /dev/null +++ b/.htaccess @@ -0,0 +1,5 @@ +Options -Indexes + + + Require all denied + diff --git a/README_SETUP.txt b/README_SETUP.txt new file mode 100644 index 0000000..bc9d40c --- /dev/null +++ b/README_SETUP.txt @@ -0,0 +1,90 @@ +Einrichtung des Admin-Bereichs + +1. Datenbank und Benutzer anlegen und anschließend tat_schema.sql importieren. +2. Die Platzhalter in inc/db.inc.php ersetzen oder besser eine geschützte Konfiguration außerhalb des Webroots verwenden. +3. Einen Admin-Benutzer mit einem sicheren Passwort anlegen. Beispiel für einen Hash: + + php -r 'echo password_hash("HIER_EIN_LANGES_EINMALIGES_PASSWORT", PASSWORD_DEFAULT), PHP_EOL;' + + INSERT INTO admin_users (username, password_hash) + VALUES ('admin', 'HIER_DEN_HASH_EINSETZEN'); + +4. Danach ist der Bereich unter /tat/admin.php erreichbar. + +Die Anwendung verwendet PDO mit echten Prepared Statements, CSRF-Schutz, +Session-Härtung und getrennte CSS-/JavaScript-Dateien. + +CSV-Import für die Abrechnung + +Auf der Seite /tat/billing.php kann eine JTL-Verkaufs-CSV hochgeladen werden. +Die Datei muss mindestens diese Spalten enthalten: + + Kundengruppe;ISBN;Menge + Großhandel;9781234567890;25 + Buchhandel;9781234567890;12 + Endkunden;9781234567890;8 + +Erlaubte Spaltennamen: +- Kundengruppe: Kundengruppe, Kundengruppen oder Gruppe +- Buchkennung: ISBN, ISBN/EAN, EAN oder ISBN Print +- Verkaufsmenge: Menge, Anzahl, Verkaufsmenge oder Quantity + +Unterstützte CSV-Trennzeichen sind Semikolon, Komma und Tabulator. Die Print-ISBN +wird mit der in TAT hinterlegten Print-ISBN verglichen. Es werden nur Bücher mit +Print-ISBN verarbeitet; eBook-only-Bücher bleiben ausgeschlossen. + +Die Kundengruppen werden wie folgt den Abrechnungswegen zugeordnet: +- Großhandel, Grosshandel oder Buchgrosshandel -> Großhandel +- Buchhandel -> Buchhandel +- Endkunden, Direktkunden, Direkt oder Privatkunden -> Direkt + +Mehrere Zeilen für dieselbe ISBN und Kundengruppe werden addiert. Nach dem Upload +zeigt TAT eine Vorschau. Erst nach der Bestätigung und Auswahl der gewünschten +Bücher werden die Mengen gespeichert. Die bisher gespeicherten Mengen der +bestätigten Bücher werden dabei ersetzt; nicht ausgewählte Bücher bleiben unverändert. +Die maximale Dateigröße beträgt 5 MB. + +CSV-Struktur für die eBook-Abrechnung + +Für eBooks wird eine separate CSV-Datei verwendet. Der Verkaufsweg wird +beibehalten, beeinflusst die Berechnung aber nicht. Es gibt keine Zuordnung zu +Großhandel, Buchhandel oder Direktverkauf. + +Verbindliche Spaltenstruktur: + + Verkaufsdatum;Verkaufsweg;ISBN;Titel;Erlös + 2026-01-05;Amazon Kindle;9781234567890;Buchtitel A;17,97 + 2026-01-06;Apple Books;9781234567890;Buchtitel A;11,98 + 2026-01-08;Tolino;9781234567890;Buchtitel A;5,99 + 2026-01-10;Amazon Kindle;9789876543210;Buchtitel B;23,96 + +Pflichtspalten: +- Verkaufsdatum: Verkaufs- oder Abrechnungsdatum des Distributors; daraus wird der Abrechnungsmonat abgeleitet +- Verkaufsweg: Der vom Distributor gelieferte Verkaufsweg, zum Beispiel Amazon Kindle, Apple Books oder Tolino +- ISBN: Abgleich mit der in TAT hinterlegten eBook-ISBN +- Titel: Bezeichnung des eBooks zur Kontrolle +- Erlös: Der vom Distributor gemeldete Erlös dieser Zeile in Euro + +Die CSV enthält keine Spalte für Abrechnungszeitraum, Transaktionsnummer, +Menge, Währung oder Korrektur. Der Abrechnungszeitraum wird aus dem +Verkaufsdatum abgeleitet. Der Erlös ist der vollständige Betrag der jeweiligen +Zeile. Stornos oder Korrekturen werden als negativer Erlös übertragen. + +Der Erlös wird unverändert übernommen. TAT zieht keine Druckkosten, +Vertriebskosten oder sonstige Abzüge ab. Die Berechnung lautet: + + Autorenanteil = Erlös x Autorenmarge / 100 + +Beispiel: + + Erlös: 17,97 EUR + Autorenmarge: 50 Prozent + Autorenanteil: 8,985 EUR, angezeigt als 8,99 EUR + +Mehrere Zeilen werden nach eBook, Verkaufsdatum und Verkaufsweg +zusammengefasst. Der Verkaufsweg bleibt dabei in der Auswertung erhalten und +wird nicht in eine Print-Vertriebskategorie umgewandelt. + +Die Datei wird als UTF-8-CSV mit Semikolon als Trennzeichen und Dezimalkomma +bereitgestellt. Die ISBN ist der eindeutige Buchschlüssel; der Titel dient nur +der Kontrolle. diff --git a/admin.php b/admin.php new file mode 100644 index 0000000..b1d57f6 --- /dev/null +++ b/admin.php @@ -0,0 +1,167 @@ + time()) { + $error = 'Zu viele Fehlversuche. Bitte später erneut versuchen.'; + } else { + $statement = $pdo->prepare('SELECT id, username, password_hash FROM admin_users WHERE username = :username AND is_active = 1 LIMIT 1'); + $statement->execute([':username' => $username]); + $admin = $statement->fetch(); + if ($admin && password_verify($password, $admin['password_hash'])) { + session_regenerate_id(true); + $_SESSION['admin_id'] = (int) $admin['id']; + $_SESSION['admin_username'] = $admin['username']; + $_SESSION['admin_csrf'] = bin2hex(random_bytes(32)); + unset($_SESSION['admin_login_failures'], $_SESSION['admin_locked_until']); + redirectAdmin(); + } + $failures = (int) ($_SESSION['admin_login_failures'] ?? 0) + 1; + $_SESSION['admin_login_failures'] = $failures; + if ($failures >= 5) $_SESSION['admin_locked_until'] = time() + 300; + usleep(500000); + $error = 'Benutzername oder Passwort ist ungültig.'; + } +} + +$loggedIn = isset($_SESSION['admin_id']); +if ($loggedIn) { + requireAdmin(); + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + verifyCsrf(); + try { + $action = postText('action', 40); + if ($action === 'logout') { + $_SESSION = []; + session_destroy(); + redirectAdmin(); + } + if ($action === 'save_author') { + $id = filter_var($_POST['author_id'] ?? null, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); + $name = postText('name', 255); + $address = postText('address', 255); + $postalCode = postText('postal_code', 20); + $city = postText('city', 120); + $bankAccount = postText('bank_account', 255); + $bic = postText('bic', 20); + $bankName = postText('bank_name', 255); + $paypalAddress = postText('paypal_address', 255); + if ($name === '') throw new InvalidArgumentException('Der Autorenname ist erforderlich.'); + if ($id) { + $statement = $pdo->prepare('UPDATE authors SET name = :name, address = :address, postal_code = :postal_code, city = :city, bank_account = :bank_account, bic = :bic, bank_name = :bank_name, paypal_address = :paypal_address WHERE id = :id'); + $statement->execute([ + ':name' => $name, + ':address' => $address, + ':postal_code' => $postalCode, + ':city' => $city, + ':bank_account' => $bankAccount, + ':bic' => $bic, + ':bank_name' => $bankName, + ':paypal_address' => $paypalAddress, + ':id' => $id, + ]); + } else { + $statement = $pdo->prepare('INSERT INTO authors (name, address, postal_code, city, bank_account, bic, bank_name, paypal_address) VALUES (:name, :address, :postal_code, :city, :bank_account, :bic, :bank_name, :paypal_address)'); + $statement->execute([':name' => $name, ':address' => $address, ':postal_code' => $postalCode, ':city' => $city, ':bank_account' => $bankAccount, ':bic' => $bic, ':bank_name' => $bankName, ':paypal_address' => $paypalAddress]); + } + $message = 'Autor gespeichert.'; + } elseif ($action === 'delete_author') { + $id = filter_var($_POST['author_id'] ?? null, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); + if (!$id) throw new InvalidArgumentException('Ungültiger Autor.'); + $statement = $pdo->prepare('DELETE FROM authors WHERE id = :id'); + $statement->execute([':id' => $id]); + $message = $statement->rowCount() ? 'Autor gelöscht.' : 'Autor nicht gefunden.'; + } elseif ($action === 'save_book') { + $id = filter_var($_POST['book_id'] ?? null, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); + $title = postText('title', 255); + $printingCosts = filter_var($_POST['printing_costs'] ?? null, FILTER_VALIDATE_FLOAT); + if ($printingCosts === false || $printingCosts < 0 || $printingCosts > 99999999.99) throw new InvalidArgumentException('Die Druckkosten müssen zwischen 0 und 99.999.999,99 liegen.'); + $printingCosts = round((float) $printingCosts, 2); + $sellingPrice = filter_var($_POST['selling_price'] ?? null, FILTER_VALIDATE_FLOAT); + if ($sellingPrice === false || $sellingPrice < 0 || $sellingPrice > 99999999.99) throw new InvalidArgumentException('Der Verkaufspreis muss zwischen 0 und 99.999.999,99 liegen.'); + $sellingPrice = round((float) $sellingPrice, 2); + $ebookPrice = filter_var($_POST['ebook_price'] ?? null, FILTER_VALIDATE_FLOAT); + if ($ebookPrice === false || $ebookPrice < 0 || $ebookPrice > 99999999.99) throw new InvalidArgumentException('Der eBook-Preis muss zwischen 0 und 99.999.999,99 liegen.'); + $ebookPrice = round((float) $ebookPrice, 2); + $isbnPrint = postText('isbn_print', 20) ?: null; + $isbnEbook = postText('isbn_ebook', 20) ?: null; + $authorIds = $_POST['author_id'] ?? []; + $margins = $_POST['margin_percent'] ?? []; + if ($title === '') throw new InvalidArgumentException('Der Buchtitel ist erforderlich.'); + if (!is_array($authorIds) || !is_array($margins) || count($authorIds) !== count($margins) || count($authorIds) < 1 || count($authorIds) > 50) throw new InvalidArgumentException('Bitte mindestens einen und höchstens 50 Autoren zuweisen.'); + $authors = []; + foreach ($authorIds as $index => $rawAuthorId) { + $authorId = filter_var($rawAuthorId, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); + $margin = filter_var($margins[$index], FILTER_VALIDATE_FLOAT); + if (!$authorId || $margin === false || $margin < 0 || $margin > 100 || isset($authors[$authorId])) throw new InvalidArgumentException('Autoren und Margen sind ungültig oder doppelt vergeben.'); + $authors[$authorId] = round((float) $margin, 2); + } + $pdo->beginTransaction(); + if ($id) { + $statement = $pdo->prepare('UPDATE books SET title = :title, printing_costs = :printing_costs, selling_price = :selling_price, ebook_price = :ebook_price, isbn_print = :isbn_print, isbn_ebook = :isbn_ebook WHERE id = :id'); + $statement->execute([':title' => $title, ':printing_costs' => $printingCosts, ':selling_price' => $sellingPrice, ':ebook_price' => $ebookPrice, ':isbn_print' => $isbnPrint, ':isbn_ebook' => $isbnEbook, ':id' => $id]); + $bookId = $id; + $pdo->prepare('DELETE FROM book_authors WHERE book_id = :book_id')->execute([':book_id' => $bookId]); + } else { + $statement = $pdo->prepare('INSERT INTO books (title, printing_costs, selling_price, ebook_price, isbn_print, isbn_ebook) VALUES (:title, :printing_costs, :selling_price, :ebook_price, :isbn_print, :isbn_ebook)'); + $statement->execute([':title' => $title, ':printing_costs' => $printingCosts, ':selling_price' => $sellingPrice, ':ebook_price' => $ebookPrice, ':isbn_print' => $isbnPrint, ':isbn_ebook' => $isbnEbook]); + $bookId = (int) $pdo->lastInsertId(); + } + $statement = $pdo->prepare('INSERT INTO book_authors (book_id, author_id, margin_percent) VALUES (:book_id, :author_id, :margin_percent)'); + foreach ($authors as $authorId => $margin) $statement->execute([':book_id' => $bookId, ':author_id' => $authorId, ':margin_percent' => $margin]); + $pdo->commit(); + $message = 'Buch gespeichert.'; + } elseif ($action === 'delete_book') { + $id = filter_var($_POST['book_id'] ?? null, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); + if (!$id) throw new InvalidArgumentException('Ungültiges Buch.'); + $statement = $pdo->prepare('DELETE FROM books WHERE id = :id'); + $statement->execute([':id' => $id]); + $message = $statement->rowCount() ? 'Buch gelöscht.' : 'Buch nicht gefunden.'; + } + } catch (Throwable $exception) { + if ($pdo->inTransaction()) $pdo->rollBack(); + $error = $exception instanceof InvalidArgumentException ? $exception->getMessage() : 'Die Aktion konnte nicht ausgeführt werden.'; + } + } +} + +if (!$loggedIn) { +?> +Admin-Anmeldung +
+ ['min_range' => 1]]) ?: null; +$editBookId = filter_var($_GET['edit_book'] ?? null, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]) ?: null; +$author = ['id' => '', 'name' => '', 'address' => '', 'postal_code' => '', 'city' => '', 'bank_account' => '', 'bic' => '', 'bank_name' => '', 'paypal_address' => '']; +if ($editAuthorId) { $statement = $pdo->prepare('SELECT id, name, address, postal_code, city, bank_account, bic, bank_name, paypal_address FROM authors WHERE id = :id'); $statement->execute([':id' => $editAuthorId]); $author = $statement->fetch() ?: $author; } +$book = ['id' => '', 'title' => '', 'printing_costs' => '0.00', 'selling_price' => '0.00', 'ebook_price' => '0.00', 'isbn_print' => '', 'isbn_ebook' => '']; +$bookAuthors = []; +if ($editBookId) { + $statement = $pdo->prepare('SELECT id, title, printing_costs, selling_price, ebook_price, isbn_print, isbn_ebook FROM books WHERE id = :id'); $statement->execute([':id' => $editBookId]); $book = $statement->fetch() ?: $book; + $statement = $pdo->prepare('SELECT ba.author_id, a.name, ba.margin_percent FROM book_authors ba INNER JOIN authors a ON a.id = ba.author_id WHERE ba.book_id = :id ORDER BY a.name'); $statement->execute([':id' => $editBookId]); $bookAuthors = $statement->fetchAll(); +} +$authors = $pdo->query('SELECT id, name, address, postal_code, city, bank_account, bic, bank_name, paypal_address FROM authors ORDER BY name')->fetchAll(); +$books = $pdo->query('SELECT b.id, b.title, b.printing_costs, b.selling_price, b.ebook_price, b.isbn_print, b.isbn_ebook, GROUP_CONCAT(CONCAT(a.name, " (", ba.margin_percent, " %)" ) ORDER BY a.name SEPARATOR ", ") AS author_summary FROM books b LEFT JOIN book_authors ba ON ba.book_id = b.id LEFT JOIN authors a ON a.id = ba.author_id GROUP BY b.id ORDER BY b.title')->fetchAll(); +$token = csrf(); +?> +Autorenmargen – Admin + +

Autorenmargen – Admin

+

+
+

Abbrechen
+

    Abbrechen
    +
    +

    Autoren

    NameAdresseIBANBICBanknamePayPal-AdresseAktionen
    Bearbeiten
    +

    Bücher

    TitelDruckkostenPrint-PreiseBook-PreisISBN PrintISBN eBookAutoren / MargenAktionen
    € € €Bearbeiten
    +
    diff --git a/author_search.php b/author_search.php new file mode 100644 index 0000000..86a61be --- /dev/null +++ b/author_search.php @@ -0,0 +1,24 @@ + 100) { + header('Content-Type: application/json; charset=utf-8'); + echo '[]'; + exit; +} + +$statement = $pdo->prepare('SELECT id, name FROM authors WHERE name LIKE :query ORDER BY name LIMIT 10'); +$statement->execute([':query' => '%' . $query . '%']); +header('Content-Type: application/json; charset=utf-8'); +echo json_encode($statement->fetchAll(PDO::FETCH_ASSOC), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); diff --git a/billing.php b/billing.php new file mode 100644 index 0000000..191f59b --- /dev/null +++ b/billing.php @@ -0,0 +1,484 @@ + 'wholesale', + 'buchhandel' => 'bookseller', + 'endkunden', 'endkunde', 'direktkunden', 'direktkunde', 'direkt', 'privatkunden', 'privatkunde' => 'direct', + default => null, + }; +} + +function findCsvHeader(array $headers, array $accepted): ?int +{ + foreach ($headers as $index => $header) { + if (in_array(normalizeCsvValue((string) $header), $accepted, true)) { + return $index; + } + } + return null; +} + +function parseCsvUpload(array $upload, PDO $pdo): array +{ + if (($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { + throw new InvalidArgumentException('Die CSV-Datei konnte nicht hochgeladen werden.'); + } + if (!is_string($upload['tmp_name'] ?? null) || !is_uploaded_file($upload['tmp_name'])) { + throw new InvalidArgumentException('Ungültiger Datei-Upload.'); + } + if ((int) ($upload['size'] ?? 0) > CSV_MAX_BYTES) { + throw new InvalidArgumentException('Die CSV-Datei darf maximal 5 MB groß sein.'); + } + + $handle = fopen($upload['tmp_name'], 'rb'); + if ($handle === false) { + throw new InvalidArgumentException('Die CSV-Datei konnte nicht gelesen werden.'); + } + $firstLine = fgets($handle); + if ($firstLine === false) { + fclose($handle); + throw new InvalidArgumentException('Die CSV-Datei ist leer.'); + } + $delimiterCounts = [ + ';' => substr_count($firstLine, ';'), + ',' => substr_count($firstLine, ','), + "\t" => substr_count($firstLine, "\t"), + ]; + $delimiter = array_search(max($delimiterCounts), $delimiterCounts, true); + rewind($handle); + $headers = fgetcsv($handle, 0, $delimiter); + if (!is_array($headers)) { + fclose($handle); + throw new InvalidArgumentException('Die CSV-Kopfzeile konnte nicht gelesen werden.'); + } + + $groupIndex = findCsvHeader($headers, ['kundengruppe', 'kundengruppen', 'gruppe']); + $isbnIndex = findCsvHeader($headers, ['isbn', 'isbnean', 'ean', 'isbnprint']); + $quantityIndex = findCsvHeader($headers, ['menge', 'anzahl', 'verkaufsmenge', 'quantity']); + if ($groupIndex === null || $isbnIndex === null || $quantityIndex === null) { + fclose($handle); + throw new InvalidArgumentException('Benötigte Spalten fehlen. Erwartet werden: Kundengruppe, ISBN und Menge.'); + } + + $books = $pdo->query('SELECT id, title, isbn_print FROM books WHERE isbn_print IS NOT NULL AND isbn_print <> ""')->fetchAll(); + $booksByIsbn = []; + foreach ($books as $book) { + $booksByIsbn[normalizeIsbn((string) $book['isbn_print'])] = $book; + } + + $aggregated = []; + $lineNumber = 1; + while (($row = fgetcsv($handle, 0, $delimiter)) !== false) { + $lineNumber++; + if ($row === [null] || count(array_filter($row, static fn ($value): bool => trim((string) $value) !== '')) === 0) { + continue; + } + $isbn = normalizeIsbn((string) ($row[$isbnIndex] ?? '')); + $group = trim((string) ($row[$groupIndex] ?? '')); + $channel = csvChannel($group); + $quantityText = trim((string) ($row[$quantityIndex] ?? '')); + $quantity = filter_var($quantityText, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0, 'max_range' => 1000000000]]); + if ($isbn === '' || $channel === null || $quantity === false) { + throw new InvalidArgumentException("Fehler in CSV-Zeile {$lineNumber}: ISBN, Kundengruppe oder Menge ist ungültig."); + } + if (!isset($booksByIsbn[$isbn])) { + throw new InvalidArgumentException("Fehler in CSV-Zeile {$lineNumber}: ISBN {$isbn} ist in TAT nicht als Printbuch angelegt."); + } + $book = $booksByIsbn[$isbn]; + $bookId = (int) $book['id']; + $aggregated[$bookId] ??= [ + 'book_id' => $bookId, + 'title' => $book['title'], + 'isbn_print' => $book['isbn_print'], + 'wholesale' => 0, + 'bookseller' => 0, + 'direct' => 0, + 'source_rows' => 0, + ]; + $aggregated[$bookId][$channel] += $quantity; + $aggregated[$bookId]['source_rows']++; + } + fclose($handle); + if (!$aggregated) { + throw new InvalidArgumentException('Die CSV-Datei enthält keine verarbeitbaren Verkaufszeilen.'); + } + return array_values($aggregated); +} + +function parseEbookCsvUpload(array $upload, PDO $pdo): array +{ + if (($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || !is_string($upload['tmp_name'] ?? null) || !is_uploaded_file($upload['tmp_name'])) { + throw new InvalidArgumentException('Die eBook-CSV konnte nicht hochgeladen werden.'); + } + if ((int) ($upload['size'] ?? 0) > CSV_MAX_BYTES) { + throw new InvalidArgumentException('Die eBook-CSV darf maximal 5 MB groß sein.'); + } + $handle = fopen($upload['tmp_name'], 'rb'); + if ($handle === false) { + throw new InvalidArgumentException('Die eBook-CSV konnte nicht gelesen werden.'); + } + $firstLine = fgets($handle); + if ($firstLine === false) { + fclose($handle); + throw new InvalidArgumentException('Die eBook-CSV ist leer.'); + } + $delimiterCounts = [';' => substr_count($firstLine, ';'), ',' => substr_count($firstLine, ','), "\t" => substr_count($firstLine, "\t")]; + $delimiter = array_search(max($delimiterCounts), $delimiterCounts, true); + rewind($handle); + $headers = fgetcsv($handle, 0, $delimiter); + $required = ['verkaufsdatum', 'verkaufsweg', 'isbn', 'titel', 'erlös']; + if (!is_array($headers)) { + fclose($handle); + throw new InvalidArgumentException('Die eBook-CSV-Kopfzeile konnte nicht gelesen werden.'); + } + $indexes = []; + foreach ($required as $header) { + $index = findCsvHeader($headers, [$header]); + if ($index === null) { + fclose($handle); + throw new InvalidArgumentException('Benötigte eBook-Spalte fehlt: ' . $header . '.'); + } + $indexes[$header] = $index; + } + $books = $pdo->query('SELECT id, title, isbn_ebook FROM books WHERE isbn_ebook IS NOT NULL AND isbn_ebook <> ""')->fetchAll(); + $booksByIsbn = []; + foreach ($books as $book) { + $booksByIsbn[normalizeIsbn((string) $book['isbn_ebook'])] = $book; + } + $aggregated = []; + $lineNumber = 1; + while (($row = fgetcsv($handle, 0, $delimiter)) !== false) { + $lineNumber++; + if ($row === [null] || count(array_filter($row, static fn ($value): bool => trim((string) $value) !== '')) === 0) { + continue; + } + $dateText = trim((string) ($row[$indexes['verkaufsdatum']] ?? '')); + $date = DateTimeImmutable::createFromFormat('!Y-m-d', $dateText); + $dateErrors = DateTimeImmutable::getLastErrors(); + if (!$date || ($dateErrors !== false && ($dateErrors['warning_count'] > 0 || $dateErrors['error_count'] > 0)) || $date->format('Y-m-d') !== $dateText) { + throw new InvalidArgumentException("Fehler in eBook-CSV-Zeile {$lineNumber}: Verkaufsdatum muss JJJJ-MM-TT sein."); + } + $channel = trim((string) ($row[$indexes['verkaufsweg']] ?? '')); + $isbn = normalizeIsbn((string) ($row[$indexes['isbn']] ?? '')); + $revenueText = trim((string) ($row[$indexes['erlös']] ?? '')); + $revenueText = str_replace(['€', ' '], '', $revenueText); + if (str_contains($revenueText, ',') && str_contains($revenueText, '.')) { + $revenueText = str_replace('.', '', $revenueText); + } + $revenue = filter_var(str_replace(',', '.', $revenueText), FILTER_VALIDATE_FLOAT); + if ($channel === '' || $isbn === '' || $revenue === false || !is_finite((float) $revenue)) { + throw new InvalidArgumentException("Fehler in eBook-CSV-Zeile {$lineNumber}: Verkaufsweg, ISBN oder Erlös ist ungültig."); + } + if (!isset($booksByIsbn[$isbn])) { + throw new InvalidArgumentException("Fehler in eBook-CSV-Zeile {$lineNumber}: ISBN {$isbn} ist in TAT nicht als eBook angelegt."); + } + $book = $booksByIsbn[$isbn]; + $key = (int) $book['id'] . '|' . $date->format('Y-m-d') . '|' . $channel; + $aggregated[$key] ??= [ + 'book_id' => (int) $book['id'], + 'title' => $book['title'], + 'isbn_ebook' => $book['isbn_ebook'], + 'sale_date' => $date->format('Y-m-d'), + 'sales_channel' => $channel, + 'revenue' => 0.0, + ]; + $aggregated[$key]['revenue'] += (float) $revenue; + } + fclose($handle); + if (!$aggregated) { + throw new InvalidArgumentException('Die eBook-CSV enthält keine verarbeitbaren Verkaufszeilen.'); + } + foreach ($aggregated as &$row) { + $row['revenue'] = round((float) $row['revenue'], 2); + } + unset($row); + return array_values($aggregated); +} + +$message = ''; +$error = ''; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + verifyCsrf(); + try { + $action = (string) ($_POST['action'] ?? ''); + if ($action === 'save_sales') { + $quantities = $_POST['quantity'] ?? []; + if (!is_array($quantities) || count($quantities) > 10000) { + throw new InvalidArgumentException('Ungültige Verkaufsdaten.'); + } + $pdo->beginTransaction(); + $bookCheck = $pdo->prepare('SELECT id FROM books WHERE id = :id AND isbn_print IS NOT NULL AND isbn_print <> ""'); + $statement = $pdo->prepare( + 'INSERT INTO book_sales (book_id, wholesale_quantity, bookseller_quantity, direct_quantity) + VALUES (:book_id, :wholesale, :bookseller, :direct) + ON DUPLICATE KEY UPDATE wholesale_quantity = VALUES(wholesale_quantity), bookseller_quantity = VALUES(bookseller_quantity), direct_quantity = VALUES(direct_quantity)' + ); + foreach ($quantities as $bookId => $values) { + $bookId = filter_var($bookId, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); + if (!$bookId || !is_array($values)) { + throw new InvalidArgumentException('Ungültige Buchdaten.'); + } + $bookCheck->execute([':id' => $bookId]); + if (!$bookCheck->fetchColumn()) { + throw new InvalidArgumentException('Es können nur Printbücher abgerechnet werden.'); + } + $parsed = []; + foreach (['wholesale', 'bookseller', 'direct'] as $channel) { + $value = filter_var($values[$channel] ?? null, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0, 'max_range' => 1000000000]]); + if ($value === false) { + throw new InvalidArgumentException('Verkaufszahlen müssen ganze Zahlen ab 0 sein.'); + } + $parsed[$channel] = $value; + } + $statement->execute([ + ':book_id' => $bookId, + ':wholesale' => $parsed['wholesale'], + ':bookseller' => $parsed['bookseller'], + ':direct' => $parsed['direct'], + ]); + } + $pdo->commit(); + $message = 'Verkaufszahlen gespeichert.'; + } elseif ($action === 'upload_csv') { + $_SESSION[CSV_PREVIEW_SESSION_KEY] = parseCsvUpload($_FILES['sales_csv'] ?? [], $pdo); + $message = 'CSV-Datei geprüft. Bitte die Vorschau kontrollieren und anschließend bestätigen.'; + } elseif ($action === 'upload_ebook_csv') { + $_SESSION[EBOOK_CSV_PREVIEW_SESSION_KEY] = parseEbookCsvUpload($_FILES['ebook_sales_csv'] ?? [], $pdo); + $message = 'eBook-CSV geprüft. Bitte die Vorschau kontrollieren und anschließend bestätigen.'; + } elseif ($action === 'confirm_ebook_csv') { + $preview = $_SESSION[EBOOK_CSV_PREVIEW_SESSION_KEY] ?? null; + if (!is_array($preview) || !$preview) { + throw new InvalidArgumentException('Es ist keine eBook-CSV-Vorschau vorhanden.'); + } + $selected = $_POST['ebook_selected'] ?? []; + if (!is_array($selected) || !$selected) { + throw new InvalidArgumentException('Bitte mindestens eine eBook-Zeile auswählen.'); + } + $statement = $pdo->prepare( + 'INSERT INTO ebook_sales (book_id, sale_date, sales_channel, revenue) + VALUES (:book_id, :sale_date, :sales_channel, :revenue) + ON DUPLICATE KEY UPDATE revenue = VALUES(revenue)' + ); + $pdo->beginTransaction(); + $count = 0; + foreach ($selected as $index) { + $index = filter_var($index, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]); + if ($index === false || !isset($preview[$index])) { + throw new InvalidArgumentException('Ungültige Auswahl in der eBook-CSV-Vorschau.'); + } + $row = $preview[$index]; + $statement->execute([ + ':book_id' => (int) $row['book_id'], + ':sale_date' => $row['sale_date'], + ':sales_channel' => $row['sales_channel'], + ':revenue' => number_format((float) $row['revenue'], 2, '.', ''), + ]); + $count++; + } + $pdo->commit(); + unset($_SESSION[EBOOK_CSV_PREVIEW_SESSION_KEY]); + $message = "eBook-CSV für {$count} ausgewählte Zeilen übernommen."; + } elseif ($action === 'confirm_csv') { + $preview = $_SESSION[CSV_PREVIEW_SESSION_KEY] ?? null; + if (!is_array($preview) || !$preview) { + throw new InvalidArgumentException('Es ist keine CSV-Vorschau vorhanden.'); + } + $selected = $_POST['selected'] ?? []; + if (!is_array($selected) || !$selected) { + throw new InvalidArgumentException('Bitte mindestens ein Buch für den Import auswählen.'); + } + $byId = []; + foreach ($preview as $row) { + $byId[(int) $row['book_id']] = $row; + } + $statement = $pdo->prepare( + 'INSERT INTO book_sales (book_id, wholesale_quantity, bookseller_quantity, direct_quantity) + VALUES (:book_id, :wholesale, :bookseller, :direct) + ON DUPLICATE KEY UPDATE wholesale_quantity = VALUES(wholesale_quantity), bookseller_quantity = VALUES(bookseller_quantity), direct_quantity = VALUES(direct_quantity)' + ); + $pdo->beginTransaction(); + $count = 0; + foreach ($selected as $bookId) { + $bookId = filter_var($bookId, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); + if (!$bookId || !isset($byId[$bookId])) { + throw new InvalidArgumentException('Ungültige Auswahl in der CSV-Vorschau.'); + } + $row = $byId[$bookId]; + $statement->execute([ + ':book_id' => $bookId, + ':wholesale' => (int) $row['wholesale'], + ':bookseller' => (int) $row['bookseller'], + ':direct' => (int) $row['direct'], + ]); + $count++; + } + $pdo->commit(); + unset($_SESSION[CSV_PREVIEW_SESSION_KEY]); + $message = "CSV-Abrechnung für {$count} ausgewählte Bücher übernommen."; + } else { + throw new InvalidArgumentException('Ungültige Aktion.'); + } + } catch (Throwable $exception) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + $error = $exception instanceof InvalidArgumentException + ? $exception->getMessage() + : 'Die CSV-Abrechnung konnte nicht verarbeitet werden.'; + } +} + +$csvPreview = $_SESSION[CSV_PREVIEW_SESSION_KEY] ?? []; +$books = $pdo->query( + 'SELECT b.id, b.title, b.isbn_print, b.isbn_ebook, b.selling_price, b.printing_costs, + COALESCE(s.wholesale_quantity, 0) AS wholesale_quantity, + COALESCE(s.bookseller_quantity, 0) AS bookseller_quantity, + COALESCE(s.direct_quantity, 0) AS direct_quantity + FROM books b + LEFT JOIN book_sales s ON s.book_id = b.id + WHERE b.isbn_print IS NOT NULL AND b.isbn_print <> "" + ORDER BY b.title' +)->fetchAll(); + +$ebookCsvPreview = $_SESSION[EBOOK_CSV_PREVIEW_SESSION_KEY] ?? []; +$ebookSales = $pdo->query( + 'SELECT b.id AS book_id, b.title, b.isbn_ebook, es.sale_date, es.sales_channel, es.revenue + FROM ebook_sales es + INNER JOIN books b ON b.id = es.book_id + ORDER BY es.sale_date, b.title, es.sales_channel' +)->fetchAll(); + +$authorsByBook = []; +$authorStatement = $pdo->query( + 'SELECT ba.book_id, a.name, ba.margin_percent + FROM book_authors ba + INNER JOIN authors a ON a.id = ba.author_id + ORDER BY ba.book_id, a.name' +); +foreach ($authorStatement->fetchAll() as $author) { + $authorsByBook[(int) $author['book_id']][] = $author; +} + +$calculations = []; +$authorSummary = []; +$totalRevenue = 0.0; +$totalAuthorPayout = 0.0; +foreach ($books as $book) { + $netPrice = (float) $book['selling_price'] / (1 + PRINT_VAT_RATE); + $printingCosts = (float) $book['printing_costs']; + $wholesaleRevenue = (($netPrice * WHOLESALE_RETAINED_RATE) - $printingCosts) * (int) $book['wholesale_quantity']; + $booksellerRevenue = (($netPrice * BOOKSELLER_RETAINED_RATE) - $printingCosts) * (int) $book['bookseller_quantity']; + $directRevenue = (($netPrice * DIRECT_RETAINED_RATE) - $printingCosts) * (int) $book['direct_quantity']; + $revenue = $wholesaleRevenue + $booksellerRevenue + $directRevenue; + $totalRevenue += $revenue; + $authorCalculations = []; + foreach ($authorsByBook[(int) $book['id']] ?? [] as $author) { + $authorNet = $revenue * ((float) $author['margin_percent'] / 100); + $authorPayout = $authorNet * (1 + PRINT_VAT_RATE); + $totalAuthorPayout += $authorPayout; + $authorName = (string) $author['name']; + $authorSummary[$authorName] ??= ['books' => [], 'print_payout' => 0.0, 'ebook_payout' => 0.0]; + $authorSummary[$authorName]['books'][(int) $book['id']] ??= ['title' => (string) $book['title'], 'wholesale' => 0.0, 'bookseller' => 0.0, 'direct' => 0.0, 'ebook' => 0.0]; + $authorSummary[$authorName]['books'][(int) $book['id']]['wholesale'] += $wholesaleRevenue * ((float) $author['margin_percent'] / 100) * (1 + PRINT_VAT_RATE); + $authorSummary[$authorName]['books'][(int) $book['id']]['bookseller'] += $booksellerRevenue * ((float) $author['margin_percent'] / 100) * (1 + PRINT_VAT_RATE); + $authorSummary[$authorName]['books'][(int) $book['id']]['direct'] += $directRevenue * ((float) $author['margin_percent'] / 100) * (1 + PRINT_VAT_RATE); + $authorSummary[$authorName]['print_payout'] += $authorPayout; + $authorCalculations[] = ['name' => $authorName, 'margin_percent' => (float) $author['margin_percent'], 'payout' => $authorPayout]; + } + $calculations[(int) $book['id']] = [ + 'wholesale_revenue' => $wholesaleRevenue, + 'bookseller_revenue' => $booksellerRevenue, + 'direct_revenue' => $directRevenue, + 'revenue' => $revenue, + 'authors' => $authorCalculations, + ]; +} + +$ebookCalculations = []; +$ebookTotalRevenue = 0.0; +$ebookTotalAuthorPayout = 0.0; +foreach ($ebookSales as $sale) { + $revenue = (float) $sale['revenue']; + $ebookTotalRevenue += $revenue; + $authorCalculations = []; + foreach ($authorsByBook[(int) $sale['book_id']] ?? [] as $author) { + $payout = $revenue * ((float) $author['margin_percent'] / 100); + $ebookTotalAuthorPayout += $payout; + $authorName = (string) $author['name']; + $authorSummary[$authorName] ??= ['books' => [], 'print_payout' => 0.0, 'ebook_payout' => 0.0]; + $authorSummary[$authorName]['books'][(int) $sale['book_id']] ??= ['title' => (string) $sale['title'], 'wholesale' => 0.0, 'bookseller' => 0.0, 'direct' => 0.0, 'ebook' => 0.0]; + $authorSummary[$authorName]['books'][(int) $sale['book_id']]['ebook'] += $payout; + $authorSummary[$authorName]['ebook_payout'] += $payout; + $authorCalculations[] = [ + 'name' => $authorName, + 'margin_percent' => (float) $author['margin_percent'], + 'payout' => $payout, + ]; + } + $ebookCalculations[] = [ + 'title' => $sale['title'], + 'isbn_ebook' => $sale['isbn_ebook'], + 'sale_date' => $sale['sale_date'], + 'sales_channel' => $sale['sales_channel'], + 'revenue' => $revenue, + 'authors' => $authorCalculations, + ]; +} + +$token = csrf(); +?> + +Abrechnung erstellen – Autorenmargen + +

    Autorenmargen – Admin

    +
    +

    CSV-Abrechnung importieren

    Erwartete Spalten: Kundengruppe, ISBN und Menge. Unterstützt werden Semikolon-, Komma- und Tabulator-getrennte CSV-Dateien. Kundengruppen werden so zugeordnet: Großhandel → Großhandel, Buchhandel → Buchhandel, Endkunden/Direktkunden → Direkt.

    +

    +

    +
    +

    eBook-CSV importieren

    Erwartete Spalten: Verkaufsdatum, Verkaufsweg, ISBN, Titel und Erlös. Der Erlös wird ohne Abzüge mit der Autorenmarge verrechnet; der Verkaufsweg bleibt erhalten. Mehrere Zeilen mit gleichem Datum, eBook und Verkaufsweg werden zusammengefasst.

    +

    eBook-CSV-Vorschau

    Nur ausgewählte Zeilen werden übernommen. Bereits gespeicherte Daten mit identischem eBook, Verkaufsdatum und Verkaufsweg werden ersetzt.

    $row): ?>
    ÜbernehmenVerkaufsdatumVerkaufswegeBookISBN eBookErlös
    +

    CSV-Vorschau

    Die Mengen werden je ISBN und Kundengruppe zusammengefasst. Nur ausgewählte Zeilen werden beim Bestätigen übernommen; die dort gespeicherten Mengen werden ersetzt.

    ÜbernehmenBuchISBN PrintGroßhandelBuchhandelDirektCSV-Zeilen
    +

    Manuelle Verkaufszahlen

    Es werden nur Bücher mit Print-ISBN abgerechnet. eBook-only-Bücher bleiben bis zur separaten eBook-Logik ausgeschlossen.

    +
    + +
    BuchVerkaufspreisDruckkostenISBN PrintGroßhandelBuchhandelDirekt
    Es sind keine abrechenbaren Printbücher vorhanden.
    +

    Auswertung nach Autoren

    Je Autor wird jedes Buch separat nach Großhandel, Buchhandel, Direkt und eBook ausgewiesen.

    $summary): ?>
    AutorBuchGroßhandelBuchhandelDirekteBookGesamt
    Gesamt array_sum(array_column($summary['books'], 'wholesale')), $authorSummary))) ?> array_sum(array_column($summary['books'], 'bookseller')), $authorSummary))) ?> array_sum(array_column($summary['books'], 'direct')), $authorSummary))) ?>
    +

    Berechnung nach Buch

    Die Zwischenwerte werden ungerundet berechnet und erst für die Anzeige auf Cent gerundet.

    BuchErlös GroßhandelErlös BuchhandelErlös DirektGesamterlösAutorenauszahlung
    ( %):
    Kein Autor zugewiesen
    Gesamt
    +

    eBook-Berechnung

    Autorenanteil = Erlös × Autorenmarge / 100. Es werden keine Druck-, Vertriebs- oder sonstigen Kosten abgezogen.

    VerkaufsdatumVerkaufswegeBookISBN eBookErlösAutorenanteil
    ( %):
    Kein Autor zugewiesen
    Gesamt
    +
    + diff --git a/css/admin.css b/css/admin.css new file mode 100644 index 0000000..86ee454 --- /dev/null +++ b/css/admin.css @@ -0,0 +1,36 @@ +* { box-sizing: border-box; } +:root { color-scheme: light; font-family: system-ui, sans-serif; color: #1f2937; background: #f3f4f6; } +body { margin: 0; } +header { display: flex; justify-content: space-between; align-items: center; gap: 1rem; padding: 1rem 5vw; background: #172554; color: #fff; } +header h1 { margin: 0; font-size: 1.35rem; } +header form { margin: 0; } +main { width: min(1200px, 90vw); margin: 2rem auto 4rem; } +.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(330px, 1fr)); gap: 1.25rem; align-items: start; } +.panel { background: #fff; border-radius: .75rem; box-shadow: 0 2px 12px #0f172a18; padding: 1.25rem; } +.panel h2 { margin-top: 0; color: #172554; } +label { display: block; font-weight: 650; margin: .8rem 0 .3rem; } +input, button { font: inherit; } +input:not([type]), input[type=text], input[type=email], input[type=password], input[type=number] { width: 100%; padding: .65rem .7rem; border: 1px solid #cbd5e1; border-radius: .4rem; } +button, .button { border: 0; border-radius: .4rem; padding: .65rem 1rem; background: #1d4ed8; color: #fff; cursor: pointer; text-decoration: none; display: inline-block; } +button.secondary, .button.secondary { background: #475569; } +button.danger { background: #b91c1c; } +.actions { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: 1rem; } +.notice { padding: .8rem 1rem; border-radius: .4rem; margin-bottom: 1rem; background: #dcfce7; color: #166534; } +.error { background: #fee2e2; color: #991b1b; } +table { width: 100%; border-collapse: collapse; margin-top: 1rem; background: #fff; } +th, td { padding: .7rem; border-bottom: 1px solid #e2e8f0; text-align: left; vertical-align: top; } +th { background: #e0e7ff; color: #172554; } +.table-wrap { overflow-x: auto; } +.author-row { display: grid; grid-template-columns: 1fr 9rem auto; gap: .5rem; align-items: end; margin: .5rem 0; } +.autocomplete { position: relative; } +.suggestions { position: absolute; z-index: 2; left: 0; right: 0; margin: 0; padding: 0; list-style: none; background: #fff; border: 1px solid #cbd5e1; box-shadow: 0 4px 10px #0f172a22; } +.suggestions button { display: block; width: 100%; border-radius: 0; background: #fff; color: #1f2937; text-align: left; } +.suggestions button:hover { background: #e0e7ff; } +.login-panel { max-width: 420px; margin: 8rem auto; } +.inline-form { display: inline; } +nav { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; } +nav a { color: #fff; } +.billing-table input[type=number] { min-width: 7rem; } +.muted { color: #64748b; } +.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } +@media (max-width: 600px) { .author-row { grid-template-columns: 1fr; } header { align-items: flex-start; flex-direction: column; } } diff --git a/css/index.html b/css/index.html new file mode 100644 index 0000000..3ff3135 --- /dev/null +++ b/css/index.html @@ -0,0 +1,2 @@ + +Not found diff --git a/inc/.htaccess b/inc/.htaccess new file mode 100644 index 0000000..b66e808 --- /dev/null +++ b/inc/.htaccess @@ -0,0 +1 @@ +Require all denied diff --git a/inc/admin_bootstrap.php b/inc/admin_bootstrap.php new file mode 100644 index 0000000..fd37a69 --- /dev/null +++ b/inc/admin_bootstrap.php @@ -0,0 +1,83 @@ + true, + 'samesite' => 'Strict', + 'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', +]); +ini_set('session.use_strict_mode', '1'); +session_start(); + +header('Cache-Control: no-store'); +header('X-Content-Type-Options: nosniff'); +header('X-Frame-Options: DENY'); +header('Referrer-Policy: no-referrer'); +header('Permissions-Policy: camera=(), microphone=(), geolocation=()'); +header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'"); + +function db(): PDO +{ + global $dbConfig; + $dsn = sprintf('mysql:host=%s;port=%d;dbname=%s;charset=%s', $dbConfig['host'], $dbConfig['port'], $dbConfig['database'], $dbConfig['charset']); + return new PDO($dsn, $dbConfig['username'], $dbConfig['password'], [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_EMULATE_PREPARES => false, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); +} + +$pdo = db(); + +function requireAdmin(): void +{ + global $pdo; + if (!isset($_SESSION['admin_id'])) { + http_response_code(403); + exit('Zugriff verweigert.'); + } + $statement = $pdo->prepare('SELECT id FROM admin_users WHERE id = :id AND is_active = 1 LIMIT 1'); + $statement->execute([':id' => (int) $_SESSION['admin_id']]); + if (!$statement->fetchColumn()) { + $_SESSION = []; + session_destroy(); + http_response_code(403); + exit('Zugriff verweigert.'); + } +} + +function e(mixed $value): string +{ + return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +} + +function csrf(): string +{ + return $_SESSION['admin_csrf'] ??= bin2hex(random_bytes(32)); +} + +function verifyCsrf(): void +{ + $token = $_POST['csrf_token'] ?? ''; + if (!is_string($token) || !hash_equals(csrf(), $token)) { + http_response_code(403); + exit('Ungültige Anfrage.'); + } +} + +function postText(string $key, int $maxLength): string +{ + $value = $_POST[$key] ?? ''; + $value = is_string($value) ? trim($value) : ''; + if (mb_strlen($value) > $maxLength) throw new InvalidArgumentException('Eingabe zu lang.'); + return $value; +} + +function redirectAdmin(): never +{ + header('Location: admin.php'); + exit; +} diff --git a/inc/db.inc.php.example b/inc/db.inc.php.example new file mode 100644 index 0000000..73cb0b4 --- /dev/null +++ b/inc/db.inc.php.example @@ -0,0 +1,12 @@ + 'localhost', + 'port' => 3306, + 'database' => 'tat', + 'username' => 'tat', + 'password' => 'CHANGE_ME', + 'charset' => 'utf8mb4', +]; diff --git a/inc/index.html b/inc/index.html new file mode 100644 index 0000000..3ff3135 --- /dev/null +++ b/inc/index.html @@ -0,0 +1,2 @@ + +Not found diff --git a/index.html b/index.html new file mode 100644 index 0000000..3ff3135 --- /dev/null +++ b/index.html @@ -0,0 +1,2 @@ + +Not found diff --git a/migrations/001_add_ebook_price.sql b/migrations/001_add_ebook_price.sql new file mode 100644 index 0000000..6badfb9 --- /dev/null +++ b/migrations/001_add_ebook_price.sql @@ -0,0 +1,3 @@ +ALTER TABLE books + ADD COLUMN IF NOT EXISTS ebook_price DECIMAL(10,2) NOT NULL DEFAULT 0.00 + AFTER selling_price; diff --git a/migrations/002_add_author_payment_fields.sql b/migrations/002_add_author_payment_fields.sql new file mode 100644 index 0000000..4e0bd77 --- /dev/null +++ b/migrations/002_add_author_payment_fields.sql @@ -0,0 +1,4 @@ +ALTER TABLE authors + ADD COLUMN IF NOT EXISTS bic VARCHAR(20) NOT NULL DEFAULT '' AFTER bank_account, + ADD COLUMN IF NOT EXISTS bank_name VARCHAR(255) NOT NULL DEFAULT '' AFTER bic, + ADD COLUMN IF NOT EXISTS paypal_address VARCHAR(255) NOT NULL DEFAULT '' AFTER bank_name; diff --git a/scripts/admin.js b/scripts/admin.js new file mode 100644 index 0000000..d1b1a1b --- /dev/null +++ b/scripts/admin.js @@ -0,0 +1,56 @@ +(() => { + const root = document.querySelector('[data-admin-app]'); + if (!root) return; + + const csrf = root.dataset.csrf; + const rows = root.querySelector('[data-author-rows]'); + const template = root.querySelector('[data-author-template]'); + const searchUrl = root.dataset.authorSearch; + + function addAuthorRow(id = '', name = '', margin = '') { + const row = template.content.firstElementChild.cloneNode(true); + row.querySelector('[data-author-id]').value = id; + row.querySelector('[data-author-name]').value = name; + row.querySelector('[data-margin]').value = margin; + rows.appendChild(row); + bindSearch(row); + } + + function bindSearch(row) { + const input = row.querySelector('[data-author-name]'); + const id = row.querySelector('[data-author-id]'); + const list = row.querySelector('[data-suggestions]'); + let timer; + input.addEventListener('input', () => { + id.value = ''; + clearTimeout(timer); + const term = input.value.trim(); + if (term.length < 2) { list.replaceChildren(); return; } + timer = setTimeout(async () => { + const response = await fetch(`${searchUrl}?q=${encodeURIComponent(term)}`, { + credentials: 'same-origin', + headers: { 'X-CSRF-Token': csrf } + }); + if (!response.ok) return; + const authors = await response.json(); + list.replaceChildren(...authors.map(author => { + const item = document.createElement('li'); + const button = document.createElement('button'); + button.type = 'button'; button.textContent = author.name; + button.addEventListener('click', () => { input.value = author.name; id.value = author.id; list.replaceChildren(); }); + item.appendChild(button); return item; + })); + }, 180); + }); + } + + root.querySelector('[data-add-author]').addEventListener('click', () => addAuthorRow()); + root.addEventListener('click', event => { + if (event.target.matches('[data-remove-author]')) event.target.closest('[data-author-row]').remove(); + }); + document.addEventListener('submit', event => { + const message = event.target.dataset.confirm; + if (message && !window.confirm(message)) event.preventDefault(); + }); + root.querySelectorAll('[data-author-row]').forEach(bindSearch); +})(); diff --git a/scripts/index.html b/scripts/index.html new file mode 100644 index 0000000..3ff3135 --- /dev/null +++ b/scripts/index.html @@ -0,0 +1,2 @@ + +Not found diff --git a/tat_schema.sql b/tat_schema.sql new file mode 100644 index 0000000..18c8eef --- /dev/null +++ b/tat_schema.sql @@ -0,0 +1,75 @@ +CREATE TABLE IF NOT EXISTS admin_users ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + username VARCHAR(80) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_admin_username (username) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS authors ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(255) NOT NULL, + address VARCHAR(255) NOT NULL DEFAULT '', + postal_code VARCHAR(20) NOT NULL DEFAULT '', + city VARCHAR(120) NOT NULL DEFAULT '', + bank_account VARCHAR(255) NOT NULL DEFAULT '', + bic VARCHAR(20) NOT NULL DEFAULT '', + bank_name VARCHAR(255) NOT NULL DEFAULT '', + paypal_address VARCHAR(255) NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_authors_name (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS books ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + title VARCHAR(255) NOT NULL, + printing_costs DECIMAL(10,2) NOT NULL DEFAULT 0.00, + selling_price DECIMAL(10,2) NOT NULL DEFAULT 0.00, + ebook_price DECIMAL(10,2) NOT NULL DEFAULT 0.00, + isbn_print VARCHAR(20) NULL, + isbn_ebook VARCHAR(20) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_books_title (title), + UNIQUE KEY uq_books_isbn_print (isbn_print), + UNIQUE KEY uq_books_isbn_ebook (isbn_ebook) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS book_authors ( + book_id INT UNSIGNED NOT NULL, + author_id INT UNSIGNED NOT NULL, + margin_percent DECIMAL(5,2) NOT NULL, + PRIMARY KEY (book_id, author_id), + CONSTRAINT fk_book_authors_book FOREIGN KEY (book_id) REFERENCES books (id) ON DELETE CASCADE, + CONSTRAINT fk_book_authors_author FOREIGN KEY (author_id) REFERENCES authors (id) ON DELETE RESTRICT, + CONSTRAINT chk_book_authors_margin CHECK (margin_percent >= 0 AND margin_percent <= 100) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS book_sales ( + book_id INT UNSIGNED NOT NULL, + wholesale_quantity INT UNSIGNED NOT NULL DEFAULT 0, + bookseller_quantity INT UNSIGNED NOT NULL DEFAULT 0, + direct_quantity INT UNSIGNED NOT NULL DEFAULT 0, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (book_id), + CONSTRAINT fk_book_sales_book FOREIGN KEY (book_id) REFERENCES books (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS ebook_sales ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + book_id INT UNSIGNED NOT NULL, + sale_date DATE NOT NULL, + sales_channel VARCHAR(255) NOT NULL, + revenue DECIMAL(12,2) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_ebook_sales_book_date_channel (book_id, sale_date, sales_channel), + KEY idx_ebook_sales_date (sale_date), + CONSTRAINT fk_ebook_sales_book FOREIGN KEY (book_id) REFERENCES books (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;