SuperSeller3000/docs/superpowers/specs/2026-05-13-superseller3000-design.md
Simon Kuehn 371213dbbb docs: update design doc + add infrastructure runbook
- Remove Ollama as AI backend (never used); Mistral is primary throughout
- Add infrastructure.md: full Docker setup, queue architecture, prod
  deployment steps, env vars — prod has no staging ERP container

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 05:59:21 +00:00

484 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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/ # MistralClient hinter OllamaClientInterface (Interfacename historisch)
# VisionAgent, 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, Mistral, 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 | **Mistral Cloud API** — Vision: `pixtral-12b-2409`, Text: `mistral-large-latest`; 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
```
**AI-Backend-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 das AI-Backend wieder erreichbar ist.
---
## 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. VisionAgent — Mistral Pixtral 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 Mistral-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 → 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:** `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 > 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
- **Mistral API:** Key in `.env.local`, nie in Git; HTTPS-only
- **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-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):
```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: <rawKey>` HTTP-Header.
### 11.3 AI-Konfiguration
**Primär: Mistral Cloud API** (Standard, bereits so konfiguriert):
```yaml
# config/services.yaml
App\Infrastructure\AI\OllamaClientInterface:
alias: App\Infrastructure\AI\MistralClient
```
```dotenv
# .env.local
MISTRAL_API_KEY=sk-...
TAVILY_API_KEY=tvly-...
# Modelle in .env vorbelegt: mistral-large-latest / pixtral-12b-2409
```
Nach Schlüsseländerung: `docker compose exec app php bin/console cache:clear`.
### 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: <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