diff --git a/CHANGELOG.md b/CHANGELOG.md index 8996490..9c3d4db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README_SETUP.txt b/README_SETUP.txt index bc9d40c..81c4839 100644 --- a/README_SETUP.txt +++ b/README_SETUP.txt @@ -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. diff --git a/admin.php b/admin.php index 50e1e24..63dff2d 100644 --- a/admin.php +++ b/admin.php @@ -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(); ?> Autorenmargen – Admin @@ -160,8 +163,8 @@ $token = csrf();

Abbrechen
-

    Abbrechen
    +

      Abbrechen

      Autoren

      NameAdresseIBANBICBanknamePayPal-AdresseAktionen
      Bearbeiten
      -

      Bücher

      TitelDruckkostenPrint-PreiseBook-PreisISBN PrintISBN eBookAutoren / MargenAktionen
      € € €Bearbeiten
      +

      Bücher

      TitelDruckkostenPrint-PreiseBook-PreisAmazon-MargeISBN PrintISBN eBookAutoren / MargenAktionen
      € € € €Bearbeiten
      diff --git a/billing.php b/billing.php index 191f59b..a63fef4 100644 --- a/billing.php +++ b/billing.php @@ -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,53 +195,56 @@ 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; } - $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."); + 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('Verkaufsdatum muss JJJJ-MM-TT sein.'); + } + $channel = trim((string) ($row[$indexes['verkaufsweg']] ?? '')); + $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 === '' || $revenue === false || !is_finite((float) $revenue)) { + throw new InvalidArgumentException('Verkaufsweg, ISBN oder Erlös ist ungültig.'); + } + if (!isset($booksByIsbn[$isbn])) { + $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; + $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; + } catch (Throwable $exception) { + $warnings[] = "Zeile {$lineNumber}: {$exception->getMessage()}"; } - $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); + 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();

      Autorenmargen – Admin

      -

      CSV-Abrechnung importieren

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

      +

      CSV-Abrechnung importieren

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

      eBook-CSV importieren

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

      -

      eBook-CSV-Vorschau

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

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

      CSV-Vorschau

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

      ÜbernehmenBuchISBN PrintGroßhandelBuchhandelDirektCSV-Zeilen
      +

      Übersprungene eBook-CSV-Zeilen

      Zeile(n) wurden übersprungen. Die übrigen gültigen Zeilen können übernommen werden.

      +

      eBook-CSV-Vorschau

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

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

      eBook-Abrechnung zurücksetzen

      Damit werden alle bereits gespeicherten eBook-Abrechnungszeilen gelöscht. Bücher, Autoren, Margen und Print-Abrechnungen bleiben erhalten.

      +

      CSV-Vorschau

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

      ÜbernehmenBuchISBN PrintGroßhandelBuchhandelDirektAmazonCSV-Zeilen

      Manuelle Verkaufszahlen

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

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

      Auswertung nach Autoren

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

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

      Berechnung nach Buch

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

      BuchErlös GroßhandelErlös BuchhandelErlös DirektGesamterlösAutorenauszahlung
      ( %):
      Kein Autor zugewiesen
      Gesamt
      +
      + +
      BuchVerkaufspreisAmazon-MargeDruckkostenISBN PrintGroßhandelBuchhandelDirektAmazon
      Es sind keine abrechenbaren Printbücher vorhanden.
      +

      Auswertung nach Autoren

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

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

      Berechnung nach Buch

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

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

      eBook-Berechnung

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

      VerkaufsdatumVerkaufswegeBookISBN eBookErlösAutorenanteil
      ( %):
      Kein Autor zugewiesen
      Gesamt
      + diff --git a/inc/admin_bootstrap.php b/inc/admin_bootstrap.php index fd37a69..4d8c171 100644 --- a/inc/admin_bootstrap.php +++ b/inc/admin_bootstrap.php @@ -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'); diff --git a/migrations/003_add_amazon_revenue.sql b/migrations/003_add_amazon_revenue.sql new file mode 100644 index 0000000..de9b262 --- /dev/null +++ b/migrations/003_add_amazon_revenue.sql @@ -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; \ No newline at end of file diff --git a/scripts/admin.js b/scripts/admin.js index d1b1a1b..0668d77 100644 --- a/scripts/admin.js +++ b/scripts/admin.js @@ -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); })(); diff --git a/tat_schema.sql b/tat_schema.sql index 18c8eef..acab35a 100644 --- a/tat_schema.sql +++ b/tat_schema.sql @@ -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