# SuperSeller3000 — Design-Dokument **Datum:** 2026-05-13 · **Zuletzt aktualisiert:** 2026-05-17 **Status:** In Betrieb (ss3k.schaunwama.de) **Kontext:** Middleware zur Artikelverwaltung, KI-gestützten Erfassung und Multi-Plattform-Verkauf von refurbished IT-Hardware. --- ## 1. Projektziel & Kontext SuperSeller3000 ist die **führende Schaltzentrale** für den Verkauf von refurbished IT-Hardware. Alle externen Systeme (eBay, Frappe ERP) sind Werkzeuge — die Middleware ist Master. **Betrieb heute:** Dropshipping. Lieferant erhält Rechnung per Mail und versendet manuell. Eigener Versand folgt später. **Team:** 2–3 Mitarbeiter **Artikel:** ~500 heute, Ziel ~5.000 **Plattformen:** eBay (initial), weitere folgen (Amazon, Kaufland, …) --- ## 2. Architektur ### 2.1 Hexagonale Architektur (Ports & Adapters) ``` src/ Domain/ # Reines PHP — Article, ArticleType, Order, Customer … # Keine Framework-Imports, vollständig unit-testbar Application/ # UseCases, Commands, Handlers # Orchestriert Domain über Interfaces (Ports) Infrastructure/ Channel/ # EbayAdapter, FrappeErpAdapter, [FutureAdapter] AI/ # OllamaClient, MistralClient (beide hinter OllamaClientInterface) # OllamaVisionAgent, SpecsResearchAgent, JsonCodingAgent, EbayTextAgent Persistence/ # Doctrine Repositories (PostgreSQL) Logging/ # DatabaseLogHandler, ArchiveCommand Http/ # Symfony Controller, Webhook-Listener, EasyAdmin Queue/ # Symfony Messenger, Redis Transport Storage/ # StorageManager ``` Jeder externe Dienst (eBay, Frappe ERP, Ollama, SMTP) implementiert ein Interface aus dem Application-Layer. Neue Plattform = neue Adapter-Klasse, Domain bleibt unberührt. ### 2.2 Tech-Stack | Bereich | Technologie | |---|---| | Sprache / Framework | PHP 8.4 · Symfony 8.4 | | ORM | Doctrine ORM | | Queue | Symfony Messenger + Redis Transport (`symfony/redis-messenger`) | | Mailer | Symfony Mailer (SMTP) | | Tests | PHPUnit 11 + Pest | | Datenbank | PostgreSQL 17 (eine Instanz, drei Schemas: `app`, `logs`, `logs_archive`) | | Cache / Queue | Redis 7 | | AI | **Ollama** (lokal, SSH-Tunnel + autossh) **oder Mistral Cloud API** — per Alias in `services.yaml` umschaltbar; Modelle via `AI_TEXT_MODEL` / `AI_VISION_MODEL` env vars konfigurierbar | | Admin-Panel | EasyAdmin 5 (`#[AdminDashboard]` Attribut, `type: easyadmin.routes` Loader) | | Auth | Symfony Security + scheb/two-factor-bundle (TOTP) | | API-Auth | API-Key (eigene Entity, bcrypt-Hash) + Symfony Voters; Keys via `app:api-keys:create` anlegen | | Reverse Proxy | Caddy 2 (Auto-HTTPS via Let's Encrypt, `php_fastcgi app:9000`) — Domain: `ss3k.schaunwama.de` | | Containerisierung | Docker Compose (VPS) — `app`, `caddy`, `postgres`, `redis`, `worker-ai`, `worker-orders`, `worker-channel`, `cron` | | Versionskontrolle | Gitea (self-hosted) + Gitea Actions (CI) | | Backups | pg_dump täglich, Docker-Volume-Backup, gitea dump | ### 2.3 Async Queue-Architektur Drei isolierte Transports — ein ausgefallener Worker blockiert die anderen nie: ``` redis://...?queue_name=ai_pipeline # AI-Agents (Vision, Specs, JSON, Text) redis://...?queue_name=orders # Order-Processing, CustomerResolver, Invoice redis://...?queue_name=channel_sync # Listing-Publish, Bestand-Sync, Deaktivierung, Tracking ``` **Ollama-Ausfall:** AI-Pipeline-Jobs verbleiben in der Queue. Symfony Messenger wiederholt mit Backoff, nach 3 Versuchen → Failed Transport (persistent, nicht verloren). Operativer Betrieb (Verkäufe, Rechnungen, Bestand) läuft völlig unabhängig durch. Replay via `messenger:failed:retry` wenn Ollama wieder verfügbar. --- ## 3. Domain-Modell ### 3.1 Artikel & Mapping ``` ArticleType id: UUID (stabil) name: string (änderbar) → AttributeDefinition[] AttributeDefinition id: UUID (stabil) name: string (änderbar) type: string|int|float|bool|select|multi_select unit: string? options: string[]? Platform id: UUID type: string label: string config: jsonb ChannelField # Registry aller Plattform-Felder id: UUID (stabil) platform_id label: string (änderbar) path: string # z.B. path = "ItemSpecifics[name=Prozessor]" für eBay ArticleTypePlatformConfig # 1× pro ArticleType × Platform article_type_id platform_id category_id: string # eBay-Kategorie-ID, ERP-Warengruppe etc. → AttributeMapping[] AttributeMapping attribute_definition_id channel_field_id transformer: string? # z.B. "kg_to_g" ``` **Stabilitätsprinzip:** Alle Mappings referenzieren UUIDs, nie Strings. Name/Label eines Attributs oder ChannelFields kann jederzeit geändert werden ohne ein Mapping zu brechen. ### 3.2 Artikel ``` Article id: UUID article_type_id sku: string inventory_number: string status: ingesting|draft|needs_review|active|listed|sold stock: int condition: new|like_new|good|acceptable condition_notes: text? # Freitext, z.B. "leichte Kratzer Unterseite" listing_price: decimal? # eBay-Verkaufspreis serial_number: string? ebay_listing_id: string? ebay_title: string? # AI-generiert, editierbar vor Freigabe ebay_description: text? # AI-generiert, editierbar vor Freigabe → AttributeValue[] → ArticlePhoto[] AttributeValue article_id attribute_definition_id value: string # Typ-Cast beim Lesen anhand AttributeDefinition.type ArticlePhoto id: UUID article_id storage_path_id filename: string is_main: bool sort_order: int # Voller Pfad = StoragePath.base_path + '/' + filename StoragePath id: UUID label: string base_path: string quota_bytes: bigint priority: int is_active: bool ``` **StorageManager:** Wählt aktiven Pfad nach Priorität, prüft Quota vor jedem Write, fällt auf nächsten zurück. Migration: nur `base_path` ändern, alle Referenzen lösen automatisch korrekt auf. **Artikel-Status-Maschine:** ``` ingesting → draft → active → listed → sold ↓ needs_review (AI-Fehler nach 3 Versuchen) ``` ### 3.3 Bestellungen & Kunden ``` Customer id: UUID name email address: jsonb frappe_customer_id: string? platform_ids: jsonb # {"ebay": "user123", "amazon": "buyer456"} # Frappe-Seite: Custom Fields superseller_customer_id + platform-IDs Order id: UUID article_id customer_id platform_id platform_order_id: string # eBay Order ID status: pending|processing|shipped|completed|failed sale_price: decimal sale_date: datetime tracking_number: string? carrier: string? shipped_at: datetime? tracking_pushed_to_ebay_at: datetime? → Invoice? Invoice id: UUID order_id frappe_invoice_id: string storage_path_id filename: string # PDF created_at: datetime emailed_at: datetime? ``` ### 3.4 AI-Pipeline ``` AIPipelineJob id: UUID type: photo|pxe|text_gen article_id: UUID? # gesetzt nach Draft-Anlage status: queued|processing|completed|failed|needs_review attempt_count: int # max. 3 input_data: jsonb # Foto-Pfad oder PXE-Dump output_data: jsonb # extrahierte Attribute missing_fields: text[] # für Retry-Prompt-Kontext (PostgreSQL text[]) error_message: string? created_at: datetime completed_at: datetime? ``` ### 3.5 Auth ``` User id: UUID email password_hash totp_secret: string? # 2FA (scheb/two-factor-bundle) permissions: jsonb # article:view, order:edit, log:delete … is_active: bool ApiKey id: UUID user_id label: string key_hash: string # bcrypt — nie Klartext in DB permissions: jsonb # eigenes, unabhängiges Permission-Set is_active: bool last_used_at: datetime? expires_at: datetime? ``` **PermissionVoter:** Eine einzige Voter-Klasse prüft User- und ApiKey-Permissions einheitlich. API-Keys sind für Browser-Login ohne 2FA, da der Key selbst ein starkes Credential ist. --- ## 4. Artikel-Erfassung: AI-Pipelines ### 4.1 Pipeline A — Foto (Typenschild) ``` 1. Foto-Upload → PhotoUploadMessage in Queue (redis://ai_pipeline) 2. OllamaVisionAgent — LLaVA liest Typenschild Output: Modellbezeichnung + Seriennummer (nur was sichtbar) 3. SpecsResearchAgent — Web-Suche mit Modellbezeichnung → vollständige Specs (Freitext) Web-Suche ist Pflicht (kein reines LLM-Wissen — zu unzuverlässig für Hardware) 4. JsonCodingAgent — strukturierter Ollama-Call: Specs-Text → JSON gegen ArticleType-Schema 5. ValidationGate — alle Pflichtfelder gesetzt? (Schema + eBay-Kategorie-Pflichtfelder) ✓ → Schritt 6 ✗ → Retry ab Schritt 4 mit missing_fields im Prompt (max. 3×) → needs_review nach 3 Fehlversuchen 6. DraftArticleCreator — Article anlegen (status: draft), Inventurnummer vergeben 7. EbayTextAgent — Titel + Beschreibung aus Attributen generieren, am Artikel speichern 8. → Freigabe-Queue (manuell) ``` ### 4.2 Pipeline B — PXE-Inventur ``` 1. Gerät bootet per PXE → sendet lshw/dmidecode-Dump an API 2. API vergibt Inventurnummer → PxeInventoryMessage in Queue 3. JsonCodingAgent — PXE-Dump enthält vollständige Rohdaten → direkt JSON-Mapping (SpecsResearchAgent entfällt) 4. ValidationGate — identisch zu Pipeline A 5. DraftArticleCreator — Inventurnummer bereits bekannt 6. EbayTextAgent — identisch 7. → Freigabe-Queue (manuell) Mitarbeiter klebt Inventurnummer-Aufkleber auf Gerät während Pipeline läuft ``` ### 4.3 Freigabe Mitarbeiter öffnet Draft im Admin-Panel → prüft Attribute, Fotos, Preis, `condition_notes`, eBay-Texte → gibt frei. Bei Freigabe: `status → active` → `PublishToChannelMessage` → eBay-Listing anlegen + Frappe-ERP-Sync. --- ## 5. Channel-Adapter Jede Plattform implementiert `ChannelAdapterInterface`: ```php interface ChannelAdapterInterface { public function publishListing(Article $article): string; // → platform_listing_id public function updateStock(Article $article, int $stock): void; public function deactivateListing(Article $article): void; public function pushTracking(Order $order): void; } ``` Neue Plattform = neue Implementierung. Domain, Order-Flow und Inventory-Logik bleiben unverändert. **eBay-spezifisch:** eBay Taxonomy API wird beim Anlegen einer `ArticleTypePlatformConfig` abgefragt — Pflichtfelder der gewählten Kategorie werden als vorgeschlagene Mappings vorgeladen. --- ## 6. Order-Flow ### 6.1 eBay-Webhook-Registrierung eBay Notification API — kein Polling. Registrierte Events: - `FIXED_PRICE_TRANSACTION` - `AUCTION_CHECKOUT_COMPLETE` - `MARKETPLACE_ACCOUNT_DELETION` (eBay-Pflicht) ### 6.2 Ablauf ``` 1. Webhook POST /webhooks/ebay → HMAC-Signatur prüfen → sofort 200 → OrderReceivedMessage 2. InventoryLock ATOMAR: UPDATE article SET stock = stock - 1 WHERE id = ? AND stock > 0 0 Zeilen → Überverkauf → Order: failed, Alert, manuell klären Erfolg → weiter 3. CustomerResolver — Matching-Kaskade (siehe 6.3) 4. InvoiceCreator — Sales Invoice in Frappe ERP via REST anlegen (Frappe ERP wird nur hier involviert — rein reaktiv) 5. InvoicePdfFetcher — PDF von Frappe abrufen → StorageManager → Invoice-Record 6. InvoiceMailer — PDF per SMTP an feste Lieferanten-Adresse → Invoice.emailed_at setzen 7. ListingSync — stock > 0: Bestände auf allen Plattformen aktualisieren stock = 0: DeactivateListingsMessage pro Plattform in Queue → Retry mit Exponential-Backoff bei API-Fehler → Alert nach 5 Fehlversuchen (kritisch: kein Überverkauf) 8. Abschluss — Order: completed, Article: sold (wenn stock = 0) ``` **Tracking (Lieferant meldet zurück):** `PATCH /orders/{id}/tracking` → `tracking_number` + `carrier` → `TrackingPushMessage` → alle Plattformen als versandt markieren → `tracking_pushed_to_ebay_at` setzen. ### 6.3 Customer-Matching-Kaskade **Stufe 1 — Platform-ID (exakt):** `platform_ids->>'ebay' = ?` → Match: direkt verwenden. **Stufe 2 — Adresse (exakt, cross-platform):** `lowercase(name + straße + ort + plz)` — 100% exakter Vergleich, keine Fuzzy-Logik. Match → selber Kunde; neue Platform-ID sofort in `platform_ids` eintragen (nächster Sale trifft Stufe 1). Kein Match → neuer Kunde anlegen. **Regel:** `lowercase` ist die einzige Toleranz. "Kirchstr 1" ≠ "Kirchstraße 1" → zwei Kunden. Datenmischung ist schlimmer als ein Duplikat. Manuelle Zusammenführung folgt als spätere Admin-Funktion. **Neu-Anlage:** Frappe-ERP-Kunde erstellen mit Custom Fields (`superseller_customer_id`, `ebay_user_id`) → `frappe_customer_id` in Middleware zurückschreiben. --- ## 7. Logging ``` Schema: logs.log_entry id level channel message context: jsonb message_search: tsvector # PostgreSQL GIN-Index → Fulltext-Suche created_at Schema: logs_archive.log_entry # identische Struktur ``` **Monolog Handler:** Custom `DatabaseLogHandler` schreibt alle Levels in `logs.log_entry`. **Rotation (täglich per Cron):** 1. Einträge > 90 Tage mit `level > DEBUG` → `logs_archive.log_entry` kopieren 2. Alle Einträge > 90 Tage aus `logs.log_entry` löschen **Admin-Panel (EasyAdmin):** Filter auf Level/Channel/Zeitraum, Fulltext-Suche, ACL-gesteuert (view/delete). --- ## 8. Sicherheit & Hardening ### VPS & Netzwerk - **UFW:** nur Port 80, 443 (Caddy) + SSH öffentlich - **SSH:** Key-only-Auth, Root-Login deaktiviert, Fail2ban - **Docker-Netz:** internes Bridge-Network — PostgreSQL, Redis nie direkt nach außen - **Caddy:** HSTS, X-Content-Type-Options, X-Frame-Options per Default ### Dienste - **PostgreSQL:** App-User mit minimalen Rechten (kein Superuser) - **Redis:** `requirepass` gesetzt, nur intern erreichbar - **Ollama:** lokal, Zugriff nur via SSH-Tunnel (autossh für Persistenz + Auto-Reconnect) - **Gitea:** Registrierung deaktiviert, nur Admin-Anlage, SSH-Key-Auth ### Applikation - **Secrets:** nie in Git — `.env.local` auf Server oder Docker Secrets; `.env` ohne Credentials - **APP_ENV:** `prod`, Debug-Modus aus - **API-Keys:** in DB als bcrypt-Hash, nie Klartext - **eBay-Webhook:** HMAC-Signatur auf jedem Request prüfen vor Queue-Einspeisung - **Rate Limiting:** Caddy oder Symfony RateLimiter auf API-Endpoints + Webhook-Listener - **Frappe-ERP:** API-Token im Secret-Store, HTTPS-only, TLS-Zertifikat validieren - **2FA:** scheb/two-factor-bundle (TOTP) für alle Browser-Logins, Backup-Codes ### Backups (täglich) - PostgreSQL: verschlüsselter `pg_dump` → externer Storage - Docker-Volumes: Rechnungs-PDFs + Artikel-Fotos - Gitea: `gitea dump` per Cron --- ## 9. Deployment ``` docker-compose.yml app # PHP 8.4-FPM + Symfony caddy # Reverse Proxy + Auto-HTTPS postgres # PostgreSQL 17 redis # Queue + Cache worker-ai # Symfony Messenger Worker (ai_pipeline) worker-orders # Symfony Messenger Worker (orders) worker-channel # Symfony Messenger Worker (channel_sync) cron # Symfony Console Commands (Log-Rotation, Backups, pg_dump) gitea # Source Control + CI act-runner # Gitea Actions Runner ``` **Gitea Actions CI:** Auf jedem Push — PHPUnit/Pest, PHPStan, PHP CS Fixer. --- ## 10. Qualität - **TDD:** Tests werden vor oder parallel zur Implementierung geschrieben, kein Code ohne Test - **PHPStan:** Level max, im CI erzwungen - **PHP CS Fixer:** einheitlicher Code-Style, im CI erzwungen - **Doku:** wird laufend gepflegt (OpenAPI für die REST-API, ADRs für Architekturentscheidungen) - **Domain vollständig unit-testbar:** keine Framework-Abhängigkeiten im Domain-Layer - **Adapter isoliert testbar:** jeder Channel-Adapter, jeder AI-Agent hat eigene Tests gegen Mocks --- ## 11. Betrieb & Setup ### 11.1 Admin-Panel URL: `https://ss3k.schaunwama.de/admin` Login: `https://ss3k.schaunwama.de/login` (Formular-Login + TOTP falls aktiviert) Passwort ändern: `/account/password` **Ersten User anlegen:** ```bash docker compose exec app php bin/console app:users:create --env=prod ``` **Artikel-Typen & Merkmale:** 1. Admin → **Attributes** → neues Merkmal anlegen (Name, Typ, Einheit, Optionen) 2. Admin → **Article Types** → neuen Typ anlegen → Merkmale per Autocomplete zuweisen ### 11.2 API-Keys API-Keys werden über die Console generiert (nie über die UI — der Klartext-Key wird nur einmal angezeigt): ```bash docker compose exec app php bin/console app:api-keys:create --env=prod ``` Der Key wird als bcrypt-Hash gespeichert. Prefix (erste 8 Zeichen) dient als Lookup-Key. Verwendung: `X-Api-Key: ` HTTP-Header. ### 11.3 AI-Backend wechseln **Ollama (Standard, lokal):** ```yaml # config/services.yaml App\Infrastructure\AI\OllamaClientInterface: alias: App\Infrastructure\AI\OllamaClient ``` ```dotenv # .env AI_TEXT_MODEL=${OLLAMA_TEXT_MODEL} AI_VISION_MODEL=${OLLAMA_VISION_MODEL} ``` **Mistral Cloud API:** ```yaml App\Infrastructure\AI\OllamaClientInterface: alias: App\Infrastructure\AI\MistralClient ``` ```dotenv AI_TEXT_MODEL=${MISTRAL_TEXT_MODEL} AI_VISION_MODEL=${MISTRAL_VISION_MODEL} MISTRAL_API_KEY=sk-... ``` Vision erfordert Pixtral (`pixtral-12b-2409`). Nach Änderung: `cache:clear` + FPM reload. ### 11.4 Pipeline starten Voraussetzungen: mind. 1 Article Type + mind. 1 API-Key vorhanden, Worker laufen. ```bash # Photo-Pipeline curl -X POST https://ss3k.schaunwama.de/api/pipeline/photo-upload \ -H "X-Api-Key: " \ -F "articleTypeId=" \ -F "photo=@/path/to/photo.jpg" # Job-Status abfragen curl https://ss3k.schaunwama.de/api/pipeline/jobs/ \ -H "X-Api-Key: " ``` --- ## Offene Punkte / Spätere Erweiterungen - Eigener Versand (Versandlabel-Generierung, weiterer Workflow-Schritt) - Weitere Verkaufsplattformen (Amazon, Kaufland) — nur neuer Channel-Adapter nötig - SpecsResearchAgent: konkrete Web-Such-Strategie definieren (SerpAPI, direkte Hersteller-Seiten, o.ä.) - Manuelle Kunden-Zusammenführung als Admin-Funktion - eBay-Promotions / Preisanpassungen - `ebay_title` / `ebay_description` sind aktuell eBay-spezifisch auf `Article` — bei weiteren Plattformen zu `platform_texts: jsonb` generalisieren