SuperSeller3000/docs/superpowers/specs/2026-05-13-superseller3000-design.md
Simon Kuehn f0d9f374e6 docs: add README and update design doc for post-plan features
New README.md covers quick start, tech stack, running tests, AI backend
switching, admin features, and architecture overview.

Design doc updated (2026-05-18): Tavily replaces SerpAPI, specs_text
field, pipeline model cache step, findExistingCustomer in order flow,
required-attribute UX, user permissions UI, article detail-first nav,
updated open items.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 17:41:33 +00:00

20 KiB
Raw Blame History

SuperSeller3000 — Design-Dokument

Datum: 2026-05-13 · Zuletzt aktualisiert: 2026-05-18
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: 23 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
Web-Suche Tavily API (TAVILY_API_KEY) — liefert strukturierte Suchergebnisse für SpecsResearchAgent
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
  specs_text: text?                   # Freitext-Specs vom SpecsResearchAgent; wird als ERP-Rechnungsposition genutzt
  → 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: Hersteller, Modellname, Modellnummer, Seriennummer
3. Model-Cache-Check   — findCompletedByModelNumber() in DB
                          Treffer → copy ebayTitle/ebayDescription/specsText/attributes → Schritt 6 (kein AI mehr)
                          Kein Treffer → weiter mit Schritt 4
4. SpecsResearchAgent  — Tavily-Suche mit Modellbezeichnung → vollständige Specs (Freitext)
                          Pflichtfeld-Liste kommt aus ArticleType.AttributeDefinitions ({{fields}}-Platzhalter im Prompt)
5. JsonCodingAgent     — strukturierter Ollama-Call: Specs-Text → JSON gegen ArticleType-Schema
6. ValidationGate      — alle Pflichtfelder gesetzt? (Schema + eBay-Kategorie-Pflichtfelder)
                          ✓ → Schritt 7
                          ✗ → Retry ab Schritt 5 mit missing_fields im Prompt (max. 3×)
                              → needs_review nach 3 Fehlversuchen
7. DraftArticleCreator — Article anlegen (status: draft), Inventurnummer vergeben
8. EbayTextAgent       — Titel + Beschreibung aus Attributen generieren, am Artikel speichern
9. → 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 → activePublishToChannelMessage → eBay-Listing anlegen + Frappe-ERP-Sync.


5. Channel-Adapter

Jede Plattform implementiert ChannelAdapterInterface:

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}/trackingtracking_number + carrierTrackingPushMessage → 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: findExistingCustomer() prüft zuerst Frappe ERP nach Name + Adresse (zweistufig: Name-GET + Address-GET). Treffer → bestehende Frappe-ID verwenden. Kein Treffer → Frappe-ERP-Kunde anlegen 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 > DEBUGlogs_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:

docker compose exec app php bin/console app:users:create --env=prod

Artikel-Navigation: Klick auf eine Zeile in der Liste → Detail-Ansicht (read-only). Edit-Button in der Zeile und auf der Detail-Seite führt zum Formular.

Artikel-Typen & Merkmale:

  1. Admin → Attributes → neues Merkmal anlegen (Name, Typ, Einheit, Optionen, Pflichtfeld ja/nein)
  2. Admin → Article Types → neuen Typ anlegen → Merkmale per Autocomplete zuweisen
  3. Pflichtmerkmale werden im Artikel-Formular mit rotem * markiert und per Browser-Validierung erzwungen.

User-Berechtigungen: Admin → Users → Edit → Bereich „Permissions" — Checkboxen pro Berechtigung: ARTICLES_MANAGE, PIPELINE_RUN, ORDERS_MANAGE, USERS_MANAGE, PROMPTS_MANAGE, SETTINGS_MANAGE

Manuelle Erfassung über Admin: Admin → New Article → Artikel-Typ, Zustand, Anzahl (stock), Foto wählen → Pipeline startet automatisch.

11.2 API-Keys

API-Keys werden über die Console generiert (nie über die UI — der Klartext-Key wird nur einmal angezeigt):

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: <rawKey> HTTP-Header.

11.3 AI-Backend wechseln

Ollama (Standard, lokal):

# config/services.yaml
App\Infrastructure\AI\OllamaClientInterface:
    alias: App\Infrastructure\AI\OllamaClient
# .env
AI_TEXT_MODEL=${OLLAMA_TEXT_MODEL}
AI_VISION_MODEL=${OLLAMA_VISION_MODEL}

Mistral Cloud API:

App\Infrastructure\AI\OllamaClientInterface:
    alias: App\Infrastructure\AI\MistralClient
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.

# Photo-Pipeline
curl -X POST https://ss3k.schaunwama.de/api/pipeline/photo-upload \
  -H "X-Api-Key: <key>" \
  -F "articleTypeId=<uuid>" \
  -F "photo=@/path/to/photo.jpg"

# Job-Status abfragen
curl https://ss3k.schaunwama.de/api/pipeline/jobs/<jobId> \
  -H "X-Api-Key: <key>"

Offene Punkte / Spätere Erweiterungen

  • Eigener Versand (Versandlabel-Generierung, weiterer Workflow-Schritt)
  • Weitere Verkaufsplattformen (Amazon, Kaufland) — nur neuer Channel-Adapter nötig
  • 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
  • PXE-Pipeline: JsonCodingHandler überspringt SpecsResearch — vollständig implementiert aber noch nicht produktiv genutzt