Import current PCAT application

This commit is contained in:
ZeroIchiro
2026-09-15 10:47:36 +00:00
commit 1b532b937f
18 changed files with 1059 additions and 0 deletions
+11
View File
@@ -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/
+5
View File
@@ -0,0 +1,5 @@
Options -Indexes
<FilesMatch "\.(sql|log|ini|env|bak|backup|dist|swp)$">
Require all denied
</FilesMatch>
+90
View File
@@ -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.
+167
View File
@@ -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>Mar­ge %</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>Mar­ge %</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>
+24
View File
@@ -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
View File
@@ -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>
+36
View File
@@ -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; } }
+2
View File
@@ -0,0 +1,2 @@
<!doctype html>
<html><head><meta charset="utf-8"><title>Not found</title></head><body></body></html>
+1
View File
@@ -0,0 +1 @@
Require all denied
+83
View File
@@ -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;
}
+12
View File
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
$dbConfig = [
'host' => 'localhost',
'port' => 3306,
'database' => 'tat',
'username' => 'tat',
'password' => 'CHANGE_ME',
'charset' => 'utf8mb4',
];
+2
View File
@@ -0,0 +1,2 @@
<!doctype html>
<html><head><meta charset="utf-8"><title>Not found</title></head><body></body></html>
+2
View File
@@ -0,0 +1,2 @@
<!doctype html>
<html><head><meta charset="utf-8"><title>Not found</title></head><body></body></html>
+3
View File
@@ -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;
+56
View File
@@ -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);
})();
+2
View File
@@ -0,0 +1,2 @@
<!doctype html>
<html><head><meta charset="utf-8"><title>Not found</title></head><body></body></html>
+75
View File
@@ -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;