480 lines
18 KiB
Markdown
480 lines
18 KiB
Markdown
|
|
# 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: <rawKey>` 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: <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
|
|||
|
|
- 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
|