Add Amazon revenue accounting

This commit is contained in:
ZeroIchiro
2026-09-23 19:56:19 +00:00
parent ecc602831a
commit dbeb86dd6c
8 changed files with 182 additions and 95 deletions
+5 -17
View File
@@ -1,19 +1,7 @@
# Änderungsprotokoll
# Changelog
## 2026-09-18
## 2026-09-23
Seit Commit `0eb32fb` wurden folgende Änderungen vorgenommen:
- Neue geschützte Admin-Seite `author_price.php` für die Berechnung von Autorenpreisen.
- Printbücher können per Dropdown ausgewählt werden.
- Berechnung des Autorenpreises auf Grundlage von Brutto-Preis, 7 % Mehrwertsteuer, Druckkosten und einem frei wählbaren Rabatt.
- Der Rabatt ist standardmäßig mit 85 % vorbelegt und kann zwischen 0 und 100 % eingegeben werden.
- Die Ersparnis pro Exemplar wird separat ausgewiesen.
- Der Autorenrabatt wird als eigener Rechenposten angezeigt.
- Checkbox ergänzt, um alternativ die kumulierte Autorenmarge aller am Buch beteiligten Autoren als Rabatt zu verwenden.
- Das manuelle Rabattfeld wird bei aktivierter Margen-Checkbox deaktiviert.
- Die kumulierte Autorenmarge wird aus allen Buch-Autor-Zuordnungen berechnet.
- JavaScript für die unmittelbare Aktivierung und Deaktivierung des Rabattfelds ergänzt.
- Navigation im Admin-Bereich um den Link zum Autorenpreis-Rechner erweitert.
- CSS für die Anordnung von Rabattfeld und Checkbox ergänzt.
- PHP-, SQL- und Zugriffsschutz-Prüfungen für die neue Funktion durchgeführt.
- Amazon-Marge als festen Bruttoerlös pro verkauftem Buch ergänzt.
- Amazon-Verkaufszahlen, CSV-Zuordnung und Autorenverteilung in die Abrechnung aufgenommen.
- eBook-CSV-Import, ISBN-Validierung und bestätigungspflichtiges Zurücksetzen der eBook-Abrechnung dokumentiert und abgesichert.
+14
View File
@@ -14,6 +14,20 @@ Einrichtung des Admin-Bereichs
Die Anwendung verwendet PDO mit echten Prepared Statements, CSRF-Schutz,
Session-Härtung und getrennte CSS-/JavaScript-Dateien.
ISBN-Eingabe und CSV-Import
Im Webformular werden Print- und eBook-ISBN als gültige ISBN-13 mit
Bindestrichen erwartet, zum Beispiel 978-3-16-148410-0. Beim CSV-Import
werden Bindestriche und andere Trennzeichen vor dem Abgleich entfernt.
Eine CSV-ISBN ohne Bindestriche wird daher gegen die formatierte ISBN in
TAT erkannt. Bei einem nicht gefundenen Treffer wird die betreffende Zeile
übersprungen. Alle übersprungenen Zeilen und Gründe werden gesammelt und
unterhalb des Uploads zusammengefasst angezeigt; gültige Zeilen können
weiterhin übernommen werden. Bereits gespeicherte eBook-Abrechnungen
können über die ausdrücklich bestätigungspflichtige Funktion „Gesamte
eBook-Abrechnung zurücksetzen“ gelöscht und anschließend neu importiert
werden. Print-Abrechnungen und Stammdaten bleiben dabei unverändert.
CSV-Import für die Abrechnung
Auf der Seite /tat/billing.php kann eine JTL-Verkaufs-CSV hochgeladen werden.
+14 -11
View File
@@ -92,8 +92,11 @@ if ($loggedIn) {
$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;
$amazonGross = filter_var($_POST['amazon_gross'] ?? null, FILTER_VALIDATE_FLOAT);
if ($amazonGross === false || $amazonGross < 0 || $amazonGross > 99999999.99) throw new InvalidArgumentException('Die Amazon-Marge muss zwischen 0 und 99.999.999,99 liegen.');
$amazonGross = round((float) $amazonGross, 2);
$isbnPrint = validateIsbnField(postText('isbn_print', 20), 'Die Print-ISBN');
$isbnEbook = validateIsbnField(postText('isbn_ebook', 20), 'Die eBook-ISBN');
$authorIds = $_POST['author_id'] ?? [];
$margins = $_POST['margin_percent'] ?? [];
if ($title === '') throw new InvalidArgumentException('Der Buchtitel ist erforderlich.');
@@ -107,13 +110,13 @@ if ($loggedIn) {
}
$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]);
$statement = $pdo->prepare('UPDATE books SET title = :title, printing_costs = :printing_costs, selling_price = :selling_price, ebook_price = :ebook_price, amazon_gross = :amazon_gross, isbn_print = :isbn_print, isbn_ebook = :isbn_ebook WHERE id = :id');
$statement->execute([':title' => $title, ':printing_costs' => $printingCosts, ':selling_price' => $sellingPrice, ':ebook_price' => $ebookPrice, ':amazon_gross' => $amazonGross, ':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]);
$statement = $pdo->prepare('INSERT INTO books (title, printing_costs, selling_price, ebook_price, amazon_gross, isbn_print, isbn_ebook) VALUES (:title, :printing_costs, :selling_price, :ebook_price, :amazon_gross, :isbn_print, :isbn_ebook)');
$statement->execute([':title' => $title, ':printing_costs' => $printingCosts, ':selling_price' => $sellingPrice, ':ebook_price' => $ebookPrice, ':amazon_gross' => $amazonGross, ':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)');
@@ -144,14 +147,14 @@ $editAuthorId = filter_var($_GET['edit_author'] ?? null, FILTER_VALIDATE_INT, ['
$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' => ''];
$book = ['id' => '', 'title' => '', 'printing_costs' => '0.00', 'selling_price' => '0.00', 'ebook_price' => '0.00', 'amazon_gross' => '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 id, title, printing_costs, selling_price, ebook_price, amazon_gross, 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();
$books = $pdo->query('SELECT b.id, b.title, b.printing_costs, b.selling_price, b.ebook_price, b.amazon_gross, 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>
@@ -160,8 +163,8 @@ $token = csrf();
<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>
<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="amazon-gross">Amazon-Marge (€ brutto pro verkauftem Buch)</label><input id="amazon-gross" type="number" name="amazon_gross" min="0" max="99999999.99" step="0.01" value="<?= e($book['amazon_gross']) ?>" 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>
<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>Amazon-Marge</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><?= number_format((float) $row['amazon_gross'], 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>
+83 -38
View File
@@ -12,6 +12,7 @@ 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';
const EBOOK_CSV_WARNINGS_SESSION_KEY = 'tat_ebook_csv_warnings';
function money(float $value): string
{
@@ -30,6 +31,19 @@ function normalizeIsbn(string $value): string
return preg_replace('/[^0-9xX]/', '', trim($value)) ?? '';
}
function importIsbn(string $value, string $label, int $lineNumber): string
{
$rawValue = trim($value);
$normalized = normalizeIsbn($rawValue);
if ($normalized === '') {
throw new InvalidArgumentException("Fehler in CSV-Zeile {$lineNumber}: {$label} ist leer.");
}
if (!preg_match('/^(?:97[89][0-9]{10}|[0-9]{9}[0-9xX])$/', $normalized)) {
throw new InvalidArgumentException("Fehler in CSV-Zeile {$lineNumber}: {$label} '{$rawValue}' ist keine gültige ISBN-10- oder ISBN-13-Zeichenfolge.");
}
return $normalized;
}
function csvChannel(string $customerGroup): ?string
{
$group = normalizeCsvValue($customerGroup);
@@ -37,6 +51,7 @@ function csvChannel(string $customerGroup): ?string
'großhandel', 'grosshandel', 'buchgrosshandel' => 'wholesale',
'buchhandel' => 'bookseller',
'endkunden', 'endkunde', 'direktkunden', 'direktkunde', 'direkt', 'privatkunden', 'privatkunde' => 'direct',
'amazon', 'amazonmarkt', 'amazon marketplace' => 'amazon',
default => null,
};
}
@@ -106,7 +121,7 @@ function parseCsvUpload(array $upload, PDO $pdo): array
if ($row === [null] || count(array_filter($row, static fn ($value): bool => trim((string) $value) !== '')) === 0) {
continue;
}
$isbn = normalizeIsbn((string) ($row[$isbnIndex] ?? ''));
$isbn = importIsbn((string) ($row[$isbnIndex] ?? ''), 'ISBN', $lineNumber);
$group = trim((string) ($row[$groupIndex] ?? ''));
$channel = csvChannel($group);
$quantityText = trim((string) ($row[$quantityIndex] ?? ''));
@@ -115,7 +130,8 @@ function parseCsvUpload(array $upload, PDO $pdo): array
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.");
$rawIsbn = trim((string) ($row[$isbnIndex] ?? ''));
throw new InvalidArgumentException("Fehler in CSV-Zeile {$lineNumber}: ISBN '{$rawIsbn}' (normalisiert: {$isbn}) ist in TAT nicht als Printbuch angelegt.");
}
$book = $booksByIsbn[$isbn];
$bookId = (int) $book['id'];
@@ -179,31 +195,34 @@ function parseEbookCsvUpload(array $upload, PDO $pdo): array
$booksByIsbn[normalizeIsbn((string) $book['isbn_ebook'])] = $book;
}
$aggregated = [];
$warnings = [];
$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;
}
try {
$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.");
throw new InvalidArgumentException('Verkaufsdatum muss JJJJ-MM-TT sein.');
}
$channel = trim((string) ($row[$indexes['verkaufsweg']] ?? ''));
$isbn = normalizeIsbn((string) ($row[$indexes['isbn']] ?? ''));
$isbn = importIsbn((string) ($row[$indexes['isbn']] ?? ''), 'ISBN', $lineNumber);
$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 ($channel === '' || $revenue === false || !is_finite((float) $revenue)) {
throw new InvalidArgumentException('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.");
$rawIsbn = trim((string) ($row[$indexes['isbn']] ?? ''));
throw new InvalidArgumentException("ISBN '{$rawIsbn}' (normalisiert: {$isbn}) ist in TAT nicht als eBook angelegt.");
}
$book = $booksByIsbn[$isbn];
$key = (int) $book['id'] . '|' . $date->format('Y-m-d') . '|' . $channel;
@@ -216,16 +235,16 @@ function parseEbookCsvUpload(array $upload, PDO $pdo): array
'revenue' => 0.0,
];
$aggregated[$key]['revenue'] += (float) $revenue;
} catch (Throwable $exception) {
$warnings[] = "Zeile {$lineNumber}: {$exception->getMessage()}";
}
}
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);
return ['rows' => array_values($aggregated), 'warnings' => $warnings];
}
$message = '';
@@ -243,9 +262,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$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)'
'INSERT INTO book_sales (book_id, wholesale_quantity, bookseller_quantity, direct_quantity, amazon_quantity)
VALUES (:book_id, :wholesale, :bookseller, :direct, :amazon)
ON DUPLICATE KEY UPDATE wholesale_quantity = VALUES(wholesale_quantity), bookseller_quantity = VALUES(bookseller_quantity), direct_quantity = VALUES(direct_quantity), amazon_quantity = VALUES(amazon_quantity)'
);
foreach ($quantities as $bookId => $values) {
$bookId = filter_var($bookId, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
@@ -257,7 +276,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
throw new InvalidArgumentException('Es können nur Printbücher abgerechnet werden.');
}
$parsed = [];
foreach (['wholesale', 'bookseller', 'direct'] as $channel) {
foreach (['wholesale', 'bookseller', 'direct', 'amazon'] 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.');
@@ -269,6 +288,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
':wholesale' => $parsed['wholesale'],
':bookseller' => $parsed['bookseller'],
':direct' => $parsed['direct'],
':amazon' => $parsed['amazon'],
]);
}
$pdo->commit();
@@ -277,8 +297,21 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$_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.';
$result = parseEbookCsvUpload($_FILES['ebook_sales_csv'] ?? [], $pdo);
$_SESSION[EBOOK_CSV_PREVIEW_SESSION_KEY] = $result['rows'];
$_SESSION[EBOOK_CSV_WARNINGS_SESSION_KEY] = $result['warnings'];
$message = $result['rows']
? 'eBook-CSV geprüft. Bitte die Vorschau kontrollieren und anschließend bestätigen.'
: 'Die eBook-CSV enthält keine importierbaren Zeilen.';
} elseif ($action === 'cancel_ebook_csv') {
unset($_SESSION[EBOOK_CSV_PREVIEW_SESSION_KEY], $_SESSION[EBOOK_CSV_WARNINGS_SESSION_KEY]);
$message = 'Die eBook-CSV-Vorschau wurde verworfen. Es wurden keine Abrechnungsdaten geändert.';
} elseif ($action === 'reset_ebook_sales') {
$pdo->beginTransaction();
$deleted = $pdo->exec('DELETE FROM ebook_sales');
$pdo->commit();
unset($_SESSION[EBOOK_CSV_PREVIEW_SESSION_KEY], $_SESSION[EBOOK_CSV_WARNINGS_SESSION_KEY]);
$message = "Die eBook-Abrechnung wurde zurückgesetzt. {$deleted} gespeicherte Zeile(n) wurden gelöscht.";
} elseif ($action === 'confirm_ebook_csv') {
$preview = $_SESSION[EBOOK_CSV_PREVIEW_SESSION_KEY] ?? null;
if (!is_array($preview) || !$preview) {
@@ -310,7 +343,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$count++;
}
$pdo->commit();
unset($_SESSION[EBOOK_CSV_PREVIEW_SESSION_KEY]);
unset($_SESSION[EBOOK_CSV_PREVIEW_SESSION_KEY], $_SESSION[EBOOK_CSV_WARNINGS_SESSION_KEY]);
$message = "eBook-CSV für {$count} ausgewählte Zeilen übernommen.";
} elseif ($action === 'confirm_csv') {
$preview = $_SESSION[CSV_PREVIEW_SESSION_KEY] ?? null;
@@ -326,9 +359,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$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)'
'INSERT INTO book_sales (book_id, wholesale_quantity, bookseller_quantity, direct_quantity, amazon_quantity)
VALUES (:book_id, :wholesale, :bookseller, :direct, :amazon)
ON DUPLICATE KEY UPDATE wholesale_quantity = VALUES(wholesale_quantity), bookseller_quantity = VALUES(bookseller_quantity), direct_quantity = VALUES(direct_quantity), amazon_quantity = VALUES(amazon_quantity)'
);
$pdo->beginTransaction();
$count = 0;
@@ -343,6 +376,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
':wholesale' => (int) $row['wholesale'],
':bookseller' => (int) $row['bookseller'],
':direct' => (int) $row['direct'],
':amazon' => (int) $row['amazon'],
]);
$count++;
}
@@ -364,10 +398,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$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,
'SELECT b.id, b.title, b.isbn_print, b.isbn_ebook, b.selling_price, b.printing_costs, b.amazon_gross,
COALESCE(s.wholesale_quantity, 0) AS wholesale_quantity,
COALESCE(s.bookseller_quantity, 0) AS bookseller_quantity,
COALESCE(s.direct_quantity, 0) AS direct_quantity
COALESCE(s.direct_quantity, 0) AS direct_quantity,
COALESCE(s.amazon_quantity, 0) AS amazon_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 <> ""
@@ -375,6 +410,7 @@ $books = $pdo->query(
)->fetchAll();
$ebookCsvPreview = $_SESSION[EBOOK_CSV_PREVIEW_SESSION_KEY] ?? [];
$ebookCsvWarnings = $_SESSION[EBOOK_CSV_WARNINGS_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
@@ -403,26 +439,32 @@ foreach ($books as $book) {
$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;
$amazonRevenue = (float) $book['amazon_gross'] * (int) $book['amazon_quantity'];
$printRevenue = $wholesaleRevenue + $booksellerRevenue + $directRevenue;
$revenue = $printRevenue + $amazonRevenue;
$totalRevenue += $revenue;
$authorCalculations = [];
foreach ($authorsByBook[(int) $book['id']] ?? [] as $author) {
$authorNet = $revenue * ((float) $author['margin_percent'] / 100);
$authorNet = $printRevenue * ((float) $author['margin_percent'] / 100);
$authorPayout = $authorNet * (1 + PRINT_VAT_RATE);
$totalAuthorPayout += $authorPayout;
$amazonAuthorPayout = $amazonRevenue * ((float) $author['margin_percent'] / 100);
$totalAuthorPayout += $authorPayout + $amazonAuthorPayout;
$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' => [], 'print_payout' => 0.0, 'amazon_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, 'amazon' => 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]['books'][(int) $book['id']]['amazon'] += $amazonRevenue * ((float) $author['margin_percent'] / 100);
$authorSummary[$authorName]['print_payout'] += $authorPayout;
$authorCalculations[] = ['name' => $authorName, 'margin_percent' => (float) $author['margin_percent'], 'payout' => $authorPayout];
$authorSummary[$authorName]['amazon_payout'] += $amazonAuthorPayout;
$authorCalculations[] = ['name' => $authorName, 'margin_percent' => (float) $author['margin_percent'], 'payout' => $authorPayout + $amazonAuthorPayout];
}
$calculations[(int) $book['id']] = [
'wholesale_revenue' => $wholesaleRevenue,
'bookseller_revenue' => $booksellerRevenue,
'direct_revenue' => $directRevenue,
'amazon_revenue' => $amazonRevenue,
'revenue' => $revenue,
'authors' => $authorCalculations,
];
@@ -439,7 +481,7 @@ foreach ($ebookSales as $sale) {
$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' => [], 'print_payout' => 0.0, 'amazon_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;
@@ -466,19 +508,22 @@ $token = csrf();
<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>
<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, Amazon → Amazon.</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; ?>
<?php if ($ebookCsvWarnings): ?><section class="panel"><h2>Übersprungene eBook-CSV-Zeilen</h2><p class="notice error"><?= count($ebookCsvWarnings) ?> Zeile(n) wurden übersprungen. Die übrigen gültigen Zeilen können übernommen werden.</p><ul><?php foreach ($ebookCsvWarnings as $warning): ?><li><?= e($warning) ?></li><?php endforeach; ?></ul></section><?php endif; ?>
<?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><form method="post" class="inline-form"><input type="hidden" name="action" value="cancel_ebook_csv"><input type="hidden" name="csrf_token" value="<?= e($token) ?>"><div class="actions"><button class="secondary" type="submit">Vorschau verwerfen</button></div></form></section><?php endif; ?>
<?php if ($ebookSales): ?><section class="panel"><h2>eBook-Abrechnung zurücksetzen</h2><p class="notice error">Damit werden alle bereits gespeicherten eBook-Abrechnungszeilen gelöscht. Bücher, Autoren, Margen und Print-Abrechnungen bleiben erhalten.</p><form method="post" data-confirm="Wirklich alle gespeicherten eBook-Abrechnungsdaten löschen? Dieser Vorgang kann nicht rückgängig gemacht werden."><input type="hidden" name="action" value="reset_ebook_sales"><input type="hidden" name="csrf_token" value="<?= e($token) ?>"><div class="actions"><button class="danger" type="submit">Gesamte eBook-Abrechnung zurücksetzen</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>Amazon</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['amazon'] ?></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; ?>
<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>Amazon-Marge</th><th>Druckkosten</th><th>ISBN Print</th><th>Großhandel</th><th>Buchhandel</th><th>Direkt</th><th>Amazon</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['amazon_gross']) ?></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><td><label class="visually-hidden" for="amazon-<?= (int) $book['id'] ?>">Amazon für <?= e($book['title']) ?></label><input id="amazon-<?= (int) $book['id'] ?>" type="number" name="quantity[<?= (int) $book['id'] ?>][amazon]" min="0" max="1000000000" step="1" value="<?= (int) $book['amazon_quantity'] ?>" required></td></tr><?php endforeach; ?>
<?php if (!$books): ?><tr><td colspan="9">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, Amazon 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>Amazon</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['amazon_payout']) ?></th><th><?= money($summary['ebook_payout']) ?></th><th><?= money($summary['print_payout'] + $summary['amazon_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['amazon']) ?></td><td><?= money($bookSummary['ebook']) ?></td><td><?= money($bookSummary['wholesale'] + $bookSummary['bookseller'] + $bookSummary['direct'] + $bookSummary['amazon'] + $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(array_sum(array_map(static fn (array $summary): float => array_sum(array_column($summary['books'], 'amazon')), $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>Erlös Amazon</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['amazon_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="5">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>
<script src="scripts/admin.js"></script>
</body></html>
+27
View File
@@ -76,6 +76,33 @@ function postText(string $key, int $maxLength): string
return $value;
}
function validateIsbnField(string $value, string $label): ?string
{
$value = trim($value);
if ($value === '') {
return null;
}
if (!preg_match('/^97[89](?:-[0-9]+)+$/', $value)) {
throw new InvalidArgumentException($label . ' muss als ISBN-13 mit Bindestrichen eingegeben werden, zum Beispiel 978-3-16-148410-0.');
}
$digits = str_replace('-', '', $value);
if (strlen($digits) !== 13) {
throw new InvalidArgumentException($label . ' muss genau 13 Ziffern enthalten.');
}
$sum = 0;
for ($index = 0; $index < 12; $index++) {
$sum += (int) $digits[$index] * ($index % 2 === 0 ? 1 : 3);
}
$checkDigit = (10 - ($sum % 10)) % 10;
if ((int) $digits[12] !== $checkDigit) {
throw new InvalidArgumentException($label . ' enthält eine ungültige Prüfziffer.');
}
return $value;
}
function redirectAdmin(): never
{
header('Location: admin.php');
+7
View File
@@ -0,0 +1,7 @@
ALTER TABLE books
ADD COLUMN IF NOT EXISTS amazon_gross DECIMAL(10,2) NOT NULL DEFAULT 0.00
AFTER ebook_price;
ALTER TABLE book_sales
ADD COLUMN IF NOT EXISTS amazon_quantity INT UNSIGNED NOT NULL DEFAULT 0
AFTER direct_quantity;
+5 -4
View File
@@ -1,4 +1,9 @@
(() => {
document.addEventListener('submit', event => {
const message = event.target.dataset.confirm;
if (message && !window.confirm(message)) event.preventDefault();
});
const root = document.querySelector('[data-admin-app]');
if (!root) return;
@@ -48,9 +53,5 @@
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
@@ -30,6 +30,7 @@ CREATE TABLE IF NOT EXISTS books (
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,
amazon_gross 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,
@@ -55,6 +56,7 @@ CREATE TABLE IF NOT EXISTS book_sales (
wholesale_quantity INT UNSIGNED NOT NULL DEFAULT 0,
bookseller_quantity INT UNSIGNED NOT NULL DEFAULT 0,
direct_quantity INT UNSIGNED NOT NULL DEFAULT 0,
amazon_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