Comic-Antiquariat Katalog
Software zur Sammlungsverwaltung, zum Katalogdruck und -versand: eine Sammlung – vom
einzelnen Heft bis zum Konvolut – erfassen, bewerten und pflegen, und daraus auf
Knopfdruck einen druckfertigen Verkaufskatalog erzeugen, wahlweise als PDF zum Versand
oder als Druckvorlage.
Ausgangslage. Umfangreiche Sammlungen – Comics, Romanhefte, Sammelbilder – werden
bei vielen Antiquariaten und privaten Sammlern nach wie vor auf Papier, in Karteikarten
oder in unverbundenen Tabellen geführt. Das erschwert drei Dinge zugleich: den
Überblick über den eigenen Bestand, die laufende Wertermittlung und die Erstellung
aktueller Verkaufslisten für Kundinnen und Kunden. Vorhandene historische Listen – oft
die einzige vollständige Bestandsaufnahme überhaupt – lassen sich mit vertretbarem
Aufwand kaum digitalisieren.
Diese Anwendung löst das in einem durchgängigen Arbeitsablauf: historische Listen
einlesen, im Bestand prüfen und pflegen, daraus jederzeit einen aktuellen,
professionell gestalteten Katalog erzeugen. Ein zentraler Nachweis dafür ist bereits
erbracht, nicht nur behauptet: Eine reale, 17-seitige Verkaufsliste eines
Comic-Antiquariats aus dem Jahr 1987 mit über 4.400 Einzelpositionen in rund 210
Serien wurde vollständig eingelesen – siehe „Lebenszyklus eines importierten
Eintrags“ weiter unten.
Technische Basis: Symfony 7.x, PHP 8.3, Vue 3 im Frontend, MariaDB als Datenbank.
Lauffähig auf gängigem Webhosting, Deployment per SFTP – keine Bindung an einen
Cloud-Anbieter. Den vollständigen Funktionsumfang und die Versionsgeschichte zeigt
CHANGELOG.md.
Enthalten
| Datei |
Zweck |
src/Service/CatalogParserService.php |
Zeilenparser inkl. Serienkontext, N/S/oS/(B) |
src/Service/PriceNormalizer.php |
„1,50“ → "1.50" als String, ohne float |
src/Service/IssueNumberNormalizer.php |
Sortierschlüssel für Heftnummern |
src/Dto/ParsedCatalogLine.php, CatalogParseResult.php |
Parser-Ergebnis ohne Doctrine-Abhängigkeit |
src/Entity/CatalogEntry.php |
Staging-Datensatz, UUIDv7, Rohtext, Importhash |
src/Repository/CatalogEntryRepository.php |
Hash-Vorabladung, Prüfliste, Statuszählung |
src/Command/ImportCatalogCommand.php |
app:import-catalog mit --dry-run, Batch-Flush |
src/Form/CatalogEntryType.php |
Korrekturformular inkl. Preis-Transformer |
src/Controller/CatalogEntryController.php |
Liste, Neuanlage, Korrektur, Freigabe, Löschen |
templates/base.html.twig, _nav, _flashes, _styles |
Grundlayout, Navigation, Meldungen, Schreibmaschinen-CSS an einer Stelle |
src/Controller/DashboardController.php, templates/dashboard/index.html.twig |
Startseite / mit Zahlen, Lücken und offenen Aufgaben |
templates/catalog/* |
Katalogeinträge: Liste, Neuanlage, Korrektur |
tests/Unit/CatalogParserServiceTest.php |
8 Tests gegen die Parser-Regeln |
Bestandsseite
| Datei |
Zweck |
src/Entity/ComicSeries.php, ComicIssue.php |
Bibliografische Ebene: Reihe und Heft |
src/Entity/ComicBook.php |
Das physische Objekt im Regal |
src/Entity/ComicBookItem.php |
Heftposition darin - ersetzt missingIssues als JSON |
src/Entity/ConditionGrade.php, Publisher.php, PriceObservation.php |
Stammdaten und Preisbeobachtungen |
src/Service/CatalogTransferService.php |
APPROVED → Bestand, einmalig je Eintrag |
src/Service/IssueRangeExpander.php |
Löst "33-36" auf, verweigert Unklares |
src/Service/InventoryNumberGenerator.php |
MIC-2026-000042 |
src/Command/TransferCatalogEntriesCommand.php |
app:transfer-catalog-entries |
src/Command/ApproveCatalogEntriesCommand.php |
app:approve-catalog-entries – Sammelfreigabe mit Filtern |
src/Command/SeedConditionGradesCommand.php |
Zustandsstufen 0 bis 4 anlegen |
src/Controller/ComicBookController.php, templates/bestand/* |
Bestandsliste, Objektansicht, Handerfassung, Bearbeiten |
src/Form/ComicBookEditType.php |
Bearbeiten eines bestehenden Objekts |
src/Entity/MediaAsset.php, src/Service/MediaStorageService.php, src/Form/MediaUploadType.php |
Fotos und Scans unter public/media-export/ |
src/Command/CheckMediaCommand.php |
app:check-media gleicht Verzeichnis und Datenbank ab |
.ddev/*, composer.json, phpunit.dist.xml, phpstan.dist.neon |
Lokale Umgebung – siehe SETUP.md |
Werte und Verwaltung
| Datei |
Zweck |
src/Value/Money.php |
Geldrechnung in Cent, ohne float |
src/Value/StockValueResolver.php |
Maßgeblicher Lagerwert: Objekt → Positionen → Einkauf |
src/Value/ValueAggregator.php, ValueOverviewService.php |
Summen je Serie, drei flache Abfragen |
src/Controller/Api/ValueApiController.php |
GET /api/werte |
src/Admin/*, src/Controller/Api/AdminApiController.php |
JSON-API für den Editor, Positivliste der Felder |
src/Admin/DeleteImpact.php |
Löschfolgen aus den Doctrine-Metadaten: mitgelöscht, Verweis verliert sich, blockiert |
assets/app.js, assets/components/*, assets/lib/api.js |
Vue-Komponenten: Wertübersicht, Editor, Feld, Relationsauswahl, Schnelleingabe, Editor für die Einleitung |
assets/vendor/vue/ |
Vue 3.5 Browser-Build (MIT), kein Node im Projekt nötig |
importmap.php |
AssetMapper-Einträge |
templates/werte/, templates/verwaltung/ |
Druckfassung der Werte, Editor-Seite |
tests/Unit/MoneyTest.php, ValueAggregatorTest.php |
Geldrechnung und Summenlogik |
src/Service/ComicBookAssembler.php |
Objekt- und Positionslogik für beide Erfassungswege |
src/Service/ComicBookCreator.php, src/Form/ComicBookType.php, src/Dto/ComicBookInput.php |
Erfassung direkt vom Regal |
src/Service/CurrencyConverter.php |
DM-Vergleichswert, Kurs konfigurierbar |
src/Command/RecalculatePriceConversionsCommand.php |
Vergleichswerte nach Kurswechsel neu rechnen |
tests/Unit/IssueRangeExpanderTest.php, InventoryNumberGeneratorTest.php, CurrencyConverterTest.php |
Regeln für Bereiche, Inventarnummern, Kurs |
Katalog-Import (umgebaut) und Katalog-PDF
| Datei |
Zweck |
src/Import/CatalogSourceReader.php, CatalogReaderRegistry.php |
Ein Reader je Format, Auswahl über Endung oder --format |
src/Import/TextCatalogReader.php |
Bisheriger Textimport, jetzt als Reader um den CatalogParserService |
src/Import/CsvCatalogReader.php |
OCR-Tabellen: BOM, Trennzeichen, Spaltennamen deutsch/englisch, stock |
src/Import/CatalogLineInspector.php |
Plausibilität für alle Formate, Hinweis auf Nachbarserien |
src/Import/CatalogImporter.php, CatalogImportReport.php |
Schreiben der Einträge, Hash-Abgleich, Batch-Flush, Bericht |
src/Enum/ReviewReason.php, src/Dto/ReviewFinding.php |
Gründe für die Prüfliste mit Klartext und Abzug |
src/Entity/CatalogPdf.php, src/Repository/CatalogPdfRepository.php |
Gespeicherte Zusammenstellung: Serien, Titelbild, Optionen |
src/CatalogPdf/* |
Datenaufbau, Titelbild-Ablage, Dompdf-Renderer |
src/Controller/CatalogPdfController.php, src/Form/CatalogPdfType.php |
Liste, Anlegen, Bearbeiten, Kopie, Löschen, PDF, Vorschau |
src/Text/IntroductionHtml.php |
Bereinigt die Einleitung des Katalogs auf eine feste Tag-Liste, entfernt Skripte und unsichere Links |
templates/katalog_pdf/* |
Liste, Formular mit Serienfilter, PDF-/Vorschau-Template |
src/Command/RenderCatalogPdfCommand.php |
app:catalog-pdf - großer Katalog ohne Zeitlimit des Webservers |
src/Controller/Api/QuickCreateApiController.php (/api/schnell/zeilen*), assets/components/QuickLines.js |
Schnelleingabe mehrerer Katalogeinträge im Listenformat, direkt in der Verwaltung |
src/Twig/MoneyExtension.php |
Filter cents: 1250 → 12,50 |
tests/Unit/CsvCatalogReaderTest.php, CatalogLineInspectorTest.php |
CSV-Regeln und Prüfregeln |
Anmeldung
Die Anwendung hat jetzt eine Anmeldung: alles außer /login verlangt ein Konto
(security.yaml, access_control). Es gibt keine Registrierung im Browser und keine
Zurücksetzung per E-Mail – Konten legt die Konsole an:
ddev console app:benutzer person@example.org # fragt das Passwort verdeckt ab
ddev console app:benutzer person@example.org --name="Per-Olof"
ddev console app:benutzer person@example.org --passwort=… # für Skripte; landet in der Shell-Historie
ddev console app:benutzer --liste # Konten und letzte Anmeldung
Dasselbe Kommando setzt bei einem vorhandenen Konto das Passwort neu. Mindestlänge: 10 Zeichen.
| Teil |
Datei |
Entity und Tabelle admin_user |
src/Entity/AdminUser.php, migrations/Version20260923090000.php |
| Repository samt Hash-Nachrüstung |
src/Repository/AdminUserRepository.php |
| Firewall, Rollen, Zugriffsregeln |
config/packages/security.yaml |
| Login-Seite und Abmelden |
src/Controller/SecurityController.php, templates/security/login.html.twig |
401 statt Weiterleitung für /api/ |
src/Security/LoginEntryPoint.php |
| Zeitstempel der letzten Anmeldung |
src/Security/LoginTimestampListener.php |
Abgemeldet wird über den Knopf in der Kopfzeile; die Route /logout fängt die Firewall ab,
mit CSRF-Token wie beim Login (csrf.yaml führt authenticate und logout bereits).
Gespeichert wird in der Symfony-Session, also einem Cookie ohne Datenbanktabelle.
Die Vue-Komponenten schicken ihre Anfragen mit credentials: same-origin, laufen also über
dieselbe Session. Ist sie abgelaufen, kommt aus /api/… ein sauberes 401 mit JSON statt der
Login-Seite als HTML.
Beim ersten Start nach dem Update:
ddev composer require symfony/security-bundle:7.*
ddev console doctrine:migrations:migrate
ddev console app:benutzer deine@adresse.de
Gegenüber der Vorlage geändert
parseLine() vs. parse() – der Command rief eine Methode auf, die es nicht gab.
Jetzt: parse() liefert ein CatalogParseResult, der Command baut die Entities.
(float) $price widersprach der eigenen Regel „decimal, niemals float“.
Preise laufen jetzt durchgängig als String.
Symfony\Component\Routing\Annotation\Route ist in Symfony 7 deprecated →
Symfony\Component\Routing\Attribute\Route.
- Regex erweiterte Fälle:
1-2/2, o. Nr., 33a, Komma-Preise, mehrere Attribute
(„N S“), optionale Währung. oS steht vor S in der Alternation.
- Heftnummer bekommt
issueSortKey – sonst sortiert die Liste 1, 10, 2.
- Nicht erkannte Zeilen werden als
NEEDS_REVIEW mit Rohtext gespeichert statt verworfen.
importHash (unique) macht den Import wiederholbar, ohne Dubletten anzulegen.
symfony/mime fehlte in composer.json, obwohl CoverImageStorage und
MediaStorageService $file->getMimeType() aufrufen - diese Methode braucht das
Paket immer, unabhängig vom Mime-Typ des Uploads, sonst wirft sie eine
LogicException. Jeder Foto- und Titelbild-Upload endete deshalb mit HTTP 500.
Jetzt in composer.json eingetragen; nach dem Einspielen einmal
composer update symfony/mime laufen lassen.
Einbau
Die vollständige Anleitung steht in SETUP.md (DDEV, Datenbank, erster
Importlauf, Stolpersteine). Kurzfassung:
ddev start # nutzt .ddev/config.yaml aus diesem Paket
ddev console make:migration
ddev console doctrine:migrations:migrate --no-interaction
ddev console app:seed-condition-grades
ddev katalog var/imports/beispiel-katalog.txt --source "Katalog August 87" --dry-run --show-failures
Danach ohne --dry-run importieren und unter /catalog?status=NEEDS_REVIEW prüfen.
Freigegebene Einträge übernehmen:
ddev exec php bin/console app:seed-condition-grades
ddev exec php bin/console app:transfer-catalog-entries --dry-run
Ergebnis unter /bestand.
config/packages/doctrine.yaml enthält nur den relevanten Auszug (UUID-Typ).
Erfassen ohne Import
/catalog/new legt einen Eintrag von Hand an – für Hefte, die in keiner Liste stehen.
Solche Einträge starten als APPROVED (ein Mensch hat sie getippt), tragen einen
manual--Hash statt eines Zeilen-Hashes und lassen sich mit „Speichern und nächster
Eintrag" am Stück erfassen, wobei die Serie stehen bleibt. Gelöscht werden kann nur,
was noch nicht im Bestand ist – übernommene Einträge bleiben als Beleg.
Zwei Wege in den Bestand
- Über den Katalog: importieren → prüfen → freigeben →
app:transfer-catalog-entries.
Der CatalogEntry bleibt als Beleg am Objekt hängen.
- Direkt vom Regal:
/bestand/new. Serienname als Freitext mit Vorschlagsliste
(neue Serien entstehen beim Speichern), fehlende Hefte als Kommaliste, „Anlegen und
nächstes Objekt" behält Serie und Standort.
Danach: /bestand/{id}/edit ändert Zustand, Standort, Preise und Notizen; die
Positionstabelle auf der Objektseite schaltet jedes Heft zwischen vorhanden, fehlt,
Farbkopie, Nachdruck und Ersatzkopie um, entfernt Zeilen und trägt neue nach – alles in
einem Formular. Vollständigkeit und Stückzahl rechnet refreshCounts() danach neu,
außer bei Objekten mit einer nicht aufgelösten Sammelposition.
Beide Wege laufen durch den ComicBookAssembler, damit ein von Hand erfasstes Konvolut
genauso aufgebaut ist wie ein importiertes – gleiche Regeln für Bereiche, gleiche
Positionslogik. CatalogTransferService ist dadurch von 190 auf 117 Zeilen geschrumpft.
Konsolenbefehle
Alle Befehle laufen im Web-Container. Wegen der Anführungszeichen bei Werten mit
Leerzeichen besser ddev exec php bin/console … als ddev console … (siehe SETUP.md).
| Befehl |
Zweck |
Wichtige Optionen |
app:import-catalog <datei> |
Liste (.txt) oder Tabelle (.csv) als Katalogeinträge einlesen |
--source, --dry-run, --show-failures |
app:approve-catalog-entries |
Einträge für die Übernahme freigeben |
--status, --series, --limit, --dry-run |
app:transfer-catalog-entries |
Freigegebene Einträge in den Bestand überführen |
--series, --limit, --expand-limit, --dry-run |
app:seed-condition-grades |
Zustandsstufen anlegen (einmalig nach der Installation) |
– |
app:recalculate-price-conversions |
Vergleichswerte aller Preisbeobachtungen mit dem aktuellen APP_RATE_DM neu rechnen |
– |
app:check-media |
public/media-export/ gegen die Datenbank abgleichen |
– |
app:catalog-pdf [id] |
Gespeichertes Katalog-PDF als Datei erzeugen; ohne Id die Liste der Kataloge |
– |
app:benutzer |
Verwaltungskonto anlegen oder Passwort neu setzen |
– |
Lebenszyklus eines importierten Eintrags
Ein CatalogEntry ist das Zwischenlager zwischen Liste und Bestand. Er wandert durch
sechs Status (App\Enum\ImportStatus), und nur zwei Schritte legen Objekte an:
Datei ──app:import-catalog──▶ NEW / PARSED / NEEDS_REVIEW
│
Liste /catalog │ prüfen und korrigieren
▼
Knopf „freigeben“ oder app:approve-catalog-entries
│
▼
APPROVED
│
app:transfer-catalog-entries
▼
IMPORTED ──▶ ComicBook im Bestand
| Status |
Bedeutung |
Wie es weitergeht |
NEW |
eingelesen, noch nicht zerlegt |
wie PARSED |
PARSED |
Serie, Heft, Zustand und Preis erkannt |
freigeben |
NEEDS_REVIEW |
der Import war unsicher (bei OCR-Zeilen mit ?, fehlendem Kurs oder unklarer Zeile) |
in /catalog ansehen, korrigieren, dann freigeben |
APPROVED |
zur Übernahme freigegeben |
app:transfer-catalog-entries |
IMPORTED |
übernommen, comicBook gesetzt |
bleibt als Beleg am Objekt hängen |
REJECTED |
verworfen |
bleibt liegen, wird nirgends mehr angefasst |
Ein typischer Durchlauf für eine große Liste, Serie für Serie:
# 1. Einlesen, erst ohne zu schreiben
ddev exec php bin/console app:import-catalog var/imports/friedrich-aug-87.txt --dry-run --show-failures
ddev exec php bin/console app:import-catalog var/imports/friedrich-aug-87.txt
# 2. In /catalog die Zeilen mit Status „zu prüfen" durchgehen und korrigieren
# 3. Freigeben - entweder in der Liste („Alle auf dieser Seite freigeben")
# oder auf der Konsole, gefiltert:
ddev exec php bin/console app:approve-catalog-entries --series="Micky Maus" --dry-run
ddev exec php bin/console app:approve-catalog-entries --series="Micky Maus" --limit=200
# 4. In den Bestand überführen
ddev exec php bin/console app:transfer-catalog-entries --series="Micky Maus" --dry-run
ddev exec php bin/console app:transfer-catalog-entries --series="Micky Maus"
app:approve-catalog-entries nimmt standardmäßig nur PARSED. NEEDS_REVIEW kommt nur
mit --status=NEEDS_REVIEW dran, --status=alle deckt beides plus NEW ab. Ohne
--dry-run fragt der Befehl vor dem Schreiben nach; --no-interaction überspringt das.
Wird die Grenze ausgeschöpft, sagt der Befehl das und man ruft ihn erneut auf.
Die Preise stehen nach dem Import in der Buchwährung (EUR, Kurs aus APP_RATE_DM,
Standard 1:1). Der Originalbetrag bleibt in rawText und der Umrechnungsvermerk in den
Notizen – siehe „Import rechnet immer in Euro".
Modellentscheidungen der Bestandsseite
ComicIssue ist bibliografisch, ComicBook physisch. Zwei Exemplare desselben
Hefts sind zwei ComicBook-Zeilen, ohne Sonderlogik.
- Konvolute führen Positionen statt JSON. „Heft 12 fehlt" ist über
ComicBookItem.status = MISSING abfragbar (findMissingBySeries()).
- Bereiche werden nur expandiert, wenn es eindeutig ist. „33-36" wird zu vier
Positionen; „1-2" bleibt ein Einzelheft mit komplexer Nummer; „1-5000" bleibt eine
Sammelposition mit Prüfhinweis. Grenze über
--expand-limit.
- Aus Bereichen entstehen keine Heft-Stammdaten. Abgeleitete Positionen haben
derivedFromRange = true und keine ComicIssue-Verknüpfung.
- Katalogpreise landen in
PriceObservation, nicht in estimatedValue. Ohne
Katalogdatum bleibt observedAt leer.
- DM-Vergleichswert 1:1. Der DM-Betrag bleibt unverändert als Beleg stehen; daneben
steht ein abgeleiteter Vergleichswert in EUR (
convertedAmount, conversionRate).
Kurs über APP_RATE_DM (Standard 1.0). Das ist bewusst keine Währungsumrechnung –
der amtliche Einführungskurs wäre 0.51129 (1 EUR = 1,95583 DM) und lässt sich
jederzeit eintragen; danach app:recalculate-price-conversions.
Medien: public/media-export/
Ablage unter public/media-export/<serie>/<inventarnummer>/<zufallsname>.<ext>, der
Originalname bleibt in der Datenbank. Dedupliziert über SHA-256 je Objekt, erlaubt sind
JPG, PNG, WebP, HEIC, TIFF und PDF – SVG bewusst nicht, weil es Skripte enthalten kann
und hier im Web-Root liegt.
Drei Punkte, die aus dieser Lage folgen:
- Kein Zugriffsschutz. Wer die URL hat, sieht die Datei. Für eine interne Sammlung
in Ordnung; falls die Anwendung je öffentlich erreichbar wird, ist das die Stelle,
an der ein Media-Controller nachgerüstet werden müsste.
- Deployment muss das Verzeichnis aussparen. Ein SFTP-Upload, der
public/
spiegelt, löscht sonst die Sammlung. public/media-export/ gehört auf die
Ausschlussliste des Deployments – bei der Release-Struktur aus der Spec also nach
shared/ und von dort verknüpft oder gemountet.
- Kein PHP im Verzeichnis. Eine
.htaccess unterbindet Ausführung und Directory
Listing (Apache). Unter nginx entspricht das einem location ^~ /media-export/ { }
ohne fastcgi_pass.
app:check-media zeigt Dateien ohne Datensatz und Datensätze ohne Datei – löscht aber
nichts von selbst.
Lagerwert und Wertübersicht
ComicBook und ComicBookItem haben jetzt stockValue + Währung, das Objekt
zusätzlich stockValuedAt. Maßgeblich ist, in dieser Reihenfolge:
- der am Objekt festgesetzte Lagerwert,
- sonst die Summe der Positionswerte (für Konvolute mit unterschiedlich wertvollen
Heften – erfassbar direkt in der Positionstabelle der Objektseite),
- sonst der Einkaufspreis als Ersatzwert.
Die Quelle wird überall mit angezeigt, damit ein übernommener Einkaufspreis nicht wie
eine Bewertung aussieht. Die Wertübersicht (/werte, auf der Startseite als
Vue-Komponente) summiert je Serie Einkauf, Schätzwert, Katalogwert und Lagerwert in
Euro. Leere Werte bleiben leer – „nicht erfasst" ist nicht „0,00". Beträge in einer
Währung ohne Kurs werden gezählt und angezeigt, nicht stillschweigend weggelassen.
Hinweis: Die Reihenfolge ist eine Arbeitsregel für die Sammlung, keine Bewertungsregel
im steuerlichen Sinn. Wird der Bestand bilanziert, legt das die Steuerberatung fest.
Verwaltung (Vue)
/verwaltung bearbeitet alle Entities über /api/admin/*. Welche Entities und Felder
bearbeitbar sind, steht ausschließlich in src/Admin/EntityRegistry.php –
Beleg-Felder (Inventarnummer, Rohtext, Katalogbetrag) sind readonly, neue Felder
erscheinen im Editor ohne Frontend-Änderung. Gespeichert wird nur, was sich geändert
hat (PATCH mit den geänderten Feldern).
Sicherheit: /verwaltung verlangt wie alles außer /login ein Konto
(security.yaml, siehe „Anmeldung"). Schreibende API-Aufrufe brauchen zusätzlich das
CSRF-Token, damit sie nicht von einer fremden Seite ausgelöst werden können.
Löschfolgen
Bevor der Editor einen Datensatz löscht, zeigt er, was das nach sich zieht -
App\Admin\DeleteImpact liest dafür die Doctrine-Metadaten statt einer eigenen Liste,
neue Entities und Beziehungen sind also ohne Zutun erfasst. Drei Fälle:
- wird mitgelöscht (Kaskade in der Datenbank oder
cascade: remove /
orphanRemoval im ORM) - z. B. die Positionen eines Objekts
- verliert den Verweis (
SET NULL) - der Datensatz bleibt, z. B. ein
Katalogeintrag, dessen Objekt gelöscht wird
- blockiert das Löschen (
RESTRICT bzw. kein onDelete) - z. B. eine Serie mit
Objekten im Bestand
GET /api/admin/{typ}/{id}/loeschfolgen liefert die Liste, der Editor zeigt sie beim
Öffnen eines Datensatzes und nochmals in der Rückfrage vor dem Löschen; ist das Löschen
blockiert, ist der Knopf deaktiviert. Das DELETE selbst prüft dieselbe Liste vorab
noch einmal serverseitig - SQLite und mancher Webspace prüfen Fremdschlüssel nicht,
ohne die Vorabprüfung bliebe dort ein Verweis ins Leere stehen.
Schnelleingabe: Zeilen direkt einfügen
Im Reiter „Katalogeinträge" gibt es „+ Zeilen eingeben": eine Serienzeile, darunter
Heft Zustand Preis je Zeile, genau wie in den historischen Listen -
Sigurd/GB
72 1 8.00
77 3-4 N 4,00
Läuft durch dieselben Regeln wie der Datei-Import (CatalogParserService,
CatalogLineInspector) und über denselben CatalogImporter: Eine Vorschau zeigt beim
Tippen, was erkannt wird und was zur Prüfung geht, „Anlegen" speichert per
Hash-Abgleich - wer denselben Block zweimal abschickt, bekommt „schon vorhanden" statt
Dubletten. Für einzelne Objekte oder Katalogeinträge bleibt die Schnellerfassung in der
Kopfzeile (+ Objekt, + Eintrag) der kürzere Weg.
Katalog-Import: Text und CSV
app:import-catalog liest jetzt zwei Formate; das Command selbst macht nur noch Ein- und
Ausgabe:
Datei ─► CatalogReaderRegistry ─► TextCatalogReader | CsvCatalogReader
└─► CatalogParseResult (formatunabhängig)
─► CatalogImporter ─► CatalogLineInspector (Prüfregeln) ─► CatalogEntry
ddev katalog var/imports/comic_collection_import_eur.csv --source "OCR-Liste 2026" --dry-run --show-review
ddev katalog var/imports/beispiel-katalog.txt --format txt
CSV-Spalten (Groß-/Kleinschreibung egal, Komma, Semikolon oder Tab):
title|serie, issue|heft|nr, condition|zustand, source_price_dm|preis_dm,
price_eur|preis_eur oder price + currency, optional stock|bestand, year_volume|jahrgang,
ocr_uncertain, ocr_notes.
Regeln, die bewusst so sind:
- Der DM-Betrag ist das Original. Steht
source_price_dm in der Datei, wird er mit DM
gespeichert; der EUR-Wert der Tabelle (schon mit 1,95583 gerechnet) nur, wenn kein
DM-Betrag da ist. Der Vergleichswert entsteht wie bisher über APP_RATE_DM.
rawText ist die unveränderte CSV-Zeile, die Zeilennummer die physische Zeile der Datei.
stock = Anzahl Einträge. Drei Exemplare ergeben drei Einträge und später drei
Objekte. Exemplar 1 behält den bisherigen Hash, frühere Importe bleiben also erkannt.
Micky Maus/70 wird zu Serie Micky Maus, Jahrgang 70 - wie beim Serienkopf im
Textimport. Akim-NA/Pic bleibt ganz.
- OCR-Notizen werden nur übernommen, wenn sie aus Katalogkürzeln bestehen (
N, S,
oS, (B)). 68 oder gel bleiben unangetastet im Rohtext.
Prüfregeln (CatalogLineInspector, gleich für Text und CSV) - nichts wird verworfen,
Auffälliges geht mit Grund in die Notizen und nach NEEDS_REVIEW:
| Grund |
Beispiel |
| Serienname unleserlich |
! 8-8 - mit Hinweis auf die lesbaren Serien davor und danach |
| Heftnummer unplausibel |
!, S, 2- |
| Zustand unplausibel |
885, 1-2681, o.Rs., 9-1, und umgekehrte Bereiche wie 2-1 |
| OCR unsicher |
ocr_uncertain = yes, ggf. mit Notiz |
| keine Serie, Preis fehlt, Bestand 0 |
|
Die Liste /catalog zeigt bei zu prüfenden Einträgen den Befund unter der Originalzeile.
Mit der OCR-Liste vom September: 4.468 Zeilen, 601 zur Prüfung (499 OCR unsicher,
89 Serie unleserlich, 62 Zustand, 14 Heftnummer).
Der Parser zieht für eine fehlende Serie nichts mehr ab - das macht jetzt der Inspector,
sonst würde doppelt abgezogen.
Katalog-PDF
/katalog-pdf verwaltet gespeicherte Zusammenstellungen. Gespeichert wird die
Auswahl, nicht das PDF: Jeder Abruf erzeugt den Katalog aus dem aktuellen Bestand.
Auswahl, Texte und Titelbild lassen sich im Baukasten auf der Startseite (Vue) pflegen;
erzeugt wird weiter unter /katalog-pdf - Vorschau und Download laufen serverseitig
und brauchen kein JavaScript.
- Auswahl: ganze Serien, einzelne Objekte und einzelne Katalogeinträge. Serien- und
Objektauswahl wirken zusammen; alles leer bedeutet „der ganze Bestand". Für Serien
gibt es Checkboxen mit Objektzahl, Suchfeld, „alle sichtbaren"/„keine",
„leere ausblenden" - eine leere Auswahl bleibt vollständig, auch wenn später neue
Serien dazukommen.
- Katalogeinträge kommen als eigener Angebotsteil hinter die Bestandslisten, mit
Quelle und Vermerk „ungeprüft", wo der Eintrag noch nicht freigegeben ist.
- Titelblatt: Titel, Untertitel, Absender (mehrzeiliger Briefkopf wie bei den
alten Listen), Stand (leer = Tag der Erzeugung), Einleitung, Titelbild, Zahlen,
Zeichenerklärung.
- Einleitung über einen kleinen HTML-Editor (
RichTextEditor.js): Fett, Kursiv,
Unterstrichen, zwei Überschriftenstufen, Absatz, Zitat, zwei Listenarten, Link.
Eingefügter Text kommt immer als reiner Text an - eingefügtes HTML aus Word oder einer
Webseite brächte sonst fremde Schriften, Farben und Tabellen mit. Gespeichert wird
serverseitig bereinigt (App\Text\IntroductionHtml): Nur die genannten Tags bleiben
stehen, <script>, Ereignis-Attribute (onclick …) und javascript:-Links fallen
weg, echte http(s)- und mailto-Links bleiben erhalten. Alte Einleitungen aus
reinem Text werden weiter als Absätze gesetzt, das Editor-Feld bleibt ohne JavaScript
ein normales Textfeld.
- Titelbild als JPG, PNG oder PDF, wahlweise ganzseitig auf einer eigenen
Seite vor dem Titelblatt (nur JPG/PNG - ein PDF-Titelblatt steht ohnehin auf eigenen
Seiten). Ein PDF-Titelbild wird nicht eingebettet, sondern dem Katalog vorangestellt -
ein fertig gestaltetes Titelblatt bleibt damit exakt erhalten. Dafür braucht es
setasign/fpdi; fehlt das Paket, wird der Katalog ohne Titelblatt ausgeliefert und
die Oberfläche sagt warum. JPG/PNG liegen unter
public/media-export/_katalog-pdf/<id>/, werden beim Hochladen (mit ext-gd) nach
EXIF gedreht und auf 2000 px verkleinert und kommen als data:-URI ins PDF - Dompdf
braucht damit weder Netzwerk noch Dateizugriff außerhalb des Projekts.
app:check-media kennt diese Dateien.
- Optionen: A4/A5, Preise (Lagerwert nach
StockValueResolver), Zustand,
Inventarnummern, fehlende Hefte bei Konvoluten, Legende, Übersichtsseite (listet vor
den Tabellen jeden Abschnitt mit Objekt-, Heft- und Betragszahlen, Bestand und
Katalogeinträge getrennt).
- Abschnitte je Serie und Jahrgang (
Micky Maus/69 und /70 getrennt), Hefte nach
issueSortKey, Summe je Serie und Gesamtsumme. Läuft eine Serie über den
Seitenumbruch, steht ihr Name oben auf der neuen Seite noch einmal.
- Aktionen: PDF ansehen, herunterladen, HTML-Vorschau, als Kopie speichern, löschen.
Löschen entfernt Zusammenstellung und Titelbild, nie den Bestand.
Dompdf (reines PHP, passt zum SFTP-Deployment). Schrift ist Courier als
PDF-Grundschrift: nichts wird eingebettet, Umlaute gehen. Gemessen mit 4.468 Zeilen in
262 Abschnitten: 97 Seiten A4, rund 22 s, 370 MB Spitze, 440 KB Datei. Der Renderer hebt
memory_limit bei Bedarf auf 768 MB und das Zeitlimit auf 300 s. Für den Gesamtkatalog
ist die Konsole bequemer:
ddev console app:catalog-pdf # Liste mit Ids
ddev console app:catalog-pdf <id> -o var/katalog.pdf
Import rechnet immer in Euro
Beim Import wird jeder Preis in die Buchwährung umgerechnet und als EUR gespeichert.
So lassen sich Einträge sortieren, filtern und summieren, ohne bei jeder Abfrage einen
Kurs anzuwenden. Verloren geht dabei nichts: die Katalogzeile steht unverändert in
rawText, und in der Notiz steht ein Vermerk wie „Originalpreis 25,00 DM, übernommen
als 25,00 EUR (Kurs 1:1)".
Der Kurs kommt weiter aus APP_RATE_DM (Standard 1:1 – eine Vergleichskonvention,
keine amtliche Umrechnung; amtlich wäre 0.51129). Eine Währung ohne hinterlegten Kurs
wird nicht umgerechnet: der Betrag bleibt stehen und der Eintrag landet als
NEEDS_REVIEW in der Prüfliste, statt eine Zahl zu erfinden.
Noch offen
Person (Zeichner, Texter, Übersetzer) fehlt noch, ebenso eine serienübergreifende
Suche und der CSV-/JSON-Export aus der MVP-Liste. Katalogdateien lassen sich noch nicht
über die Weboberfläche hochladen - der CatalogImporter ist dafür vorbereitet. Im
Katalog-PDF fehlt ein Inhaltsverzeichnis mit Seitenzahlen (Dompdf kennt die Seiten erst
nach dem Umbruch).