Import current PCAT application
This commit is contained in:
+11
@@ -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/
|
||||
@@ -0,0 +1,5 @@
|
||||
Options -Indexes
|
||||
|
||||
<FilesMatch "\.(sql|log|ini|env|bak|backup|dist|swp)$">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
@@ -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.
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/inc/admin_bootstrap.php';
|
||||
|
||||
$message = '';
|
||||
$error = '';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'login') {
|
||||
$username = postText('username', 80);
|
||||
$password = is_string($_POST['password'] ?? null) ? $_POST['password'] : '';
|
||||
$lockedUntil = (int) ($_SESSION['admin_locked_until'] ?? 0);
|
||||
if ($lockedUntil > 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) {
|
||||
?><!doctype html>
|
||||
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Admin-Anmeldung</title><link rel="stylesheet" href="css/admin.css"></head>
|
||||
<body><main><section class="panel login-panel"><h1>Autorenmargen – Admin</h1><?php if ($error): ?><p class="notice error"><?= e($error) ?></p><?php endif; ?><form method="post"><input type="hidden" name="action" value="login"><label for="username">Benutzername</label><input id="username" name="username" maxlength="80" required autofocus><label for="password">Passwort</label><input id="password" type="password" name="password" required><div class="actions"><button type="submit">Anmelden</button></div></form></section></main></body></html>
|
||||
<?php exit; }
|
||||
|
||||
$editAuthorId = filter_var($_GET['edit_author'] ?? null, FILTER_VALIDATE_INT, ['options' => ['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();
|
||||
?><!doctype html>
|
||||
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Autorenmargen – Admin</title><link rel="stylesheet" href="css/admin.css"></head>
|
||||
<body data-admin-app data-csrf="<?= e($token) ?>" data-author-search="author_search.php">
|
||||
<header><h1>Autorenmargen – Admin</h1><nav><a class="button secondary" href="billing.php">Abrechnung erstellen</a><form method="post" class="inline-form"><input type="hidden" name="action" value="logout"><input type="hidden" name="csrf_token" value="<?= e($token) ?>"><button class="secondary" type="submit">Abmelden</button></form></nav></header>
|
||||
<main><?php if ($message): ?><p class="notice"><?= e($message) ?></p><?php endif; ?><?php if ($error): ?><p class="notice error"><?= e($error) ?></p><?php endif; ?>
|
||||
<div class="grid">
|
||||
<section class="panel"><h2><?= $author['id'] ? 'Autor bearbeiten' : 'Autor anlegen' ?></h2><form method="post"><input type="hidden" name="action" value="save_author"><input type="hidden" name="csrf_token" value="<?= e($token) ?>"><input type="hidden" name="author_id" value="<?= e($author['id']) ?>"><label for="author-name">Name</label><input id="author-name" name="name" maxlength="255" value="<?= e($author['name']) ?>" required><label for="address">Adresse</label><input id="address" name="address" maxlength="255" value="<?= e($author['address']) ?>"><label for="postal-code">PLZ</label><input id="postal-code" name="postal_code" maxlength="20" value="<?= e($author['postal_code']) ?>"><label for="city">Ort</label><input id="city" name="city" maxlength="120" value="<?= e($author['city']) ?>"><label for="bank-account">IBAN</label><input id="bank-account" name="bank_account" maxlength="255" value="<?= e($author['bank_account']) ?>"><label for="bic">BIC</label><input id="bic" name="bic" maxlength="20" value="<?= e($author['bic']) ?>"><label for="bank-name">Bankname</label><input id="bank-name" name="bank_name" maxlength="255" value="<?= e($author['bank_name']) ?>"><label for="paypal-address">PayPal-Adresse</label><input id="paypal-address" name="paypal_address" maxlength="255" value="<?= e($author['paypal_address']) ?>"><div class="actions"><button type="submit">Speichern</button><?php if ($author['id']): ?><a class="button secondary" href="admin.php">Abbrechen</a><?php endif; ?></div></form></section>
|
||||
<section class="panel"><h2><?= $book['id'] ? 'Buch bearbeiten' : 'Buch anlegen' ?></h2><form method="post"><input type="hidden" name="action" value="save_book"><input type="hidden" name="csrf_token" value="<?= e($token) ?>"><input type="hidden" name="book_id" value="<?= e($book['id']) ?>"><label for="book-title">Titel</label><input id="book-title" name="title" maxlength="255" value="<?= e($book['title']) ?>" required><label for="printing-costs">Druckkosten (€)</label><input id="printing-costs" type="number" name="printing_costs" min="0" max="99999999.99" step="0.01" value="<?= e($book['printing_costs']) ?>" required><label for="selling-price">Print-Preis (€)</label><input id="selling-price" type="number" name="selling_price" min="0" max="99999999.99" step="0.01" value="<?= e($book['selling_price']) ?>" required><label for="ebook-price">eBook-Preis (€)</label><input id="ebook-price" type="number" name="ebook_price" min="0" max="99999999.99" step="0.01" value="<?= e($book['ebook_price']) ?>" required><label for="isbn-print">ISBN Print</label><input id="isbn-print" name="isbn_print" maxlength="20" value="<?= e($book['isbn_print']) ?>"><label for="isbn-ebook">ISBN eBook</label><input id="isbn-ebook" name="isbn_ebook" maxlength="20" value="<?= e($book['isbn_ebook']) ?>"><label>Autoren und individuelle Margen</label><div data-author-rows><?php foreach ($bookAuthors as $bookAuthor): ?><div class="author-row" data-author-row><div class="autocomplete"><input type="hidden" name="author_id[]" data-author-id value="<?= (int) $bookAuthor['author_id'] ?>"><input name="author_name[]" data-author-name value="<?= e($bookAuthor['name']) ?>" autocomplete="off" required><ul class="suggestions" data-suggestions></ul></div><div><label>Marge %</label><input type="number" name="margin_percent[]" data-margin min="0" max="100" step="0.01" value="<?= e($bookAuthor['margin_percent']) ?>" required></div><button type="button" class="danger" data-remove-author>Entfernen</button></div><?php endforeach; ?></div><template data-author-template><div class="author-row" data-author-row><div class="autocomplete"><input type="hidden" name="author_id[]" data-author-id><input name="author_name[]" data-author-name autocomplete="off" required><ul class="suggestions" data-suggestions></ul></div><div><label>Marge %</label><input type="number" name="margin_percent[]" data-margin min="0" max="100" step="0.01" required></div><button type="button" class="danger" data-remove-author>Entfernen</button></div></template><div class="actions"><button type="button" class="secondary" data-add-author>Autor hinzufügen</button><button type="submit">Buch speichern</button><?php if ($book['id']): ?><a class="button secondary" href="admin.php">Abbrechen</a><?php endif; ?></div></form></section>
|
||||
</div>
|
||||
<section class="panel"><h2>Autoren</h2><div class="table-wrap"><table><thead><tr><th>Name</th><th>Adresse</th><th>IBAN</th><th>BIC</th><th>Bankname</th><th>PayPal-Adresse</th><th>Aktionen</th></tr></thead><tbody><?php foreach ($authors as $row): ?><tr><td><?= e($row['name']) ?></td><td><?= e(trim($row['address'] . ', ' . $row['postal_code'] . ' ' . $row['city'], ', ')) ?></td><td><?= e($row['bank_account']) ?></td><td><?= e($row['bic']) ?></td><td><?= e($row['bank_name']) ?></td><td><?= e($row['paypal_address']) ?></td><td><a class="button secondary" href="?edit_author=<?= (int) $row['id'] ?>">Bearbeiten</a> <form method="post" class="inline-form" data-confirm="Autor wirklich löschen?"><input type="hidden" name="action" value="delete_author"><input type="hidden" name="csrf_token" value="<?= e($token) ?>"><input type="hidden" name="author_id" value="<?= (int) $row['id'] ?>"><button class="danger" type="submit">Löschen</button></form></td></tr><?php endforeach; ?></tbody></table></div></section>
|
||||
<section class="panel"><h2>Bücher</h2><div class="table-wrap"><table><thead><tr><th>Titel</th><th>Druckkosten</th><th>Print-Preis</th><th>eBook-Preis</th><th>ISBN Print</th><th>ISBN eBook</th><th>Autoren / Margen</th><th>Aktionen</th></tr></thead><tbody><?php foreach ($books as $row): ?><tr><td><?= e($row['title']) ?></td><td><?= number_format((float) $row['printing_costs'], 2, ',', '.') ?> €</td><td><?= number_format((float) $row['selling_price'], 2, ',', '.') ?> €</td><td><?= number_format((float) $row['ebook_price'], 2, ',', '.') ?> €</td><td><?= e($row['isbn_print']) ?></td><td><?= e($row['isbn_ebook']) ?></td><td><?= e($row['author_summary'] ?? '') ?></td><td><a class="button secondary" href="?edit_book=<?= (int) $row['id'] ?>">Bearbeiten</a> <form method="post" class="inline-form" data-confirm="Buch wirklich löschen?"><input type="hidden" name="action" value="delete_book"><input type="hidden" name="csrf_token" value="<?= e($token) ?>"><input type="hidden" name="book_id" value="<?= (int) $row['id'] ?>"><button class="danger" type="submit">Löschen</button></form></td></tr><?php endforeach; ?></tbody></table></div></section>
|
||||
</main><script src="scripts/admin.js"></script></body></html>
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/inc/admin_bootstrap.php';
|
||||
requireAdmin();
|
||||
|
||||
$token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!is_string($token) || !hash_equals(csrf(), $token)) {
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
|
||||
$query = trim((string) ($_GET['q'] ?? ''));
|
||||
if (mb_strlen($query) < 2 || mb_strlen($query) > 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);
|
||||
+484
@@ -0,0 +1,484 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/inc/admin_bootstrap.php';
|
||||
requireAdmin();
|
||||
|
||||
const PRINT_VAT_RATE = 0.07;
|
||||
const WHOLESALE_RETAINED_RATE = 0.50;
|
||||
const BOOKSELLER_RETAINED_RATE = 0.70;
|
||||
const DIRECT_RETAINED_RATE = 1.00;
|
||||
const CSV_MAX_BYTES = 5242880;
|
||||
const CSV_PREVIEW_SESSION_KEY = 'tat_billing_csv_preview';
|
||||
const EBOOK_CSV_PREVIEW_SESSION_KEY = 'tat_ebook_csv_preview';
|
||||
|
||||
function money(float $value): string
|
||||
{
|
||||
return number_format($value, 2, ',', '.') . ' €';
|
||||
}
|
||||
|
||||
function normalizeCsvValue(string $value): string
|
||||
{
|
||||
$value = preg_replace('/^\\xEF\\xBB\\xBF/', '', $value) ?? $value;
|
||||
$value = trim(mb_strtolower($value, 'UTF-8'));
|
||||
return preg_replace('/[^a-z0-9äöüß]+/u', '', $value) ?? $value;
|
||||
}
|
||||
|
||||
function normalizeIsbn(string $value): string
|
||||
{
|
||||
return preg_replace('/[^0-9xX]/', '', trim($value)) ?? '';
|
||||
}
|
||||
|
||||
function csvChannel(string $customerGroup): ?string
|
||||
{
|
||||
$group = normalizeCsvValue($customerGroup);
|
||||
return match ($group) {
|
||||
'großhandel', 'grosshandel', 'buchgrosshandel' => '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();
|
||||
?><!doctype html>
|
||||
<html lang="de">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Abrechnung erstellen – Autorenmargen</title><link rel="stylesheet" href="css/admin.css"></head>
|
||||
<body>
|
||||
<header><h1>Autorenmargen – Admin</h1><nav><a class="button secondary" href="admin.php">Stammdaten</a><form method="post" action="admin.php" class="inline-form"><input type="hidden" name="action" value="logout"><input type="hidden" name="csrf_token" value="<?= e($token) ?>"><button class="secondary" type="submit">Abmelden</button></form></nav></header>
|
||||
<main>
|
||||
<section class="panel"><h2>CSV-Abrechnung importieren</h2><p class="muted">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.</p>
|
||||
<?php if ($message): ?><p class="notice"><?= e($message) ?></p><?php endif; ?>
|
||||
<?php if ($error): ?><p class="notice error"><?= e($error) ?></p><?php endif; ?>
|
||||
<form method="post" enctype="multipart/form-data"><input type="hidden" name="action" value="upload_csv"><input type="hidden" name="csrf_token" value="<?= e($token) ?>"><label for="sales-csv">JTL-Verkaufs-CSV</label><input id="sales-csv" type="file" name="sales_csv" accept=".csv,text/csv" required><div class="actions"><button type="submit">CSV prüfen und Vorschau anzeigen</button></div></form></section>
|
||||
<section class="panel"><h2>eBook-CSV importieren</h2><p class="muted">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.</p><form method="post" enctype="multipart/form-data"><input type="hidden" name="action" value="upload_ebook_csv"><input type="hidden" name="csrf_token" value="<?= e($token) ?>"><label for="ebook-sales-csv">Distributor-eBook-CSV</label><input id="ebook-sales-csv" type="file" name="ebook_sales_csv" accept=".csv,text/csv" required><div class="actions"><button type="submit">eBook-CSV prüfen und Vorschau anzeigen</button></div></form></section>
|
||||
<?php if ($ebookCsvPreview): ?><section class="panel"><h2>eBook-CSV-Vorschau</h2><p class="muted">Nur ausgewählte Zeilen werden übernommen. Bereits gespeicherte Daten mit identischem eBook, Verkaufsdatum und Verkaufsweg werden ersetzt.</p><form method="post"><input type="hidden" name="action" value="confirm_ebook_csv"><input type="hidden" name="csrf_token" value="<?= e($token) ?>"><div class="table-wrap"><table class="billing-table"><thead><tr><th>Übernehmen</th><th>Verkaufsdatum</th><th>Verkaufsweg</th><th>eBook</th><th>ISBN eBook</th><th>Erlös</th></tr></thead><tbody><?php foreach ($ebookCsvPreview as $index => $row): ?><tr><td><input type="checkbox" name="ebook_selected[]" value="<?= (int) $index ?>" checked></td><td><?= e($row['sale_date']) ?></td><td><?= e($row['sales_channel']) ?></td><td><?= e($row['title']) ?></td><td><?= e($row['isbn_ebook']) ?></td><td><?= money((float) $row['revenue']) ?></td></tr><?php endforeach; ?></tbody></table></div><div class="actions"><button type="submit">Ausgewählte eBook-Daten übernehmen</button></div></form></section><?php endif; ?>
|
||||
<?php if ($csvPreview): ?><section class="panel"><h2>CSV-Vorschau</h2><p class="muted">Die Mengen werden je ISBN und Kundengruppe zusammengefasst. Nur ausgewählte Zeilen werden beim Bestätigen übernommen; die dort gespeicherten Mengen werden ersetzt.</p><form method="post"><input type="hidden" name="action" value="confirm_csv"><input type="hidden" name="csrf_token" value="<?= e($token) ?>"><div class="table-wrap"><table class="billing-table"><thead><tr><th>Übernehmen</th><th>Buch</th><th>ISBN Print</th><th>Großhandel</th><th>Buchhandel</th><th>Direkt</th><th>CSV-Zeilen</th></tr></thead><tbody><?php foreach ($csvPreview as $row): ?><tr><td><input type="checkbox" name="selected[]" value="<?= (int) $row['book_id'] ?>" checked></td><td><?= e($row['title']) ?></td><td><?= e($row['isbn_print']) ?></td><td><?= (int) $row['wholesale'] ?></td><td><?= (int) $row['bookseller'] ?></td><td><?= (int) $row['direct'] ?></td><td><?= (int) $row['source_rows'] ?></td></tr><?php endforeach; ?></tbody></table></div><div class="actions"><button type="submit">Ausgewählte CSV-Daten übernehmen</button></div></form></section><?php endif; ?>
|
||||
<section class="panel"><h2>Manuelle Verkaufszahlen</h2><p class="muted">Es werden nur Bücher mit Print-ISBN abgerechnet. eBook-only-Bücher bleiben bis zur separaten eBook-Logik ausgeschlossen.</p>
|
||||
<form method="post"><input type="hidden" name="action" value="save_sales"><input type="hidden" name="csrf_token" value="<?= e($token) ?>"><div class="table-wrap"><table class="billing-table"><thead><tr><th>Buch</th><th>Verkaufspreis</th><th>Druckkosten</th><th>ISBN Print</th><th>Großhandel</th><th>Buchhandel</th><th>Direkt</th></tr></thead><tbody>
|
||||
<?php foreach ($books as $book): ?><tr><td><strong><?= e($book['title']) ?></strong></td><td><?= money((float) $book['selling_price']) ?></td><td><?= money((float) $book['printing_costs']) ?></td><td><?= e($book['isbn_print']) ?></td><td><label class="visually-hidden" for="wholesale-<?= (int) $book['id'] ?>">Großhandel für <?= e($book['title']) ?></label><input id="wholesale-<?= (int) $book['id'] ?>" type="number" name="quantity[<?= (int) $book['id'] ?>][wholesale]" min="0" max="1000000000" step="1" value="<?= (int) $book['wholesale_quantity'] ?>" required></td><td><label class="visually-hidden" for="bookseller-<?= (int) $book['id'] ?>">Buchhandel für <?= e($book['title']) ?></label><input id="bookseller-<?= (int) $book['id'] ?>" type="number" name="quantity[<?= (int) $book['id'] ?>][bookseller]" min="0" max="1000000000" step="1" value="<?= (int) $book['bookseller_quantity'] ?>" required></td><td><label class="visually-hidden" for="direct-<?= (int) $book['id'] ?>">Direkt für <?= e($book['title']) ?></label><input id="direct-<?= (int) $book['id'] ?>" type="number" name="quantity[<?= (int) $book['id'] ?>][direct]" min="0" max="1000000000" step="1" value="<?= (int) $book['direct_quantity'] ?>" required></td></tr><?php endforeach; ?>
|
||||
<?php if (!$books): ?><tr><td colspan="7">Es sind keine abrechenbaren Printbücher vorhanden.</td></tr><?php endif; ?></tbody></table></div><div class="actions"><button type="submit">Verkaufszahlen speichern und abrechnen</button></div></form></section>
|
||||
<?php if ($authorSummary): ?><?php ksort($authorSummary, SORT_NATURAL | SORT_FLAG_CASE); ?><section class="panel"><h2>Auswertung nach Autoren</h2><p class="muted">Je Autor wird jedes Buch separat nach Großhandel, Buchhandel, Direkt und eBook ausgewiesen.</p><div class="table-wrap"><table class="billing-table"><thead><tr><th>Autor</th><th>Buch</th><th>Großhandel</th><th>Buchhandel</th><th>Direkt</th><th>eBook</th><th>Gesamt</th></tr></thead><tbody><?php foreach ($authorSummary as $authorName => $summary): ?><tr><th colspan="2"><?= e($authorName) ?></th><th><?= money(array_sum(array_column($summary['books'], 'wholesale'))) ?></th><th><?= money(array_sum(array_column($summary['books'], 'bookseller'))) ?></th><th><?= money(array_sum(array_column($summary['books'], 'direct'))) ?></th><th><?= money($summary['ebook_payout']) ?></th><th><?= money($summary['print_payout'] + $summary['ebook_payout']) ?></th></tr><?php foreach ($summary['books'] as $bookSummary): ?><tr><td></td><td><?= e($bookSummary['title']) ?></td><td><?= money($bookSummary['wholesale']) ?></td><td><?= money($bookSummary['bookseller']) ?></td><td><?= money($bookSummary['direct']) ?></td><td><?= money($bookSummary['ebook']) ?></td><td><?= money($bookSummary['wholesale'] + $bookSummary['bookseller'] + $bookSummary['direct'] + $bookSummary['ebook']) ?></td></tr><?php endforeach; ?><?php endforeach; ?></tbody><tfoot><tr><th colspan="2">Gesamt</th><th><?= money(array_sum(array_map(static fn (array $summary): float => array_sum(array_column($summary['books'], 'wholesale')), $authorSummary))) ?></th><th><?= money(array_sum(array_map(static fn (array $summary): float => array_sum(array_column($summary['books'], 'bookseller')), $authorSummary))) ?></th><th><?= money(array_sum(array_map(static fn (array $summary): float => array_sum(array_column($summary['books'], 'direct')), $authorSummary))) ?></th><th><?= money($ebookTotalAuthorPayout) ?></th><th><?= money($totalAuthorPayout + $ebookTotalAuthorPayout) ?></th></tr></tfoot></table></div></section><?php endif; ?>
|
||||
<?php if ($books): ?><section class="panel"><h2>Berechnung nach Buch</h2><p class="muted">Die Zwischenwerte werden ungerundet berechnet und erst für die Anzeige auf Cent gerundet.</p><div class="table-wrap"><table class="billing-table"><thead><tr><th>Buch</th><th>Erlös Großhandel</th><th>Erlös Buchhandel</th><th>Erlös Direkt</th><th>Gesamterlös</th><th>Autorenauszahlung</th></tr></thead><tbody><?php foreach ($books as $book): $calculation = $calculations[(int) $book['id']]; ?><tr><td><strong><?= e($book['title']) ?></strong></td><td><?= money($calculation['wholesale_revenue']) ?></td><td><?= money($calculation['bookseller_revenue']) ?></td><td><?= money($calculation['direct_revenue']) ?></td><td><?= money($calculation['revenue']) ?></td><td><?php if ($calculation['authors']): ?><?php foreach ($calculation['authors'] as $author): ?><div><?= e($author['name']) ?> (<?= number_format($author['margin_percent'], 2, ',', '.') ?> %): <?= money($author['payout']) ?></div><?php endforeach; ?><?php else: ?><span class="muted">Kein Autor zugewiesen</span><?php endif; ?></td></tr><?php endforeach; ?></tbody><tfoot><tr><th colspan="4">Gesamt</th><th><?= money($totalRevenue) ?></th><th><?= money($totalAuthorPayout) ?></th></tr></tfoot></table></div></section><?php endif; ?>
|
||||
<?php if ($ebookCalculations): ?><section class="panel"><h2>eBook-Berechnung</h2><p class="muted">Autorenanteil = Erlös × Autorenmarge / 100. Es werden keine Druck-, Vertriebs- oder sonstigen Kosten abgezogen.</p><div class="table-wrap"><table class="billing-table"><thead><tr><th>Verkaufsdatum</th><th>Verkaufsweg</th><th>eBook</th><th>ISBN eBook</th><th>Erlös</th><th>Autorenanteil</th></tr></thead><tbody><?php foreach ($ebookCalculations as $calculation): ?><tr><td><?= e($calculation['sale_date']) ?></td><td><?= e($calculation['sales_channel']) ?></td><td><strong><?= e($calculation['title']) ?></strong></td><td><?= e($calculation['isbn_ebook']) ?></td><td><?= money($calculation['revenue']) ?></td><td><?php if ($calculation['authors']): ?><?php foreach ($calculation['authors'] as $author): ?><div><?= e($author['name']) ?> (<?= number_format($author['margin_percent'], 2, ',', '.') ?> %): <?= money($author['payout']) ?></div><?php endforeach; ?><?php else: ?><span class="muted">Kein Autor zugewiesen</span><?php endif; ?></td></tr><?php endforeach; ?></tbody><tfoot><tr><th colspan="4">Gesamt</th><th><?= money($ebookTotalRevenue) ?></th><th><?= money($ebookTotalAuthorPayout) ?></th></tr></tfoot></table></div></section><?php endif; ?>
|
||||
</main>
|
||||
</body></html>
|
||||
@@ -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; } }
|
||||
@@ -0,0 +1,2 @@
|
||||
<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>Not found</title></head><body></body></html>
|
||||
@@ -0,0 +1 @@
|
||||
Require all denied
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/db.inc.php';
|
||||
|
||||
session_set_cookie_params([
|
||||
'httponly' => 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;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$dbConfig = [
|
||||
'host' => 'localhost',
|
||||
'port' => 3306,
|
||||
'database' => 'tat',
|
||||
'username' => 'tat',
|
||||
'password' => 'CHANGE_ME',
|
||||
'charset' => 'utf8mb4',
|
||||
];
|
||||
@@ -0,0 +1,2 @@
|
||||
<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>Not found</title></head><body></body></html>
|
||||
@@ -0,0 +1,2 @@
|
||||
<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>Not found</title></head><body></body></html>
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -0,0 +1,2 @@
|
||||
<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>Not found</title></head><body></body></html>
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user