Compare commits

..

No commits in common. "6bd8e0bec846f74edb540657eb114e031b020f77" and "818c1ec8f7a93432c37c8cda1b8509d30e7aec7d" have entirely different histories.

27 changed files with 93 additions and 1875 deletions

107
CLAUDE.md
View file

@ -1,107 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
All PHP commands run inside Docker. The app container is named `app`.
```bash
# Unit tests
docker compose exec app php vendor/bin/phpunit tests/Unit/
# Single test file or method
docker compose exec app php vendor/bin/phpunit tests/Unit/Domain/Article/ArticleTest.php
docker compose exec app php vendor/bin/phpunit --filter testSomeMethod
# Integration tests (loads .env.local secrets automatically)
bin/test-integration
bin/test-integration tests/Integration/Channel/EbayAdapterTest.php
# PHPStan (level 9 — must be clean before committing)
docker compose exec app php vendor/bin/phpstan analyse
# CS Fixer (dry-run to check, no --dry-run to fix)
docker compose exec app php vendor/bin/php-cs-fixer fix --dry-run --diff
docker compose exec app php vendor/bin/php-cs-fixer fix
# Migrations
docker compose exec app php bin/console doctrine:migrations:migrate --no-interaction
docker compose exec app php bin/console doctrine:migrations:diff # generate from entity changes
# Cache
docker compose exec app php bin/console cache:clear
# Create first user / API key
docker compose exec app php bin/console app:users:create
docker compose exec app php bin/console app:api-keys:create
```
## Architecture
Hexagonal architecture: **Domain → Application → Infrastructure**. The boundary is enforced by convention and PHPStan.
- `src/Domain/` — pure PHP, zero framework imports. Entities, enums, value objects, repository *interfaces*. Doctrine attributes on entities are the pragmatic exception.
- `src/Application/` — use cases, message handlers, service interfaces (ports). Orchestrates domain via interfaces only.
- `src/Infrastructure/` — all framework/external-system code: Doctrine repositories, Symfony controllers, Messenger handlers, channel adapters, AI clients.
### Dependency Injection
Every Domain repository interface is aliased to its Doctrine implementation in `config/services.yaml`. All Application interfaces (ports) are aliased there too. When adding a new interface+implementation pair, add the alias manually — autowiring alone won't resolve interfaces.
Channel adapters are collected via `tagged_iterator app.channel_adapter` into `ChannelAdapterRegistry`. Tag new adapters in `services.yaml`.
### Routing (Symfony 8 gotcha)
`routing.controllers` auto-discovers all controllers. API controllers **must** declare the `/api` prefix in their class-level `#[Route]` attribute — a yaml `prefix:` on top of auto-discovery is silently ignored.
### AI Pipeline
The AI backend is `MistralClient` (vision: pixtral-12b, text: mistral-large). The interface is named `OllamaClientInterface` for historical reasons — the `services.yaml` alias points it to `MistralClient`. Web search uses `TavilyWebSearch` behind `WebSearchInterface`.
Pipeline A (photo) chains Messages sequentially — each handler dispatches the next:
`PhotoUpload → SpecsResearch → JsonCoding → Validation → DraftArticle → EbayText`
After vision, `findCompletedByModelNumber()` checks the DB for a cache hit and skips the remaining AI steps if found.
`PipelineJobFailureListener` catches `WorkerMessageFailedEvent` after all retries are exhausted and sets `AIPipelineJob.status = failed`.
### Messenger Transports
Three isolated Redis streams — a failing worker never blocks the others:
| Transport | Worker service | Retries | Delay |
|---|---|---|---|
| `ai_pipeline` | `worker-ai` | 3 | 2 s ×2 |
| `orders` | `worker-orders` | 5 | 1 s ×2 |
| `channel_sync` | `worker-channel` | 5 | 2 s ×2, max 60 s |
Exhausted messages land in `failed` transport (persistent). Replay with `messenger:failed:retry`.
### eBay Integration
`EbayAdapter` implements `ChannelAdapterInterface`. It uses:
- `EbayInventoryApiClient` — inventory items, offers, publish/withdraw, stock updates, tracking
- `EbayFulfillmentApiClient` — order fetching
- `EbayOAuthClient` — Client-Credentials token with `cache.app` caching
- `EbayTaxonomyService` — category/aspect lookup, also cached
`ArticleTypePlatformConfig` holds per-ArticleType eBay settings (category ID, business policy IDs). `ArticleTypeEbayMapping` maps each eBay aspect name to either an `Article` field (`SOURCE_ARTICLE_FIELD`) or an `AttributeDefinition` (`SOURCE_ATTRIBUTE`). `EbayAdapter.buildAspects()` reads from this table.
### EasyAdmin (Admin Panel)
`DashboardController` carries `#[AdminDashboard]` and `configureMenuItems()`. All CRUD controllers in `src/Infrastructure/Http/Controller/Admin/` are auto-discovered. Menu items reference controller class names directly via `MenuItem::linkTo()`.
### PostgreSQL Schemas
Three schemas: `app` (all entities), `logs` (live log entries), `logs_archive` (rotated). `doctrine.yaml` sets `schema_filter` to exclude `logs_archive.*` and `app.inventory_seq` from migration diffs — never remove that filter.
### Auth
Browser login: form + optional TOTP (`scheb/two-factor-bundle`). API access: `X-Api-Key` header — stored as bcrypt hash with prefix for lookup. `PermissionVoter` checks `User.permissions` and `ApiKey.permissions` (both jsonb) uniformly. Permission constants live in `PermissionVoter`.
### Docker
All PHP services (`app`, `worker-*`, `cron`) run as user `1000:1000` with `HOME=/tmp`. Never run commands as root inside the container — it creates root-owned files on the host.
`docker-compose.override.yml` exists only for local dev (exposes Postgres/Redis ports). Do not use it on the production server.

View file

@ -120,8 +120,6 @@ services:
$adapters: !tagged_iterator app.channel_adapter $adapters: !tagged_iterator app.channel_adapter
App\Infrastructure\Channel\Ebay\EbayAdapter: App\Infrastructure\Channel\Ebay\EbayAdapter:
arguments:
$marketplaceId: '%env(EBAY_MARKETPLACE_ID)%'
tags: ['app.channel_adapter'] tags: ['app.channel_adapter']
App\Infrastructure\Channel\Ebay\EbayOAuthClient: App\Infrastructure\Channel\Ebay\EbayOAuthClient:
@ -136,15 +134,6 @@ services:
$apiBaseUrl: '%env(EBAY_API_BASE_URL)%' $apiBaseUrl: '%env(EBAY_API_BASE_URL)%'
$marketplaceId: '%env(EBAY_MARKETPLACE_ID)%' $marketplaceId: '%env(EBAY_MARKETPLACE_ID)%'
App\Infrastructure\Channel\Ebay\EbayAccountApiClient:
arguments:
$apiBaseUrl: '%env(EBAY_API_BASE_URL)%'
$marketplaceId: '%env(EBAY_MARKETPLACE_ID)%'
App\Infrastructure\Channel\Ebay\EbayPolicyProvider:
arguments:
$cache: '@cache.app'
App\Infrastructure\Channel\Ebay\EbayTaxonomyService: App\Infrastructure\Channel\Ebay\EbayTaxonomyService:
arguments: arguments:
$apiBaseUrl: '%env(EBAY_API_BASE_URL)%' $apiBaseUrl: '%env(EBAY_API_BASE_URL)%'

View file

@ -1,268 +0,0 @@
# SuperSeller3000 — Plan 7: eBay Admin-Navigation & Business Policies
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Jeder Channel-Adapter bekommt einen eigenen Navigationsbereich im Admin-Panel. Für eBay werden alle Pflichtfelder für ein vollständiges Listing abgebildet: Die vier Business Policies (Fulfillment, Payment, Return, Merchant Location) werden direkt aus dem eBay-Account abgerufen und als Dropdown im Admin angeboten — keine manuelle ID-Eingabe.
**Auslöser:** `publishListing()` scheitert ohne `listingPolicies` + `merchantLocationKey` im Offer-Body. Außerdem ist `getCategoryId()` derzeit hardcoded auf `'177'` — der Adapter liest `ArticleTypePlatformConfig` noch gar nicht.
---
## Was ein vollständiges eBay-Listing braucht
| Feld | Quelle | Status |
|---|---|---|
| Titel, Beschreibung | `Article.ebayTitle/ebayDescription` | ✅ |
| Kategorie-ID | `ArticleTypePlatformConfig.categoryId` | ⚠️ vorhanden, aber Adapter liest es nicht |
| Condition, Fotos | `Article` | ✅ |
| Item Specifics (Aspects) | `ArticleTypeEbayMapping` | ✅ |
| Preis, Bestand | `Article.listingPrice/stock` | ✅ |
| **Fulfillment Policy ID** | eBay Account → `ArticleTypePlatformConfig` | ❌ fehlt |
| **Payment Policy ID** | eBay Account → `ArticleTypePlatformConfig` | ❌ fehlt |
| **Return Policy ID** | eBay Account → `ArticleTypePlatformConfig` | ❌ fehlt |
| **Merchant Location Key** | eBay Account → `ArticleTypePlatformConfig` | ❌ fehlt |
Business Policies werden einmalig im eBay Verkäuferkonto definiert und danach per ID referenziert. Der Admin ruft die Liste live ab und zeigt sie als Dropdown.
---
## Schritte
### 1. `ArticleTypePlatformConfig` — 4 neue Felder
- [ ] In `src/Domain/Channel/ArticleTypePlatformConfig.php` vier nullable String-Felder ergänzen:
```php
#[ORM\Column(type: 'string', length: 100, nullable: true)]
private ?string $fulfillmentPolicyId = null;
#[ORM\Column(type: 'string', length: 100, nullable: true)]
private ?string $paymentPolicyId = null;
#[ORM\Column(type: 'string', length: 100, nullable: true)]
private ?string $returnPolicyId = null;
#[ORM\Column(type: 'string', length: 100, nullable: true)]
private ?string $merchantLocationKey = null;
```
- [ ] Getter + Setter für alle vier Felder
---
### 2. `ArticleTypePlatformConfigRepositoryInterface` — neue Query-Methode
- [ ] In `src/Domain/Channel/Repository/ArticleTypePlatformConfigRepositoryInterface.php` ergänzen:
```php
public function findByArticleTypeAndPlatformType(ArticleType $articleType, string $platformType): ?ArticleTypePlatformConfig;
```
- [ ] Implementierung in `DoctrineArticleTypePlatformConfigRepository`:
```php
public function findByArticleTypeAndPlatformType(ArticleType $articleType, string $platformType): ?ArticleTypePlatformConfig
{
return $this->em->createQuery('
SELECT c FROM App\Domain\Channel\ArticleTypePlatformConfig c
JOIN c.platform p
WHERE c.articleType = :articleType AND p.type = :platformType
')
->setParameter('articleType', $articleType)
->setParameter('platformType', $platformType)
->getOneOrNullResult();
}
```
---
### 3. Migration
- [ ] `docker compose exec app php bin/console doctrine:migrations:diff` ausführen
- [ ] Generierte Migration prüfen — erwartet: 4 `ADD COLUMN ... nullable` auf `app.article_type_platform_configs`
- [ ] Dateipfad-Konvention: `migrations/Version2026MMDD000001.php`
---
### 4. `EbayAccountApiClient` (neu)
Neuer Client für die eBay Account API (`/sell/account/v1`). Gleiche Auth-Struktur wie `EbayInventoryApiClient`.
- [ ] `src/Infrastructure/Channel/Ebay/EbayAccountApiClient.php` erstellen:
```php
final class EbayAccountApiClient
{
private const ACCOUNT_BASE = '/sell/account/v1';
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly EbayOAuthClient $oauthClient,
private readonly string $apiBaseUrl,
private readonly string $marketplaceId,
) {}
/** @return list<array{fulfillmentPolicyId: string, name: string}> */
public function getFulfillmentPolicies(): array { ... }
/** @return list<array{paymentPolicyId: string, name: string}> */
public function getPaymentPolicies(): array { ... }
/** @return list<array{returnPolicyId: string, name: string}> */
public function getReturnPolicies(): array { ... }
}
```
Alle drei rufen `GET {BASE}/{resource}?marketplace_id={marketplaceId}` auf.
Response-Key: `fulfillmentPolicies` / `paymentPolicies` / `returnPolicies`.
- [ ] `EbayInventoryApiClient` um `getLocations()` erweitern:
```php
/** @return list<array{merchantLocationKey: string, name: string}> */
public function getLocations(): array
{
// GET /sell/inventory/v1/location
$data = $this->request('GET', self::INVENTORY_BASE.'/location', []);
return $data['locations'] ?? [];
}
```
- [ ] `services.yaml`: `EbayAccountApiClient` registrieren (gleiche Argumente wie `EbayInventoryApiClient`):
```yaml
App\Infrastructure\Channel\Ebay\EbayAccountApiClient:
arguments:
$apiBaseUrl: '%env(EBAY_API_BASE_URL)%'
$marketplaceId: '%env(EBAY_MARKETPLACE_ID)%'
```
---
### 5. `EbayPolicyProvider` (neu)
Service, der Policy-Listen für EasyAdmin-Formulare aufbereitet und cached.
- [ ] `src/Infrastructure/Channel/Ebay/EbayPolicyProvider.php` erstellen:
```php
final class EbayPolicyProvider
{
public function __construct(
private readonly EbayAccountApiClient $accountClient,
private readonly EbayInventoryApiClient $inventoryClient,
private readonly CacheInterface $cache,
) {}
/** @return array<string, string> Label => ID */
public function getFulfillmentChoices(): array { ... }
/** @return array<string, string> */
public function getPaymentChoices(): array { ... }
/** @return array<string, string> */
public function getReturnChoices(): array { ... }
/** @return array<string, string> Label => merchantLocationKey */
public function getLocationChoices(): array { ... }
}
```
- Label-Format: `"Policyname (ID)"` → Value: die ID
- Cache-Key pro Policy-Typ, TTL 300 s (`cache.app`)
- Bei Exception (API nicht erreichbar, fehlende Credentials): leeres Array zurückgeben — der CRUD-Controller behandelt das
---
### 6. `EbayAdapter` — Config wirklich lesen, Policies übergeben
- [ ] `EbayAdapter` bekommt `ArticleTypePlatformConfigRepositoryInterface` injiziert
- [ ] `publishListing()` lädt die Config über `findByArticleTypeAndPlatformType($article->getArticleType(), 'ebay')`
- [ ] Exception werfen wenn kein Config-Eintrag existiert: `"No eBay platform config for ArticleType {name}"`
- [ ] `createOffer()` body erweitern:
```php
'categoryId' => $config->getCategoryId(),
'listingPolicies' => array_filter([ // array_filter entfernt null-Werte
'fulfillmentPolicyId' => $config->getFulfillmentPolicyId(),
'paymentPolicyId' => $config->getPaymentPolicyId(),
'returnPolicyId' => $config->getReturnPolicyId(),
]),
...($config->getMerchantLocationKey() !== null ? [
'merchantLocationKey' => $config->getMerchantLocationKey(),
] : []),
```
- [ ] Hardcoded `return '177';` in `getCategoryId()` entfernen (Methode fällt weg, Config übernimmt)
- [ ] Bestehende Unit-Tests für `EbayAdapter` anpassen
---
### 7. EasyAdmin CRUD — `EbayArticleTypePlatformConfigCrudController`
- [ ] `src/Infrastructure/Http/Controller/Admin/EbayArticleTypePlatformConfigCrudController.php` erstellen
- [ ] Entity: `ArticleTypePlatformConfig`
- [ ] `createIndexQueryBuilder()` überschreiben → filtert auf `platform.type = 'ebay'`
**`configureFields()`:**
```php
public function configureFields(string $pageName): iterable
{
yield AssociationField::new('articleType', 'Artikel-Typ');
yield TextField::new('categoryId', 'eBay Kategorie-ID');
$choices = $this->tryGetChoices('fulfillment'); // siehe unten
yield $choices !== null
? ChoiceField::new('fulfillmentPolicyId', 'Versand-Policy')->setChoices($choices)
: TextField::new('fulfillmentPolicyId', 'Versand-Policy (ID)');
// analog für payment, return, merchantLocationKey
}
```
- `tryGetChoices(string $type): ?array<string,string>` — ruft `EbayPolicyProvider` auf, gibt `null` zurück bei leerer Liste oder Exception
- Wenn `null`: `TextField` statt `ChoiceField` + einmalige Flash-Warnung `"eBay API nicht erreichbar — ID manuell eingeben"`
- [ ] `services.yaml``EbayPolicyProvider` in den Controller injizieren
---
### 8. Navigation — eBay-Bereich in `DashboardController`
- [ ] In `configureMenuItems()` einen eBay-Submenü-Block einfügen:
```php
yield MenuItem::subMenu('eBay', 'fa fa-store')->setSubItems([
MenuItem::linkTo(EbayArticleTypePlatformConfigCrudController::class, 'Kategorie & Policies', 'fa fa-sliders'),
MenuItem::linkToRoute('eBay Aspect Import', 'fa fa-download', 'admin_ebay_aspect_import'),
]);
```
- [ ] Übersetzungsschlüssel in `messages.de.yaml` / `messages.en.yaml` ergänzen
- [ ] `ArticleTypePlatformConfig` aus dem allgemeinen Bereich entfernen, falls er dort noch auftaucht (war bisher nicht in der Nav)
---
## Nicht in diesem Plan
- Weitere Adapter-Sektionen (Amazon, Kaufland) — Struktur ist vorbereitet, wird angelegt wenn die Adapter existieren
- Sandbox-Policies abrufen — die Account-API ist auf dem echten eBay-Account. Für Tests: Policy-IDs aus Sandbox-Account manuell in `.env.local` setzen oder `EbayPolicyProvider` mocken
- Kategorie-ID als Typeahead (statt Text-Input) — ist ein separates Thema
---
## Dateien die geändert werden
```
src/Domain/Channel/ArticleTypePlatformConfig.php ← 4 neue Felder
src/Domain/Channel/Repository/ArticleTypePlatformConfigRepositoryInterface.php ← neue Methode
src/Infrastructure/Persistence/Repository/DoctrineArticleTypePlatformConfigRepository.php ← Implementierung
src/Infrastructure/Channel/Ebay/EbayAccountApiClient.php ← NEU
src/Infrastructure/Channel/Ebay/EbayInventoryApiClient.php ← getLocations()
src/Infrastructure/Channel/Ebay/EbayPolicyProvider.php ← NEU
src/Infrastructure/Channel/Ebay/EbayAdapter.php ← Config lesen, Policies übergeben
src/Infrastructure/Http/Controller/Admin/EbayArticleTypePlatformConfigCrudController.php ← NEU
src/Infrastructure/Http/Controller/Admin/DashboardController.php ← Navigation
config/services.yaml ← neue Services
migrations/Version2026...php ← NEU
```

View file

@ -29,8 +29,8 @@ src/
# Orchestriert Domain über Interfaces (Ports) # Orchestriert Domain über Interfaces (Ports)
Infrastructure/ Infrastructure/
Channel/ # EbayAdapter, FrappeErpAdapter, [FutureAdapter] Channel/ # EbayAdapter, FrappeErpAdapter, [FutureAdapter]
AI/ # MistralClient hinter OllamaClientInterface (Interfacename historisch) AI/ # OllamaClient, MistralClient (beide hinter OllamaClientInterface)
# VisionAgent, SpecsResearchAgent, JsonCodingAgent, EbayTextAgent # OllamaVisionAgent, SpecsResearchAgent, JsonCodingAgent, EbayTextAgent
Persistence/ # Doctrine Repositories (PostgreSQL) Persistence/ # Doctrine Repositories (PostgreSQL)
Logging/ # DatabaseLogHandler, ArchiveCommand Logging/ # DatabaseLogHandler, ArchiveCommand
Http/ # Symfony Controller, Webhook-Listener, EasyAdmin Http/ # Symfony Controller, Webhook-Listener, EasyAdmin
@ -38,7 +38,7 @@ src/
Storage/ # StorageManager 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. 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 ### 2.2 Tech-Stack
@ -51,7 +51,7 @@ Jeder externe Dienst (eBay, Frappe ERP, Mistral, SMTP) implementiert ein Interfa
| Tests | PHPUnit 11 + Pest | | Tests | PHPUnit 11 + Pest |
| Datenbank | PostgreSQL 17 (eine Instanz, drei Schemas: `app`, `logs`, `logs_archive`) | | Datenbank | PostgreSQL 17 (eine Instanz, drei Schemas: `app`, `logs`, `logs_archive`) |
| Cache / Queue | Redis 7 | | 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 | | 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 | | Web-Suche | Tavily API (`TAVILY_API_KEY`) — liefert strukturierte Suchergebnisse für SpecsResearchAgent |
| Admin-Panel | EasyAdmin 5 (`#[AdminDashboard]` Attribut, `type: easyadmin.routes` Loader) | | Admin-Panel | EasyAdmin 5 (`#[AdminDashboard]` Attribut, `type: easyadmin.routes` Loader) |
| Auth | Symfony Security + scheb/two-factor-bundle (TOTP) | | Auth | Symfony Security + scheb/two-factor-bundle (TOTP) |
@ -71,7 +71,7 @@ redis://...?queue_name=orders # Order-Processing, CustomerResolver, Invo
redis://...?queue_name=channel_sync # Listing-Publish, Bestand-Sync, Deaktivierung, Tracking 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. **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.
--- ---
@ -216,14 +216,14 @@ ApiKey
``` ```
1. Foto-Upload → PhotoUploadMessage in Queue (redis://ai_pipeline) 1. Foto-Upload → PhotoUploadMessage in Queue (redis://ai_pipeline)
2. VisionAgent — Mistral Pixtral liest Typenschild 2. OllamaVisionAgent — LLaVA liest Typenschild
Output: Hersteller, Modellname, Modellnummer, Seriennummer Output: Hersteller, Modellname, Modellnummer, Seriennummer
3. Model-Cache-Check — findCompletedByModelNumber() in DB 3. Model-Cache-Check — findCompletedByModelNumber() in DB
Treffer → copy ebayTitle/ebayDescription/specsText/attributes → Schritt 6 (kein AI mehr) Treffer → copy ebayTitle/ebayDescription/specsText/attributes → Schritt 6 (kein AI mehr)
Kein Treffer → weiter mit Schritt 4 Kein Treffer → weiter mit Schritt 4
4. SpecsResearchAgent — Tavily-Suche mit Modellbezeichnung → vollständige Specs (Freitext) 4. SpecsResearchAgent — Tavily-Suche mit Modellbezeichnung → vollständige Specs (Freitext)
Pflichtfeld-Liste kommt aus ArticleType.AttributeDefinitions ({{fields}}-Platzhalter im Prompt) Pflichtfeld-Liste kommt aus ArticleType.AttributeDefinitions ({{fields}}-Platzhalter im Prompt)
5. JsonCodingAgent — strukturierter Mistral-Call: Specs-Text → JSON gegen ArticleType-Schema 5. JsonCodingAgent — strukturierter Ollama-Call: Specs-Text → JSON gegen ArticleType-Schema
6. ValidationGate — alle Pflichtfelder gesetzt? (Schema + eBay-Kategorie-Pflichtfelder) 6. ValidationGate — alle Pflichtfelder gesetzt? (Schema + eBay-Kategorie-Pflichtfelder)
✓ → Schritt 7 ✓ → Schritt 7
✗ → Retry ab Schritt 5 mit missing_fields im Prompt (max. 3×) ✗ → Retry ab Schritt 5 mit missing_fields im Prompt (max. 3×)
@ -352,7 +352,7 @@ Schema: logs_archive.log_entry # identische Struktur
### Dienste ### Dienste
- **PostgreSQL:** App-User mit minimalen Rechten (kein Superuser) - **PostgreSQL:** App-User mit minimalen Rechten (kein Superuser)
- **Redis:** `requirepass` gesetzt, nur intern erreichbar - **Redis:** `requirepass` gesetzt, nur intern erreichbar
- **Mistral API:** Key in `.env.local`, nie in Git; HTTPS-only - **Ollama:** lokal, Zugriff nur via SSH-Tunnel (autossh für Persistenz + Auto-Reconnect)
- **Gitea:** Registrierung deaktiviert, nur Admin-Anlage, SSH-Key-Auth - **Gitea:** Registrierung deaktiviert, nur Admin-Anlage, SSH-Key-Auth
### Applikation ### Applikation
@ -440,21 +440,31 @@ 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. Der Key wird als bcrypt-Hash gespeichert. Prefix (erste 8 Zeichen) dient als Lookup-Key.
Verwendung: `X-Api-Key: <rawKey>` HTTP-Header. Verwendung: `X-Api-Key: <rawKey>` HTTP-Header.
### 11.3 AI-Konfiguration ### 11.3 AI-Backend wechseln
**Primär: Mistral Cloud API** (Standard, bereits so konfiguriert): **Ollama (Standard, lokal):**
```yaml ```yaml
# config/services.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: App\Infrastructure\AI\OllamaClientInterface:
alias: App\Infrastructure\AI\MistralClient alias: App\Infrastructure\AI\MistralClient
``` ```
```dotenv ```dotenv
# .env.local AI_TEXT_MODEL=${MISTRAL_TEXT_MODEL}
AI_VISION_MODEL=${MISTRAL_VISION_MODEL}
MISTRAL_API_KEY=sk-... 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`. Vision erfordert Pixtral (`pixtral-12b-2409`). Nach Änderung: `cache:clear` + FPM reload.
### 11.4 Pipeline starten ### 11.4 Pipeline starten

View file

@ -1,546 +0,0 @@
# SuperSeller3000 — Infrastruktur & Deployment
**Zuletzt aktualisiert:** 2026-05-19
**Zielumgebung:** VPS (Ubuntu/Debian), Docker Compose
**Domain:** `ss3k.schaunwama.de`
> Dieses Dokument beschreibt den vollständigen Stand der Docker-Infrastruktur und alle Schritte, um das System auf einem neuen Server hochzuziehen.
---
## 1. Übersicht der Services
| Service | Image / Build | Zweck |
|---|---|---|
| `app` | Build `docker/app/Dockerfile` | PHP 8.4-FPM — Symfony-Applikation |
| `caddy` | `caddy:2-alpine` | Reverse Proxy, Auto-HTTPS (Let's Encrypt) |
| `postgres` | `postgres:17-alpine` | Datenbank (Schemas: `app`, `logs`, `logs_archive`) |
| `redis` | `redis:7-alpine` | Queue-Backend + Session-Cache |
| `worker-ai` | Build `docker/app/Dockerfile` | Messenger Worker — Transport `ai_pipeline` |
| `worker-orders` | Build `docker/app/Dockerfile` | Messenger Worker — Transport `orders` |
| `worker-channel` | Build `docker/app/Dockerfile` | Messenger Worker — Transport `channel_sync` |
| `cron` | Build `docker/app/Dockerfile` | Log-Rotation täglich (`app:logs:rotate`) |
**Nicht im Prod-Setup:** kein Staging-ERP-Container. Frappe ERP läuft extern (eigene Instanz, `FRAPPE_ERP_BASE_URL` in `.env.local`).
---
## 2. Queue-Architektur (Symfony Messenger + Redis Streams)
Drei isolierte Redis-Streams — ein ausgefallener Worker blockiert die anderen nie.
### Transports & Retry-Strategie
| Transport | Redis Stream | Worker | max_retries | Delay / Backoff |
|---|---|---|---|---|
| `ai_pipeline` | `ai_pipeline` | `worker-ai` | 3 | 2 s · ×2 |
| `orders` | `orders` | `worker-orders` | 5 | 1 s · ×2 |
| `channel_sync` | `channel_sync` | `worker-channel` | 5 | 2 s · ×2 · max 60 s |
| `failed` | `failed` | — | — | persistent, nie verloren |
Nach Erschöpfung aller Retries landet die Message im `failed`-Transport. Replay: `messenger:failed:retry`.
### Messages pro Transport
#### `ai_pipeline``worker-ai`
Die Pipeline-Schritte werden sequenziell als separate Messages dispatcht — jeder Handler dispatcht den Nachfolger.
| Message | Handler-Logik |
|---|---|
| `PhotoUploadMessage` | Startet Pipeline A: `VisionAgent` (Mistral Pixtral) liest Typenschild → Hersteller, Modell, SN |
| `SpecsResearchMessage` | `SpecsResearchAgent`: Tavily-Suche mit Modellbezeichnung → Specs-Freitext; Pflichtfelder aus `ArticleType.AttributeDefinitions` (`{{fields}}`-Platzhalter im Prompt) |
| `JsonCodingMessage` | `JsonCodingAgent`: Specs-Text → strukturiertes JSON gegen ArticleType-Schema |
| `ValidationMessage` | Pflichtfelder vollständig? → weiter; sonst Retry ab `JsonCodingMessage` (max. 3×, `missing_fields` im Prompt) → `needs_review` |
| `DraftArticleMessage` | Article anlegen (status: `draft`), Inventurnummer vergeben, Attribute + Foto persistieren |
| `EbayTextMessage` | `EbayTextAgent`: eBay-Titel + Beschreibung aus Attributen generieren, an Article speichern |
| `PxeInventoryMessage` | Startet Pipeline B (PXE): PXE-Dump direkt an `JsonCodingAgent` — SpecsResearch entfällt |
**Model-Cache:** Nach `PhotoUploadMessage` prüft `findCompletedByModelNumber()` die DB. Treffer → alle Daten kopieren, `SpecsResearch`/`JsonCoding`/`EbayText` überspringen.
**Fehlerfall:** `PipelineJobFailureListener` fängt `WorkerMessageFailedEvent` ab und setzt `AIPipelineJob.status = failed` mit dem echten Fehlertext.
#### `orders``worker-orders`
| Message | Handler-Logik |
|---|---|
| `OrderReceivedMessage` | Vollständiger 13-Schritt-Order-Flow: Idempotenz-Check → atomarer Inventory-Lock (`stock - 1`) → `CustomerResolver` (Matching-Kaskade) → Order anlegen → Frappe Sales Invoice → PDF abrufen → `StorageManager` → Invoice-Record → PDF per SMTP an Lieferant → `UpdateStockOnChannelsMessage` oder `DeactivateListingMessage` dispatchen → Order: `completed` |
#### `channel_sync``worker-channel`
| Message | Handler-Logik |
|---|---|
| `PublishToChannelMessage` | `ChannelAdapterRegistry` → richtiger Adapter (z.B. `EbayAdapter`) → `publishListing()``ebay_listing_id` an Article zurückschreiben |
| `UpdateStockOnChannelsMessage` | `updateStock()` auf allen aktiven Plattformen des Artikels |
| `DeactivateListingMessage` | `deactivateListing()` — wird ausgelöst wenn `stock = 0` nach Verkauf |
| `TrackingPushMessage` | `pushTracking()` → Plattform als versandt markieren → `tracking_pushed_to_ebay_at` setzen |
### AI-Backend-Ausfall
AI-Worker-Messages verbleiben in der Queue. Messenger wiederholt mit Backoff, nach 3 Versuchen → `failed`-Transport. 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. Netzwerk
Alle Services kommunizieren über ein internes Docker-Bridge-Network. Nur Caddy exponiert Ports nach außen.
```yaml
networks:
default:
driver: bridge
ipam:
config:
- subnet: 172.18.0.0/24
gateway: 172.18.0.1
```
Das Gateway `172.18.0.1` ist die Host-Adresse innerhalb des Docker-Netzes (erreichbar von allen Containern).
---
## 4. docker-compose.yml (Prod)
```yaml
services:
app:
build:
context: .
dockerfile: docker/app/Dockerfile
user: "1000:1000"
environment:
HOME: /tmp
volumes:
- .:/var/www
- ./docker/app/php.ini:/usr/local/etc/php/conf.d/app.ini:ro
- ./docker/app/zz-fpm-pool.conf:/usr/local/etc/php-fpm.d/zzz-pool.conf:ro
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
env_file:
- path: .env
required: true
- path: .env.local
required: false
caddy:
image: caddy:2-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./docker/caddy/Caddyfile:/etc/caddy/Caddyfile
- .:/var/www
- ./docker/app/php.ini:/usr/local/etc/php/conf.d/app.ini:ro
- caddy_data:/data
depends_on:
- app
postgres:
image: postgres:17-alpine
env_file: .env
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 5s
timeout: 5s
retries: 5
worker-ai:
build:
context: .
dockerfile: docker/app/Dockerfile
user: "1000:1000"
environment:
HOME: /tmp
command: php bin/console messenger:consume ai_pipeline --time-limit=3600 --memory-limit=256M
volumes:
- .:/var/www
- ./docker/app/php.ini:/usr/local/etc/php/conf.d/app.ini:ro
depends_on:
- postgres
- redis
env_file:
- path: .env
required: true
- path: .env.local
required: false
restart: unless-stopped
worker-orders:
build:
context: .
dockerfile: docker/app/Dockerfile
user: "1000:1000"
environment:
HOME: /tmp
command: php bin/console messenger:consume orders --time-limit=3600 --memory-limit=256M
volumes:
- .:/var/www
- ./docker/app/php.ini:/usr/local/etc/php/conf.d/app.ini:ro
depends_on:
- postgres
- redis
env_file:
- path: .env
required: true
- path: .env.local
required: false
restart: unless-stopped
worker-channel:
build:
context: .
dockerfile: docker/app/Dockerfile
user: "1000:1000"
environment:
HOME: /tmp
command: php bin/console messenger:consume channel_sync --time-limit=3600 --memory-limit=256M
volumes:
- .:/var/www
- ./docker/app/php.ini:/usr/local/etc/php/conf.d/app.ini:ro
depends_on:
- postgres
- redis
env_file:
- path: .env
required: true
- path: .env.local
required: false
restart: unless-stopped
cron:
build:
context: .
dockerfile: docker/app/Dockerfile
user: "1000:1000"
environment:
HOME: /tmp
command: >
sh -c "while true; do
php bin/console app:logs:rotate;
sleep 86400;
done"
volumes:
- .:/var/www
- ./docker/app/php.ini:/usr/local/etc/php/conf.d/app.ini:ro
depends_on:
- postgres
env_file:
- path: .env
required: true
- path: .env.local
required: false
restart: unless-stopped
volumes:
postgres_data:
redis_data:
caddy_data:
networks:
default:
driver: bridge
ipam:
config:
- subnet: 172.18.0.0/24
gateway: 172.18.0.1
```
**Hinweis:** `docker-compose.override.yml` existiert nur für lokale Entwicklung (Port-Bindings für Postgres/Redis zum direkten Zugriff vom Host). Auf dem Prod-Server nicht verwenden.
---
## 4. Dockerfile — `docker/app/Dockerfile`
```dockerfile
FROM php:8.4-fpm-alpine
RUN apk add --no-cache \
postgresql-dev \
icu-dev \
libzip-dev \
unzip \
git \
$PHPIZE_DEPS \
&& docker-php-ext-install \
pdo_pgsql \
intl \
zip \
opcache \
&& pecl install redis \
&& docker-php-ext-enable redis \
&& apk del $PHPIZE_DEPS
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /var/www
COPY docker/app/php.ini /usr/local/etc/php/conf.d/app.ini
```
---
## 5. PHP-Konfiguration
### `docker/app/php.ini`
```ini
opcache.enable=0
memory_limit=256M
upload_max_filesize=20M
post_max_size=20M
```
`opcache.enable=0` weil der Dev-Build keinen Preload hat — in Prod kann auf `1` + `opcache.preload` umgestellt werden, wenn gewünscht.
### `docker/app/zz-fpm-pool.conf`
```ini
[www]
; SSE connections hold a worker for up to 90 s each — raise the pool ceiling
; so regular requests are not starved.
pm = dynamic
pm.max_children = 30
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 8
```
---
## 6. Caddyfile (Prod) — `docker/caddy/Caddyfile`
```
{
admin off
}
ss3k.schaunwama.de {
root * /var/www/public
php_fastcgi app:9000
file_server
encode gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
}
}
```
**Prod enthält keinen `erpstaging`-Block.** Frappe ERP läuft auf einer eigenen Instanz (extern, `FRAPPE_ERP_BASE_URL` in `.env.local`).
> Aktueller Dev-Stand hat noch den `erpstaging.schaunwama.de`-Block für die Staging-ERP-Weiterleitung — der muss vor dem ersten Prod-Deploy entfernt werden.
---
## 7. Umgebungsvariablen
### `.env` (ohne Credentials, in Git)
```dotenv
APP_ENV=prod
APP_SECRET=change_me_in_env_local
POSTGRES_DB=superseller
POSTGRES_USER=superseller
POSTGRES_PASSWORD=change_me
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?serverVersion=17&charset=utf8"
REDIS_PASSWORD=change_me
REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379
MESSENGER_TRANSPORT_DSN=redis://:${REDIS_PASSWORD}@redis:6379/messages
MAILER_DSN=smtp://localhost
TAVILY_API_KEY=
MISTRAL_BASE_URL=https://api.mistral.ai
MISTRAL_API_KEY=
MISTRAL_VISION_MODEL=pixtral-12b-2409
MISTRAL_TEXT_MODEL=mistral-large-latest
AI_TEXT_MODEL=${MISTRAL_TEXT_MODEL}
AI_VISION_MODEL=${MISTRAL_VISION_MODEL}
EBAY_CLIENT_ID=
EBAY_CLIENT_SECRET=
EBAY_MARKETPLACE_ID=EBAY_DE
EBAY_API_BASE_URL=https://api.ebay.com
EBAY_OAUTH_BASE_URL=https://api.ebay.com
EBAY_VERIFICATION_TOKEN=
EBAY_ENDPOINT_URL=https://ss3k.schaunwama.de/webhooks/ebay
FRAPPE_ERP_BASE_URL=https://erp.example.com
FRAPPE_ERP_API_KEY=changeme
FRAPPE_ERP_API_SECRET=changeme
FRAPPE_GENERIC_ITEM_CODE=REFURB-HW
SUPPLIER_EMAIL=lieferant@example.com
SENDER_EMAIL=noreply@superseller3000.de
```
### `.env.local` (nur auf Server, nie in Git)
Alle echten Credentials überschreiben hier die Platzhalter aus `.env`:
```dotenv
APP_SECRET=<random-32-char-hex>
POSTGRES_PASSWORD=<sicheres-passwort>
REDIS_PASSWORD=<sicheres-passwort>
MAILER_DSN=smtp://user:pass@mailserver:587
TAVILY_API_KEY=tvly-...
MISTRAL_API_KEY=sk-...
EBAY_CLIENT_ID=...
EBAY_CLIENT_SECRET=...
EBAY_VERIFICATION_TOKEN=... # aus eBay Developer Portal
FRAPPE_ERP_BASE_URL=https://erp.meinefirma.de
FRAPPE_ERP_API_KEY=...
FRAPPE_ERP_API_SECRET=...
SUPPLIER_EMAIL=lieferant@meinefirma.de
SENDER_EMAIL=noreply@meinefirma.de
```
---
## 8. VPS-Voraussetzungen
```bash
# Docker + Compose Plugin
apt install docker.io docker-compose-plugin
# User 1000 muss existieren (PHP läuft als 1000:1000)
id -u superseller # sollte 1000 sein; ggf. anpassen
# UFW: nur Caddy-Ports + SSH
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
```
---
## 9. Erste Inbetriebnahme (Schritt für Schritt)
```bash
# 1. Repo klonen
git clone <gitea-url>/superseller3000 /home/superseller/SuperSeller3000
cd /home/superseller/SuperSeller3000
# 2. .env.local mit echten Credentials anlegen (siehe Abschnitt 7)
nano .env.local
# 3. Caddyfile prüfen — kein erpstaging-Block!
# (docker/caddy/Caddyfile muss nur den ss3k.schaunwama.de-Block enthalten)
# 4. Images bauen + DB hochfahren
docker compose up -d postgres redis
# warten bis postgres healthy
# 5. Composer install
docker compose run --rm app composer install --no-dev --optimize-autoloader
# 6. Datenbankmigrationen ausführen
docker compose run --rm app php bin/console doctrine:migrations:migrate --no-interaction
# 7. Cache aufwärmen
docker compose run --rm app php bin/console cache:warmup --env=prod
# 8. Ersten Admin-User anlegen
docker compose run --rm app php bin/console app:users:create --env=prod
# 9. Ersten API-Key anlegen (Klartext wird nur einmal angezeigt)
docker compose run --rm app php bin/console app:api-keys:create --env=prod
# 10. Alle Services starten
docker compose up -d
# 11. eBay-Webhook registrieren (URL in .env.local: EBAY_ENDPOINT_URL)
# Im eBay Developer Portal: Notification URL = https://ss3k.schaunwama.de/webhooks/ebay
# Verification Token = EBAY_VERIFICATION_TOKEN
```
---
## 10. Laufender Betrieb
### Worker-Status prüfen
```bash
docker compose ps
docker compose logs -f worker-ai
docker compose logs -f worker-orders
docker compose logs -f worker-channel
```
### Failed Messages (nach Erschöpfung aller Retries)
```bash
# Übersicht
docker compose exec app php bin/console messenger:failed:show
# Einzelne Message erneut versuchen
docker compose exec app php bin/console messenger:failed:retry
# Alle nochmal
docker compose exec app php bin/console messenger:failed:retry --all
```
### Updates deployen
```bash
git pull
docker compose build app worker-ai worker-orders worker-channel cron
docker compose run --rm app composer install --no-dev --optimize-autoloader
docker compose run --rm app php bin/console doctrine:migrations:migrate --no-interaction
docker compose run --rm app php bin/console cache:warmup --env=prod
docker compose up -d
```
### Logs (Admin-Panel)
`https://ss3k.schaunwama.de/admin`**Log Entries** — Fulltext-Suche, Filter nach Level/Channel/Zeitraum.
Rotation läuft täglich automatisch (Cron-Service): Einträge > 90 Tage → `logs_archive`, danach gelöscht.
### Backup
```bash
# PostgreSQL-Dump
docker compose exec postgres pg_dump -U superseller superseller | gzip > backup_$(date +%Y%m%d).sql.gz
# Artikel-Fotos und Rechnungs-PDFs (Docker Volumes)
docker run --rm -v superseller3000_postgres_data:/data -v $(pwd):/backup \
alpine tar czf /backup/volume_postgres_$(date +%Y%m%d).tar.gz /data
```
---
## 11. AI-Konfiguration
### Primäres Backend: Mistral Cloud API
Standardkonfiguration — `config/services.yaml` bereits so ausgeliefert:
```yaml
App\Infrastructure\AI\OllamaClientInterface:
alias: App\Infrastructure\AI\MistralClient
```
`.env.local`:
```dotenv
MISTRAL_API_KEY=sk-...
# Modelle sind in .env vorbelegt:
# AI_TEXT_MODEL=${MISTRAL_TEXT_MODEL} → mistral-large-latest
# AI_VISION_MODEL=${MISTRAL_VISION_MODEL} → pixtral-12b-2409
```
Web-Suche (SpecsResearchAgent): **Tavily API**`TAVILY_API_KEY=tvly-...` in `.env.local`.
Nach Schlüsseländerung: `docker compose exec app php bin/console cache:clear`.

View file

@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260519070206 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE app.article_type_ebay_mappings ALTER required DROP DEFAULT');
$this->addSql('ALTER INDEX app.article_type_ebay_mappings_article_type_id_ebay_aspect_name_key RENAME TO uq_ebay_mapping');
$this->addSql('ALTER TABLE app.article_type_platform_configs ADD fulfillment_policy_id VARCHAR(100) DEFAULT NULL');
$this->addSql('ALTER TABLE app.article_type_platform_configs ADD payment_policy_id VARCHAR(100) DEFAULT NULL');
$this->addSql('ALTER TABLE app.article_type_platform_configs ADD return_policy_id VARCHAR(100) DEFAULT NULL');
$this->addSql('ALTER TABLE app.article_type_platform_configs ADD merchant_location_key VARCHAR(100) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA logs_archive');
$this->addSql('ALTER TABLE app.article_type_ebay_mappings ALTER required SET DEFAULT false');
$this->addSql('ALTER INDEX app.uq_ebay_mapping RENAME TO article_type_ebay_mappings_article_type_id_ebay_aspect_name_key');
$this->addSql('ALTER TABLE app.article_type_platform_configs DROP fulfillment_policy_id');
$this->addSql('ALTER TABLE app.article_type_platform_configs DROP payment_policy_id');
$this->addSql('ALTER TABLE app.article_type_platform_configs DROP return_policy_id');
$this->addSql('ALTER TABLE app.article_type_platform_configs DROP merchant_location_key');
}
}

View file

@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260520090000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add ebay_aspect_field_mappings JSON column to article_types';
}
public function up(Schema $schema): void
{
$this->addSql("ALTER TABLE app.article_types ADD COLUMN ebay_aspect_field_mappings JSON DEFAULT NULL");
}
public function down(Schema $schema): void
{
$this->addSql("ALTER TABLE app.article_types DROP COLUMN ebay_aspect_field_mappings");
}
}

View file

@ -1,44 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260520100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Replace ebay_aspect_field_mappings JSON with article_type_ebay_mappings table';
}
public function up(Schema $schema): void
{
$this->addSql("
CREATE TABLE app.article_type_ebay_mappings (
id UUID NOT NULL,
article_type_id UUID NOT NULL,
ebay_aspect_name VARCHAR(255) NOT NULL,
source_type VARCHAR(30) NOT NULL,
article_field_key VARCHAR(100) DEFAULT NULL,
attribute_definition_id UUID DEFAULT NULL,
required BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (id),
UNIQUE (article_type_id, ebay_aspect_name),
CONSTRAINT fk_etm_article_type FOREIGN KEY (article_type_id)
REFERENCES app.article_types (id) ON DELETE CASCADE,
CONSTRAINT fk_etm_attr_def FOREIGN KEY (attribute_definition_id)
REFERENCES app.attribute_definitions (id) ON DELETE SET NULL
)
");
$this->addSql("ALTER TABLE app.article_types DROP COLUMN IF EXISTS ebay_aspect_field_mappings");
}
public function down(Schema $schema): void
{
$this->addSql("DROP TABLE app.article_type_ebay_mappings");
$this->addSql("ALTER TABLE app.article_types ADD COLUMN ebay_aspect_field_mappings JSON DEFAULT NULL");
}
}

View file

@ -135,11 +135,6 @@ class Article
return $this->articleType; return $this->articleType;
} }
public function getArticleTypeName(): string
{
return $this->articleType->getName();
}
public function getSku(): string public function getSku(): string
{ {
return $this->sku; return $this->sku;

View file

@ -27,10 +27,6 @@ class ArticleType
#[ORM\OneToMany(targetEntity: ArticleTypeAttribute::class, mappedBy: 'articleType', cascade: ['persist', 'remove'], orphanRemoval: true)] #[ORM\OneToMany(targetEntity: ArticleTypeAttribute::class, mappedBy: 'articleType', cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $attributeAssignments; private Collection $attributeAssignments;
/** @var Collection<int, ArticleTypeEbayMapping> */
#[ORM\OneToMany(targetEntity: ArticleTypeEbayMapping::class, mappedBy: 'articleType', cascade: ['persist', 'remove'], orphanRemoval: true, indexBy: 'ebayAspectName')]
private Collection $ebayMappings;
/** @var list<AttributeDefinition>|null pending from form — applied by applyAttributeAssignments() */ /** @var list<AttributeDefinition>|null pending from form — applied by applyAttributeAssignments() */
private ?array $pendingRequired = null; private ?array $pendingRequired = null;
@ -42,7 +38,6 @@ class ArticleType
$this->id = Uuid::v7(); $this->id = Uuid::v7();
$this->name = $name; $this->name = $name;
$this->attributeAssignments = new ArrayCollection(); $this->attributeAssignments = new ArrayCollection();
$this->ebayMappings = new ArrayCollection();
} }
public function __toString(): string public function __toString(): string
@ -75,25 +70,6 @@ class ArticleType
$this->ebayCategoryId = $id; $this->ebayCategoryId = $id;
} }
/** @return Collection<int, ArticleTypeEbayMapping> */
public function getEbayMappings(): Collection
{
return $this->ebayMappings;
}
public function upsertEbayMapping(ArticleTypeEbayMapping $mapping): void
{
foreach ($this->ebayMappings as $existing) {
if ($existing->getEbayAspectName() === $mapping->getEbayAspectName()) {
$existing->setArticleFieldKey($mapping->getArticleFieldKey());
$existing->setAttributeDefinition($mapping->getAttributeDefinition());
$existing->setRequired($mapping->isRequired());
return;
}
}
$this->ebayMappings->add($mapping);
}
/** @return Collection<int, ArticleTypeAttribute> */ /** @return Collection<int, ArticleTypeAttribute> */
public function getAttributeAssignments(): Collection public function getAttributeAssignments(): Collection
{ {

View file

@ -1,83 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Domain\Article;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* Explicit mapping: eBay aspect name source field for a given ArticleType.
* sourceType 'article_field' reads a direct Article getter (manufacturer, modelNumber, ).
* sourceType 'attribute' reads an AttributeValue linked to an AttributeDefinition.
*/
#[ORM\Entity]
#[ORM\Table(name: 'article_type_ebay_mappings', schema: 'app')]
#[ORM\UniqueConstraint(name: 'uq_ebay_mapping', columns: ['article_type_id', 'ebay_aspect_name'])]
class ArticleTypeEbayMapping
{
public const SOURCE_ARTICLE_FIELD = 'article_field';
public const SOURCE_ATTRIBUTE = 'attribute';
#[ORM\Id]
#[ORM\Column(type: 'uuid')]
private Uuid $id;
#[ORM\ManyToOne(targetEntity: ArticleType::class, inversedBy: 'ebayMappings')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ArticleType $articleType;
#[ORM\Column(type: 'string', length: 255)]
private string $ebayAspectName;
#[ORM\Column(type: 'string', length: 30)]
private string $sourceType;
#[ORM\Column(type: 'string', length: 100, nullable: true)]
private ?string $articleFieldKey = null;
#[ORM\ManyToOne(targetEntity: AttributeDefinition::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?AttributeDefinition $attributeDefinition = null;
#[ORM\Column(type: 'boolean')]
private bool $required = false;
public function __construct(ArticleType $articleType, string $ebayAspectName, string $sourceType)
{
$this->id = Uuid::v7();
$this->articleType = $articleType;
$this->ebayAspectName = $ebayAspectName;
$this->sourceType = $sourceType;
}
public function getId(): Uuid { return $this->id; }
public function getArticleType(): ArticleType { return $this->articleType; }
public function getEbayAspectName(): string { return $this->ebayAspectName; }
public function getSourceType(): string { return $this->sourceType; }
public function getArticleFieldKey(): ?string { return $this->articleFieldKey; }
public function setArticleFieldKey(?string $key): void { $this->articleFieldKey = $key; }
public function getAttributeDefinition(): ?AttributeDefinition { return $this->attributeDefinition; }
public function setAttributeDefinition(?AttributeDefinition $def): void { $this->attributeDefinition = $def; }
public function isRequired(): bool { return $this->required; }
public function setRequired(bool $required): void { $this->required = $required; }
public function getSourceLabel(): string
{
if (self::SOURCE_ARTICLE_FIELD === $this->sourceType) {
return 'Artikelfeld: '.($this->articleFieldKey ?? '?');
}
return 'Attribut: '.($this->attributeDefinition?->getName() ?? '?');
}
}

View file

@ -30,18 +30,6 @@ class ArticleTypePlatformConfig
#[ORM\Column(type: 'string', length: 255)] #[ORM\Column(type: 'string', length: 255)]
private string $categoryId; private string $categoryId;
#[ORM\Column(type: 'string', length: 100, nullable: true)]
private ?string $fulfillmentPolicyId = null;
#[ORM\Column(type: 'string', length: 100, nullable: true)]
private ?string $paymentPolicyId = null;
#[ORM\Column(type: 'string', length: 100, nullable: true)]
private ?string $returnPolicyId = null;
#[ORM\Column(type: 'string', length: 100, nullable: true)]
private ?string $merchantLocationKey = null;
/** @var Collection<int, AttributeMapping> */ /** @var Collection<int, AttributeMapping> */
#[ORM\OneToMany(mappedBy: 'platformConfig', targetEntity: AttributeMapping::class, cascade: ['persist', 'remove'])] #[ORM\OneToMany(mappedBy: 'platformConfig', targetEntity: AttributeMapping::class, cascade: ['persist', 'remove'])]
private Collection $attributeMappings; private Collection $attributeMappings;
@ -80,46 +68,6 @@ class ArticleTypePlatformConfig
$this->categoryId = $categoryId; $this->categoryId = $categoryId;
} }
public function getFulfillmentPolicyId(): ?string
{
return $this->fulfillmentPolicyId;
}
public function setFulfillmentPolicyId(?string $fulfillmentPolicyId): void
{
$this->fulfillmentPolicyId = $fulfillmentPolicyId;
}
public function getPaymentPolicyId(): ?string
{
return $this->paymentPolicyId;
}
public function setPaymentPolicyId(?string $paymentPolicyId): void
{
$this->paymentPolicyId = $paymentPolicyId;
}
public function getReturnPolicyId(): ?string
{
return $this->returnPolicyId;
}
public function setReturnPolicyId(?string $returnPolicyId): void
{
$this->returnPolicyId = $returnPolicyId;
}
public function getMerchantLocationKey(): ?string
{
return $this->merchantLocationKey;
}
public function setMerchantLocationKey(?string $merchantLocationKey): void
{
$this->merchantLocationKey = $merchantLocationKey;
}
/** @return Collection<int, AttributeMapping> */ /** @return Collection<int, AttributeMapping> */
public function getAttributeMappings(): Collection public function getAttributeMappings(): Collection
{ {

View file

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Domain\Channel\Repository; namespace App\Domain\Channel\Repository;
use App\Domain\Article\ArticleType;
use App\Domain\Channel\ArticleTypePlatformConfig; use App\Domain\Channel\ArticleTypePlatformConfig;
use Symfony\Component\Uid\Uuid; use Symfony\Component\Uid\Uuid;
@ -17,8 +16,6 @@ interface ArticleTypePlatformConfigRepositoryInterface
/** @return list<ArticleTypePlatformConfig> */ /** @return list<ArticleTypePlatformConfig> */
public function findByArticleType(Uuid $articleTypeId): array; public function findByArticleType(Uuid $articleTypeId): array;
public function findByArticleTypeAndPlatformType(ArticleType $articleType, string $platformType): ?ArticleTypePlatformConfig;
public function save(ArticleTypePlatformConfig $config): void; public function save(ArticleTypePlatformConfig $config): void;
public function remove(ArticleTypePlatformConfig $config): void; public function remove(ArticleTypePlatformConfig $config): void;

View file

@ -1,80 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Channel\Ebay;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class EbayAccountApiClient
{
private const ACCOUNT_BASE = '/sell/account/v1';
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly EbayOAuthClient $oauthClient,
private readonly string $apiBaseUrl,
private readonly string $marketplaceId,
) {
}
/**
* @return list<array{fulfillmentPolicyId: string, name: string}>
*/
public function getFulfillmentPolicies(): array
{
$data = $this->request('GET', self::ACCOUNT_BASE.'/fulfillment_policy?marketplace_id='.$this->marketplaceId);
/** @var list<array{fulfillmentPolicyId: string, name: string}> */
return $data['fulfillmentPolicies'] ?? [];
}
/**
* @return list<array{paymentPolicyId: string, name: string}>
*/
public function getPaymentPolicies(): array
{
$data = $this->request('GET', self::ACCOUNT_BASE.'/payment_policy?marketplace_id='.$this->marketplaceId);
/** @var list<array{paymentPolicyId: string, name: string}> */
return $data['paymentPolicies'] ?? [];
}
/**
* @return list<array{returnPolicyId: string, name: string}>
*/
public function getReturnPolicies(): array
{
$data = $this->request('GET', self::ACCOUNT_BASE.'/return_policy?marketplace_id='.$this->marketplaceId);
/** @var list<array{returnPolicyId: string, name: string}> */
return $data['returnPolicies'] ?? [];
}
/**
* @return array<string, mixed>
*/
private function request(string $method, string $path): array
{
$token = $this->oauthClient->getAccessToken();
$response = $this->httpClient->request($method, $this->apiBaseUrl.$path, [
'headers' => [
'Authorization' => 'Bearer '.$token,
'Content-Type' => 'application/json',
'X-EBAY-C-MARKETPLACE-ID' => $this->marketplaceId,
],
]);
$statusCode = $response->getStatusCode();
if ($statusCode >= 400) {
$content = $response->getContent(false);
throw new \RuntimeException("eBay Account API error {$statusCode}: {$content}");
}
/** @var array<string, mixed> $data */
$data = $response->toArray();
return $data;
}
}

View file

@ -7,16 +7,12 @@ namespace App\Infrastructure\Channel\Ebay;
use App\Application\Channel\ChannelAdapterInterface; use App\Application\Channel\ChannelAdapterInterface;
use App\Domain\Article\Article; use App\Domain\Article\Article;
use App\Domain\Article\ArticleCondition; use App\Domain\Article\ArticleCondition;
use App\Domain\Article\ArticleTypeEbayMapping;
use App\Domain\Channel\Repository\ArticleTypePlatformConfigRepositoryInterface;
use App\Domain\Order\Order; use App\Domain\Order\Order;
final class EbayAdapter implements ChannelAdapterInterface final class EbayAdapter implements ChannelAdapterInterface
{ {
public function __construct( public function __construct(
private readonly EbayInventoryApiClient $apiClient, private readonly EbayInventoryApiClient $apiClient,
private readonly ArticleTypePlatformConfigRepositoryInterface $platformConfigRepository,
private readonly string $marketplaceId,
) { ) {
} }
@ -28,14 +24,6 @@ final class EbayAdapter implements ChannelAdapterInterface
public function publishListing(Article $article): string public function publishListing(Article $article): string
{ {
$sku = $article->getSku(); $sku = $article->getSku();
$config = $this->platformConfigRepository->findByArticleTypeAndPlatformType(
$article->getArticleType(),
'ebay'
);
if (null === $config) {
throw new \RuntimeException(\sprintf('No eBay platform config found for ArticleType "%s"', $article->getArticleType()->getName()));
}
$this->apiClient->upsertInventoryItem($sku, [ $this->apiClient->upsertInventoryItem($sku, [
'availability' => [ 'availability' => [
@ -52,18 +40,11 @@ final class EbayAdapter implements ChannelAdapterInterface
], ],
]); ]);
$listingPolicies = array_filter([ $offerId = $this->apiClient->createOffer([
'fulfillmentPolicyId' => $config->getFulfillmentPolicyId(),
'paymentPolicyId' => $config->getPaymentPolicyId(),
'returnPolicyId' => $config->getReturnPolicyId(),
]);
$offerBody = [
'sku' => $sku, 'sku' => $sku,
'marketplaceId' => $this->marketplaceId, 'marketplaceId' => 'EBAY_DE',
'format' => 'FIXED_PRICE', 'format' => 'FIXED_PRICE',
'availableQuantity' => $article->getStock(), 'availableQuantity' => $article->getStock(),
'categoryId' => $config->getCategoryId(),
'pricingSummary' => [ 'pricingSummary' => [
'price' => [ 'price' => [
'currency' => 'EUR', 'currency' => 'EUR',
@ -71,17 +52,8 @@ final class EbayAdapter implements ChannelAdapterInterface
], ],
], ],
'listingDescription' => $article->getEbayDescription() ?? '', 'listingDescription' => $article->getEbayDescription() ?? '',
]; 'categoryId' => $this->getCategoryId(),
]);
if ([] !== $listingPolicies) {
$offerBody['listingPolicies'] = $listingPolicies;
}
if (null !== $config->getMerchantLocationKey()) {
$offerBody['merchantLocationKey'] = $config->getMerchantLocationKey();
}
$offerId = $this->apiClient->createOffer($offerBody);
return $this->apiClient->publishOffer($offerId); return $this->apiClient->publishOffer($offerId);
} }
@ -139,36 +111,16 @@ final class EbayAdapter implements ChannelAdapterInterface
private function buildAspects(Article $article): array private function buildAspects(Article $article): array
{ {
$aspects = []; $aspects = [];
$valuesByDefId = [];
foreach ($article->getAttributeValues() as $value) { foreach ($article->getAttributeValues() as $value) {
$valuesByDefId[$value->getAttributeDefinition()->getId()->toRfc4122()] = $value->getValue(); $name = $value->getAttributeDefinition()->getName();
} $aspects[$name] = [$value->getValue()];
foreach ($article->getArticleType()->getEbayMappings() as $mapping) {
$ebayName = $mapping->getEbayAspectName();
if (ArticleTypeEbayMapping::SOURCE_ARTICLE_FIELD === $mapping->getSourceType()) {
$getter = 'get'.ucfirst((string) $mapping->getArticleFieldKey());
if (!method_exists($article, $getter)) {
continue;
}
$fieldValue = $article->$getter();
if (null !== $fieldValue && '' !== (string) $fieldValue) {
$aspects[$ebayName] = [(string) $fieldValue];
}
} else {
$def = $mapping->getAttributeDefinition();
if (null === $def) {
continue;
}
$val = $valuesByDefId[$def->getId()->toRfc4122()] ?? null;
if (null !== $val) {
$aspects[$ebayName] = [$val];
}
}
} }
return $aspects; return $aspects;
} }
private function getCategoryId(): string
{
return '177';
}
} }

View file

@ -74,17 +74,6 @@ class EbayInventoryApiClient
$this->request('POST', self::INVENTORY_BASE.'/bulk_update_price_quantity', $quantityUpdate); $this->request('POST', self::INVENTORY_BASE.'/bulk_update_price_quantity', $quantityUpdate);
} }
/**
* @return list<array{merchantLocationKey: string, name: string}>
*/
public function getLocations(): array
{
$data = $this->request('GET', self::INVENTORY_BASE.'/location', []);
/** @var list<array{merchantLocationKey: string, name: string}> */
return $data['locations'] ?? [];
}
/** /**
* Adds tracking info to an order. * Adds tracking info to an order.
* *

View file

@ -1,104 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Channel\Ebay;
use Psr\Cache\CacheItemPoolInterface;
final class EbayPolicyProvider
{
private const TTL = 300;
public function __construct(
private readonly EbayAccountApiClient $accountClient,
private readonly EbayInventoryApiClient $inventoryClient,
private readonly CacheItemPoolInterface $cache,
) {
}
/**
* @return array<string, string> "Name (ID)" => policyId
*/
public function getFulfillmentChoices(): array
{
return $this->cached('ebay_policy_fulfillment', function (): array {
$choices = [];
foreach ($this->accountClient->getFulfillmentPolicies() as $policy) {
$choices[$policy['name'].' ('.$policy['fulfillmentPolicyId'].')'] = $policy['fulfillmentPolicyId'];
}
return $choices;
});
}
/**
* @return array<string, string> "Name (ID)" => policyId
*/
public function getPaymentChoices(): array
{
return $this->cached('ebay_policy_payment', function (): array {
$choices = [];
foreach ($this->accountClient->getPaymentPolicies() as $policy) {
$choices[$policy['name'].' ('.$policy['paymentPolicyId'].')'] = $policy['paymentPolicyId'];
}
return $choices;
});
}
/**
* @return array<string, string> "Name (ID)" => policyId
*/
public function getReturnChoices(): array
{
return $this->cached('ebay_policy_return', function (): array {
$choices = [];
foreach ($this->accountClient->getReturnPolicies() as $policy) {
$choices[$policy['name'].' ('.$policy['returnPolicyId'].')'] = $policy['returnPolicyId'];
}
return $choices;
});
}
/**
* @return array<string, string> "Name (key)" => merchantLocationKey
*/
public function getLocationChoices(): array
{
return $this->cached('ebay_policy_location', function (): array {
$choices = [];
foreach ($this->inventoryClient->getLocations() as $location) {
$choices[$location['name'].' ('.$location['merchantLocationKey'].')'] = $location['merchantLocationKey'];
}
return $choices;
});
}
/**
* @param callable(): array<string, string> $loader
*
* @return array<string, string>
*/
private function cached(string $key, callable $loader): array
{
try {
$item = $this->cache->getItem($key);
if ($item->isHit()) {
/** @var array<string, string> */
return $item->get();
}
$choices = $loader();
$item->set($choices);
$item->expiresAfter(self::TTL);
$this->cache->save($item);
return $choices;
} catch (\Throwable) {
return [];
}
}
}

View file

@ -71,9 +71,6 @@ final class DashboardController extends AbstractDashboardController
yield MenuItem::linkTo(TranslationCrudController::class, $t('menu.translations'), 'fa fa-language'); yield MenuItem::linkTo(TranslationCrudController::class, $t('menu.translations'), 'fa fa-language');
yield MenuItem::linkTo(UserCrudController::class, $t('menu.users'), 'fa fa-users'); yield MenuItem::linkTo(UserCrudController::class, $t('menu.users'), 'fa fa-users');
yield MenuItem::linkTo(LogEntryCrudController::class, $t('menu.logs'), 'fa fa-list'); yield MenuItem::linkTo(LogEntryCrudController::class, $t('menu.logs'), 'fa fa-list');
yield MenuItem::subMenu($t('menu.section_ebay'), 'fa fa-store')->setSubItems([
MenuItem::linkTo(EbayArticleTypePlatformConfigCrudController::class, $t('menu.ebay_platform_configs'), 'fa fa-sliders'),
]);
yield MenuItem::section($t('menu.section_sales')); yield MenuItem::section($t('menu.section_sales'));
yield MenuItem::linkTo(OrderCrudController::class, $t('menu.orders'), 'fa fa-shopping-cart'); yield MenuItem::linkTo(OrderCrudController::class, $t('menu.orders'), 'fa fa-shopping-cart');
yield MenuItem::linkTo(CustomerCrudController::class, $t('menu.customers'), 'fa fa-users'); yield MenuItem::linkTo(CustomerCrudController::class, $t('menu.customers'), 'fa fa-users');

View file

@ -1,100 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Http\Controller\Admin;
use App\Domain\Channel\ArticleTypePlatformConfig;
use App\Infrastructure\Channel\Ebay\EbayPolicyProvider;
use Doctrine\ORM\QueryBuilder;
use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection;
use EasyCorp\Bundle\EasyAdminBundle\Collection\FilterCollection;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
use EasyCorp\Bundle\EasyAdminBundle\Dto\SearchDto;
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
use EasyCorp\Bundle\EasyAdminBundle\Orm\EntityRepository;
/** @extends AbstractCrudController<ArticleTypePlatformConfig> */
final class EbayArticleTypePlatformConfigCrudController extends AbstractCrudController
{
public function __construct(private readonly EbayPolicyProvider $policyProvider)
{
}
public static function getEntityFqcn(): string
{
return ArticleTypePlatformConfig::class;
}
public function configureCrud(Crud $crud): Crud
{
return $crud
->setEntityLabelInSingular('eBay Kategorie-Konfiguration')
->setEntityLabelInPlural('eBay Kategorie-Konfigurationen')
->setDefaultSort(['id' => 'ASC']);
}
public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder
{
$qb = $this->container->get(EntityRepository::class)->createQueryBuilder($searchDto, $entityDto, $fields, $filters);
$qb->join(\sprintf('%s.platform', $qb->getRootAliases()[0]), 'ebay_platform')
->andWhere('ebay_platform.type = :platformType')
->setParameter('platformType', 'ebay');
return $qb;
}
public function configureFields(string $pageName): iterable
{
yield AssociationField::new('articleType', 'Artikel-Typ');
yield TextField::new('categoryId', 'eBay Kategorie-ID');
$fulfillmentChoices = $this->policyProvider->getFulfillmentChoices();
if ([] !== $fulfillmentChoices) {
yield ChoiceField::new('fulfillmentPolicyId', 'Versand-Policy')
->setChoices($fulfillmentChoices)
->setRequired(false);
} else {
yield TextField::new('fulfillmentPolicyId', 'Versand-Policy (ID)')
->setHelp('eBay-API nicht erreichbar — ID manuell eingeben')
->setRequired(false);
}
$paymentChoices = $this->policyProvider->getPaymentChoices();
if ([] !== $paymentChoices) {
yield ChoiceField::new('paymentPolicyId', 'Zahlungs-Policy')
->setChoices($paymentChoices)
->setRequired(false);
} else {
yield TextField::new('paymentPolicyId', 'Zahlungs-Policy (ID)')
->setHelp('eBay-API nicht erreichbar — ID manuell eingeben')
->setRequired(false);
}
$returnChoices = $this->policyProvider->getReturnChoices();
if ([] !== $returnChoices) {
yield ChoiceField::new('returnPolicyId', 'Rückgabe-Policy')
->setChoices($returnChoices)
->setRequired(false);
} else {
yield TextField::new('returnPolicyId', 'Rückgabe-Policy (ID)')
->setHelp('eBay-API nicht erreichbar — ID manuell eingeben')
->setRequired(false);
}
$locationChoices = $this->policyProvider->getLocationChoices();
if ([] !== $locationChoices) {
yield ChoiceField::new('merchantLocationKey', 'Lagerstandort')
->setChoices($locationChoices)
->setRequired(false);
} else {
yield TextField::new('merchantLocationKey', 'Lagerstandort (Key)')
->setHelp('eBay-API nicht erreichbar — Key manuell eingeben')
->setRequired(false);
}
}
}

View file

@ -5,13 +5,11 @@ declare(strict_types=1);
namespace App\Infrastructure\Http\Controller\Admin; namespace App\Infrastructure\Http\Controller\Admin;
use App\Domain\Article\ArticleType; use App\Domain\Article\ArticleType;
use App\Domain\Article\ArticleTypeEbayMapping;
use App\Domain\Article\AttributeDefinition; use App\Domain\Article\AttributeDefinition;
use App\Domain\Article\AttributeType; use App\Domain\Article\AttributeType;
use App\Domain\Article\Repository\ArticleTypeRepositoryInterface; use App\Domain\Article\Repository\ArticleTypeRepositoryInterface;
use App\Infrastructure\Channel\Ebay\EbayTaxonomyService; use App\Infrastructure\Channel\Ebay\EbayTaxonomyService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@ -27,7 +25,6 @@ final class EbayAspectImportController extends AbstractController
private readonly ArticleTypeRepositoryInterface $articleTypeRepo, private readonly ArticleTypeRepositoryInterface $articleTypeRepo,
private readonly EbayTaxonomyService $taxonomy, private readonly EbayTaxonomyService $taxonomy,
private readonly EntityManagerInterface $em, private readonly EntityManagerInterface $em,
private readonly AdminUrlGenerator $adminUrlGenerator,
) { ) {
} }
@ -48,31 +45,6 @@ final class EbayAspectImportController extends AbstractController
return $this->json(array_slice($results, 0, 15)); return $this->json(array_slice($results, 0, 15));
} }
/**
* Article fields that can be mapped directly from eBay aspects.
* key = Article getter suffix / property name, value = German UI label.
*/
private const ARTICLE_FIELDS = [
'articleTypeName' => 'Produktart (Artikel-Typ)',
'manufacturer' => 'Hersteller (Marke)',
'modelNumber' => 'Herstellernummer (PN / MPN)',
'modelName' => 'Modellname',
'serialNumber' => 'Seriennummer',
];
/** eBay aspect names (lowercase) that auto-match to article fields. */
private const ARTICLE_FIELD_ALIASES = [
'produktart' => 'articleTypeName',
'marke' => 'manufacturer',
'brand' => 'manufacturer',
'hersteller' => 'manufacturer',
'herstellernummer' => 'modelNumber',
'mpn' => 'modelNumber',
'teilenummer' => 'modelNumber',
'modell' => 'modelName',
'seriennummer' => 'serialNumber',
];
#[Route('/admin/ebay/aspect-import/{id}', name: 'admin_ebay_aspect_import')] #[Route('/admin/ebay/aspect-import/{id}', name: 'admin_ebay_aspect_import')]
#[IsGranted('ROLE_USER')] #[IsGranted('ROLE_USER')]
public function __invoke(string $id, Request $request): Response public function __invoke(string $id, Request $request): Response
@ -82,6 +54,7 @@ final class EbayAspectImportController extends AbstractController
throw $this->createNotFoundException(); throw $this->createNotFoundException();
} }
// Save category selection (step 1 of the 2-step flow)
if ($request->isMethod('POST') && 'set-category' === $request->request->get('_action')) { if ($request->isMethod('POST') && 'set-category' === $request->request->get('_action')) {
return $this->handleSetCategory($request, $articleType); return $this->handleSetCategory($request, $articleType);
} }
@ -119,11 +92,9 @@ final class EbayAspectImportController extends AbstractController
'articleType' => $articleType, 'articleType' => $articleType,
'rows' => $rows, 'rows' => $rows,
'allDefs' => $allDefs, 'allDefs' => $allDefs,
'articleFields' => self::ARTICLE_FIELDS,
'counts' => $counts, 'counts' => $counts,
'categoryId' => $categoryId, 'categoryId' => $categoryId,
'searchUrl' => $this->generateUrl('admin_ebay_category_search'), 'searchUrl' => $this->generateUrl('admin_ebay_category_search'),
'existingMappings' => $articleType->getEbayMappings(),
]); ]);
} }
@ -135,9 +106,7 @@ final class EbayAspectImportController extends AbstractController
$this->em->flush(); $this->em->flush();
} }
return $this->redirect( return $this->redirectToRoute('admin_ebay_aspect_import', ['id' => $articleType->getId()->toRfc4122()]);
$this->adminUrlGenerator->setRoute('admin_ebay_aspect_import', ['id' => $articleType->getId()->toRfc4122()])->generateUrl()
);
} }
private function handleImport(Request $request, ArticleType $articleType): Response private function handleImport(Request $request, ArticleType $articleType): Response
@ -155,31 +124,16 @@ final class EbayAspectImportController extends AbstractController
if (!\is_array($rawData)) { if (!\is_array($rawData)) {
continue; continue;
} }
// HTTP POST values are always strings; cast once to satisfy PHPStan level 9
/** @var array<string, string> $data */ /** @var array<string, string> $data */
$data = array_map(static fn (mixed $v): string => \is_scalar($v) ? (string) $v : '', $rawData); $data = array_map(static fn (mixed $v): string => \is_scalar($v) ? (string) $v : '', $rawData);
$action = $data['action'] ?? 'skip'; $action = $data['action'] ?? 'skip';
$ebayName = $data['ebayName'] ?? ''; if ('skip' === $action) {
$isRequired = ($data['ebayRequired'] ?? '0') === '1';
if ('skip' === $action || '' === $ebayName) {
continue; continue;
} }
if ('article_field' === $action) { $markRequired = ($data['required'] ?? '0') === '1';
$fieldKey = $data['articleField'] ?? '';
if ('' === $fieldKey || !isset(self::ARTICLE_FIELDS[$fieldKey])) {
continue;
}
$mapping = new ArticleTypeEbayMapping($articleType, $ebayName, ArticleTypeEbayMapping::SOURCE_ARTICLE_FIELD);
$mapping->setArticleFieldKey($fieldKey);
$mapping->setRequired($isRequired);
$articleType->upsertEbayMapping($mapping);
++$imported;
continue;
}
$markRequired = $isRequired || ($data['required'] ?? '0') === '1';
if ('match' === $action) { if ('match' === $action) {
$def = $this->em->find(AttributeDefinition::class, $data['definitionId'] ?? ''); $def = $this->em->find(AttributeDefinition::class, $data['definitionId'] ?? '');
@ -209,11 +163,6 @@ final class EbayAspectImportController extends AbstractController
$this->em->persist($def); $this->em->persist($def);
} }
$mapping = new ArticleTypeEbayMapping($articleType, $ebayName, ArticleTypeEbayMapping::SOURCE_ATTRIBUTE);
$mapping->setAttributeDefinition($def);
$mapping->setRequired($markRequired);
$articleType->upsertEbayMapping($mapping);
if ($markRequired) { if ($markRequired) {
$requiredDefs[] = $def; $requiredDefs[] = $def;
} else { } else {
@ -260,63 +209,29 @@ final class EbayAspectImportController extends AbstractController
* @param list<array{name: string, required: bool, usage: string, values: list<string>}> $aspects * @param list<array{name: string, required: bool, usage: string, values: list<string>}> $aspects
* @param list<AttributeDefinition> $allDefs * @param list<AttributeDefinition> $allDefs
* *
* @return list<array{aspect: array{name: string, required: bool, usage: string, values: list<string>}, action: string, preMatchId: string|null, preFieldKey: string|null, alreadyAssigned: bool, suggestedType: string}> * @return list<array{aspect: array{name: string, required: bool, usage: string, values: list<string>}, action: string, preMatchId: string|null, suggestedType: string}>
*/ */
private function buildRows(array $aspects, array $allDefs, ArticleType $articleType): array private function buildRows(array $aspects, array $allDefs, ArticleType $articleType): array
{ {
// Build name → def map for auto-matching
$defsByName = []; $defsByName = [];
foreach ($allDefs as $def) { foreach ($allDefs as $def) {
$defsByName[mb_strtolower(trim($def->getName()))] = $def; $defsByName[mb_strtolower(trim($def->getName()))] = $def;
} }
// Track which defs are already assigned to this article type
$assignedDefIds = []; $assignedDefIds = [];
foreach ($articleType->getAttributeAssignments() as $assignment) { foreach ($articleType->getAttributeAssignments() as $assignment) {
$assignedDefIds[$assignment->getAttributeDefinition()->getId()->toRfc4122()] = true; $assignedDefIds[$assignment->getAttributeDefinition()->getId()->toRfc4122()] = true;
} }
// Index existing mappings by eBay aspect name for O(1) lookup
$existingMappings = [];
foreach ($articleType->getEbayMappings() as $m) {
$existingMappings[$m->getEbayAspectName()] = $m;
}
$rows = []; $rows = [];
foreach ($aspects as $aspect) { foreach ($aspects as $aspect) {
$normalized = mb_strtolower(trim($aspect['name'])); $normalized = mb_strtolower(trim($aspect['name']));
// Highest priority: already stored mapping
if (isset($existingMappings[$aspect['name']])) {
$existing = $existingMappings[$aspect['name']];
$isFieldMapping = ArticleTypeEbayMapping::SOURCE_ARTICLE_FIELD === $existing->getSourceType();
$rows[] = [
'aspect' => $aspect,
'action' => $isFieldMapping ? 'article_field' : 'match',
'preMatchId' => $isFieldMapping ? null : $existing->getAttributeDefinition()?->getId()->toRfc4122(),
'preFieldKey' => $isFieldMapping ? $existing->getArticleFieldKey() : null,
'alreadyAssigned' => true,
'suggestedType' => 'string',
];
continue;
}
// Auto-detect article field by alias
$autoField = self::ARTICLE_FIELD_ALIASES[$normalized] ?? null;
if (null !== $autoField) {
$rows[] = [
'aspect' => $aspect,
'action' => 'article_field',
'preMatchId' => null,
'preFieldKey' => $autoField,
'alreadyAssigned' => false,
'suggestedType' => 'string',
];
continue;
}
$match = $defsByName[$normalized] ?? null; $match = $defsByName[$normalized] ?? null;
$alreadyAssigned = $match && isset($assignedDefIds[$match->getId()->toRfc4122()]); $alreadyAssigned = $match && isset($assignedDefIds[$match->getId()->toRfc4122()]);
if (null !== $match) { if ($match !== null) {
$action = 'match'; $action = 'match';
$preMatchId = $match->getId()->toRfc4122(); $preMatchId = $match->getId()->toRfc4122();
} elseif ($aspect['required'] || 'RECOMMENDED' === $aspect['usage']) { } elseif ($aspect['required'] || 'RECOMMENDED' === $aspect['usage']) {
@ -335,7 +250,6 @@ final class EbayAspectImportController extends AbstractController
'aspect' => $aspect, 'aspect' => $aspect,
'action' => $action, 'action' => $action,
'preMatchId' => $preMatchId, 'preMatchId' => $preMatchId,
'preFieldKey' => null,
'alreadyAssigned' => $alreadyAssigned, 'alreadyAssigned' => $alreadyAssigned,
'suggestedType' => $suggestedType, 'suggestedType' => $suggestedType,
]; ];

View file

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Infrastructure\Persistence\Repository; namespace App\Infrastructure\Persistence\Repository;
use App\Domain\Article\ArticleType;
use App\Domain\Channel\ArticleTypePlatformConfig; use App\Domain\Channel\ArticleTypePlatformConfig;
use App\Domain\Channel\Repository\ArticleTypePlatformConfigRepositoryInterface; use App\Domain\Channel\Repository\ArticleTypePlatformConfigRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@ -47,21 +46,6 @@ final class DoctrineArticleTypePlatformConfigRepository implements ArticleTypePl
->getResult(); ->getResult();
} }
public function findByArticleTypeAndPlatformType(ArticleType $articleType, string $platformType): ?ArticleTypePlatformConfig
{
/** @var ArticleTypePlatformConfig|null */
return $this->em->getRepository(ArticleTypePlatformConfig::class)
->createQueryBuilder('c')
->join('c.platform', 'p')
->where('c.articleType = :articleType')
->andWhere('p.type = :platformType')
->setParameter('articleType', $articleType)
->setParameter('platformType', $platformType)
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
public function save(ArticleTypePlatformConfig $config): void public function save(ArticleTypePlatformConfig $config): void
{ {
$this->em->persist($config); $this->em->persist($config);

View file

@ -43,41 +43,6 @@
{% if categoryId %} {% if categoryId %}
{# ── Existing mappings table ──────────────────────────────────────── #}
{% if existingMappings|length > 0 %}
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="fa fa-table me-2"></i>Aktive Mappings <span class="badge bg-secondary ms-1">{{ existingMappings|length }}</span></h5>
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
<th>eBay-Aspekt (Ziel)</th>
<th>Quelle</th>
<th class="text-center" style="width:6rem">Pflicht?</th>
</tr>
</thead>
<tbody>
{% for mapping in existingMappings %}
<tr>
<td class="fw-medium">{{ mapping.ebayAspectName }}</td>
<td class="text-muted">{{ mapping.sourceLabel }}</td>
<td class="text-center">
{% if mapping.required %}
<span class="badge bg-danger">Ja</span>
{% else %}
<span class="text-muted">—</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{# ── Summary bar ──────────────────────────────────────────────────── #} {# ── Summary bar ──────────────────────────────────────────────────── #}
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex gap-2 align-items-center"> <div class="d-flex gap-2 align-items-center">
@ -155,9 +120,8 @@
class="form-select form-select-sm aspect-action" class="form-select form-select-sm aspect-action"
data-index="{{ i }}"> data-index="{{ i }}">
<option value="skip" {% if row.action == 'skip' %}selected{% endif %}>— Überspringen</option> <option value="skip" {% if row.action == 'skip' %}selected{% endif %}>— Überspringen</option>
<option value="article_field" {% if row.action == 'article_field' %}selected{% endif %}>→ Artikelfeld</option> <option value="match" {% if row.action == 'match' %}selected{% endif %}>Vorhandenes verknüpfen</option>
<option value="match" {% if row.action == 'match' %}selected{% endif %}>Attribut verknüpfen</option> <option value="create" {% if row.action == 'create' %}selected{% endif %}>Neu anlegen</option>
<option value="create" {% if row.action == 'create' %}selected{% endif %}>Attribut anlegen</option>
</select> </select>
<input type="hidden" name="aspects[{{ i }}][ebayName]" value="{{ aspect.name }}"> <input type="hidden" name="aspects[{{ i }}][ebayName]" value="{{ aspect.name }}">
<input type="hidden" name="aspects[{{ i }}][ebayValues]" value="{{ aspect.values|join(',') }}"> <input type="hidden" name="aspects[{{ i }}][ebayValues]" value="{{ aspect.values|join(',') }}">
@ -165,19 +129,10 @@
</td> </td>
<td> <td>
<div class="section-article_field-{{ i }}" style="display:{% if row.action == 'article_field' %}block{% else %}none{% endif %};">
<select name="aspects[{{ i }}][articleField]" class="form-select form-select-sm">
{% for fieldKey, fieldLabel in articleFields %}
<option value="{{ fieldKey }}" {% if row.preFieldKey == fieldKey %}selected{% endif %}>
{{ fieldLabel }}
</option>
{% endfor %}
</select>
</div>
<div class="section-match-{{ i }}" style="display:{% if row.action == 'match' %}block{% else %}none{% endif %};"> <div class="section-match-{{ i }}" style="display:{% if row.action == 'match' %}block{% else %}none{% endif %};">
<select name="aspects[{{ i }}][definitionId]" class="form-select form-select-sm"> <select name="aspects[{{ i }}][definitionId]" class="form-select form-select-sm">
{% for def in allDefs %} {% for def in allDefs %}
<option value="{{ def.id.toRfc4122() }}" {% if row.preMatchId == def.id.toRfc4122() %}selected{% endif %}> <option value="{{ def.id }}" {% if row.preMatchId == def.id|toString %}selected{% endif %}>
{{ def.name }} ({{ def.type.value }}) {{ def.name }} ({{ def.type.value }})
</option> </option>
{% endfor %} {% endfor %}
@ -204,7 +159,7 @@
</td> </td>
<td class="text-center"> <td class="text-center">
<div class="section-req-{{ i }}" style="display:{% if row.action != 'skip' and row.action != 'article_field' %}block{% else %}none{% endif %};"> <div class="section-req-{{ i }}" style="display:{% if row.action != 'skip' %}block{% else %}none{% endif %};">
<input type="checkbox" <input type="checkbox"
name="aspects[{{ i }}][required]" name="aspects[{{ i }}][required]"
value="1" value="1"
@ -308,12 +263,12 @@
/* ── Aspect table JS ─────────────────────────────────────────── */ /* ── Aspect table JS ─────────────────────────────────────────── */
function syncRow(index, action) { function syncRow(index, action) {
['article_field', 'match', 'create', 'skip'].forEach(s => { ['match', 'create', 'skip'].forEach(s => {
const el = document.querySelector(`.section-${s}-${index}`); const el = document.querySelector(`.section-${s}-${index}`);
if (el) el.style.display = s === action ? 'block' : 'none'; if (el) el.style.display = s === action ? 'block' : 'none';
}); });
const req = document.querySelector(`.section-req-${index}`); const req = document.querySelector(`.section-req-${index}`);
if (req) req.style.display = (action !== 'skip' && action !== 'article_field') ? 'block' : 'none'; if (req) req.style.display = action !== 'skip' ? 'block' : 'none';
} }
document.querySelectorAll('.aspect-action').forEach(sel => { document.querySelectorAll('.aspect-action').forEach(sel => {

View file

@ -7,9 +7,6 @@ namespace App\Tests\Integration\Infrastructure\Channel\Ebay;
use App\Domain\Article\Article; use App\Domain\Article\Article;
use App\Domain\Article\ArticleCondition; use App\Domain\Article\ArticleCondition;
use App\Domain\Article\ArticleType; use App\Domain\Article\ArticleType;
use App\Domain\Channel\ArticleTypePlatformConfig;
use App\Domain\Channel\Platform;
use App\Domain\Channel\Repository\ArticleTypePlatformConfigRepositoryInterface;
use App\Infrastructure\Channel\Ebay\EbayAdapter; use App\Infrastructure\Channel\Ebay\EbayAdapter;
use App\Infrastructure\Channel\Ebay\EbayInventoryApiClient; use App\Infrastructure\Channel\Ebay\EbayInventoryApiClient;
use App\Infrastructure\Channel\Ebay\EbayOAuthClient; use App\Infrastructure\Channel\Ebay\EbayOAuthClient;
@ -38,47 +35,45 @@ final class EbayAdapterIntegrationTest extends TestCase
private string $createdListingId = ''; private string $createdListingId = '';
private bool $userTokenAvailable = false; private bool $userTokenAvailable = false;
private static function env(string $key, string $default = ''): string
{
$val = $_SERVER[$key] ?? getenv($key);
return \is_string($val) ? $val : $default;
}
protected function setUp(): void protected function setUp(): void
{ {
$clientId = self::env('EBAY_CLIENT_ID'); $clientId = $_SERVER['EBAY_CLIENT_ID'] ?? getenv('EBAY_CLIENT_ID');
$clientSecret = self::env('EBAY_CLIENT_SECRET'); $clientSecret = $_SERVER['EBAY_CLIENT_SECRET'] ?? getenv('EBAY_CLIENT_SECRET');
$apiBaseUrl = self::env('EBAY_API_BASE_URL'); $apiBaseUrl = $_SERVER['EBAY_API_BASE_URL'] ?? getenv('EBAY_API_BASE_URL');
$oauthBaseUrl = self::env('EBAY_OAUTH_BASE_URL'); $oauthBaseUrl = $_SERVER['EBAY_OAUTH_BASE_URL'] ?? getenv('EBAY_OAUTH_BASE_URL');
$marketplaceId = self::env('EBAY_MARKETPLACE_ID', 'EBAY_DE'); $marketplaceId = $_SERVER['EBAY_MARKETPLACE_ID'] ?? getenv('EBAY_MARKETPLACE_ID') ?: 'EBAY_DE';
if ('' === $clientId || '' === $clientSecret || '' === $apiBaseUrl || '' === $oauthBaseUrl) { if (!$clientId || !$clientSecret || !$apiBaseUrl || !$oauthBaseUrl) {
$this->markTestSkipped('EBAY_* env vars not set'); $this->markTestSkipped('EBAY_* env vars not set');
} }
$http = HttpClient::create(); $http = HttpClient::create();
$cache = new ArrayAdapter(); $cache = new ArrayAdapter();
$oauth = new EbayOAuthClient($http, $cache, $clientId, $clientSecret, $oauthBaseUrl); $oauth = new EbayOAuthClient(
$http,
$cache,
(string) $clientId,
(string) $clientSecret,
(string) $oauthBaseUrl,
);
$this->apiClient = new EbayInventoryApiClient($http, $oauth, $apiBaseUrl, $marketplaceId); $this->apiClient = new EbayInventoryApiClient(
$http,
$oauth,
(string) $apiBaseUrl,
(string) $marketplaceId,
);
$articleType = new ArticleType('Notebook'); $this->adapter = new EbayAdapter($this->apiClient);
$platform = new Platform('ebay', 'eBay');
// Sandbox category 177 = Notebooks; no policies needed for sandbox withdraw/stock tests
$platformConfig = new ArticleTypePlatformConfig($articleType, $platform, '177');
$configRepo = $this->createStub(ArticleTypePlatformConfigRepositoryInterface::class); $userToken = $_SERVER['EBAY_USER_TOKEN'] ?? getenv('EBAY_USER_TOKEN');
$configRepo->method('findByArticleTypeAndPlatformType')->willReturn($platformConfig); $this->userTokenAvailable = (bool) $userToken;
$this->adapter = new EbayAdapter($this->apiClient, $configRepo, $marketplaceId); // Build a realistic sandbox article for listing tests
$type = new ArticleType('Notebook');
$this->userTokenAvailable = '' !== self::env('EBAY_USER_TOKEN');
// Build a realistic sandbox article for listing tests (reuse same $articleType so config matches)
$this->article = new Article( $this->article = new Article(
$articleType, $type,
'SS3K-TEST-'.time(), 'SS3K-TEST-'.time(),
'INV-TEST-001', 'INV-TEST-001',
1, 1,
@ -103,7 +98,7 @@ final class EbayAdapterIntegrationTest extends TestCase
} }
} }
public function testPublishListingCreatesLiveSandboxListing(): void public function test_publish_listing_creates_live_sandbox_listing(): void
{ {
if (!$this->userTokenAvailable) { if (!$this->userTokenAvailable) {
$this->markTestSkipped( $this->markTestSkipped(
@ -121,7 +116,7 @@ final class EbayAdapterIntegrationTest extends TestCase
$this->article->setEbayListingId($listingId); $this->article->setEbayListingId($listingId);
} }
public function testUpdateStockChangesQuantity(): void public function test_update_stock_changes_quantity(): void
{ {
if (!$this->userTokenAvailable) { if (!$this->userTokenAvailable) {
$this->markTestSkipped('EBAY_USER_TOKEN not set — see test_publish_listing_creates_live_sandbox_listing'); $this->markTestSkipped('EBAY_USER_TOKEN not set — see test_publish_listing_creates_live_sandbox_listing');
@ -136,7 +131,7 @@ final class EbayAdapterIntegrationTest extends TestCase
$this->addToAssertionCount(1); $this->addToAssertionCount(1);
} }
public function testDeactivateListingWithdrawsOffer(): void public function test_deactivate_listing_withdraws_offer(): void
{ {
if (!$this->userTokenAvailable) { if (!$this->userTokenAvailable) {
$this->markTestSkipped('EBAY_USER_TOKEN not set — see test_publish_listing_creates_live_sandbox_listing'); $this->markTestSkipped('EBAY_USER_TOKEN not set — see test_publish_listing_creates_live_sandbox_listing');
@ -151,7 +146,7 @@ final class EbayAdapterIntegrationTest extends TestCase
$this->addToAssertionCount(1); $this->addToAssertionCount(1);
} }
public function testDeactivateListingIsNoopWhenNoListingId(): void public function test_deactivate_listing_is_noop_when_no_listing_id(): void
{ {
// No listing ID set — should silently return, no API call // No listing ID set — should silently return, no API call
$this->adapter->deactivateListing($this->article); $this->adapter->deactivateListing($this->article);

View file

@ -134,20 +134,4 @@ final class EbayTaxonomyIntegrationTest extends TestCase
$names = array_map(static fn (array $a) => $a['name'], $aspects); $names = array_map(static fn (array $a) => $a['name'], $aspects);
$this->assertNotEmpty($names); $this->assertNotEmpty($names);
} }
public function test_category_suggestions_returns_results(): void
{
$results = $this->taxonomy->getCategorySuggestions('Notebook');
$this->assertNotEmpty($results, 'Sandbox must return at least one suggestion for "Notebook"');
foreach ($results as $result) {
$this->assertArrayHasKey('id', $result);
$this->assertArrayHasKey('name', $result);
$this->assertArrayHasKey('path', $result);
$this->assertIsString($result['id']);
$this->assertNotEmpty($result['id']);
$this->assertIsString($result['name']);
}
}
} }

View file

@ -7,9 +7,6 @@ namespace App\Tests\Unit\Infrastructure\Channel\Ebay;
use App\Domain\Article\Article; use App\Domain\Article\Article;
use App\Domain\Article\ArticleCondition; use App\Domain\Article\ArticleCondition;
use App\Domain\Article\ArticleType; use App\Domain\Article\ArticleType;
use App\Domain\Channel\ArticleTypePlatformConfig;
use App\Domain\Channel\Platform;
use App\Domain\Channel\Repository\ArticleTypePlatformConfigRepositoryInterface;
use App\Infrastructure\Channel\Ebay\EbayAdapter; use App\Infrastructure\Channel\Ebay\EbayAdapter;
use App\Infrastructure\Channel\Ebay\EbayInventoryApiClient; use App\Infrastructure\Channel\Ebay\EbayInventoryApiClient;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
@ -18,35 +15,25 @@ use PHPUnit\Framework\TestCase;
final class EbayAdapterTest extends TestCase final class EbayAdapterTest extends TestCase
{ {
private EbayInventoryApiClient&MockObject $apiClient; private EbayInventoryApiClient&MockObject $apiClient;
private ArticleTypePlatformConfigRepositoryInterface&MockObject $configRepo;
private EbayAdapter $adapter; private EbayAdapter $adapter;
private Article $article; private Article $article;
private ArticleTypePlatformConfig $platformConfig;
protected function setUp(): void protected function setUp(): void
{ {
$this->apiClient = $this->createMock(EbayInventoryApiClient::class); $this->apiClient = $this->createMock(EbayInventoryApiClient::class);
$this->configRepo = $this->createMock(ArticleTypePlatformConfigRepositoryInterface::class); $this->adapter = new EbayAdapter($this->apiClient);
$this->adapter = new EbayAdapter($this->apiClient, $this->configRepo, 'EBAY_DE'); $this->article = new Article(new ArticleType('Notebook'), 'NB-001', 'INV-001', 1, ArticleCondition::Good);
$articleType = new ArticleType('Notebook');
$platform = new Platform('ebay', 'eBay');
$this->platformConfig = new ArticleTypePlatformConfig($articleType, $platform, '177');
$this->article = new Article($articleType, 'NB-001', 'INV-001', 1, ArticleCondition::Good);
$this->article->setEbayTitle('Dell Latitude 5520'); $this->article->setEbayTitle('Dell Latitude 5520');
$this->article->setListingPrice('299.00'); $this->article->setListingPrice('299.00');
} }
public function testGetTypeReturnsEbay(): void public function test_get_type_returns_ebay(): void
{ {
$this->assertSame('ebay', $this->adapter->getType()); $this->assertSame('ebay', $this->adapter->getType());
} }
public function testPublishListingCallsUpsertCreateAndPublish(): void public function test_publish_listing_calls_upsert_create_and_publish(): void
{ {
$this->configRepo->method('findByArticleTypeAndPlatformType')->willReturn($this->platformConfig);
$this->apiClient->expects($this->once())->method('upsertInventoryItem'); $this->apiClient->expects($this->once())->method('upsertInventoryItem');
$this->apiClient->expects($this->once())->method('createOffer')->willReturn('offer-123'); $this->apiClient->expects($this->once())->method('createOffer')->willReturn('offer-123');
$this->apiClient->expects($this->once())->method('publishOffer')->with('offer-123')->willReturn('listing-456'); $this->apiClient->expects($this->once())->method('publishOffer')->with('offer-123')->willReturn('listing-456');
@ -56,57 +43,7 @@ final class EbayAdapterTest extends TestCase
$this->assertSame('listing-456', $listingId); $this->assertSame('listing-456', $listingId);
} }
public function testPublishListingThrowsWhenNoPlatformConfig(): void public function test_deactivate_listing_withdraws_offer(): void
{
$this->configRepo->method('findByArticleTypeAndPlatformType')->willReturn(null);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('No eBay platform config found for ArticleType');
$this->adapter->publishListing($this->article);
}
public function testPublishListingIncludesPoliciesWhenSet(): void
{
$this->platformConfig->setFulfillmentPolicyId('FP-123');
$this->platformConfig->setPaymentPolicyId('PP-456');
$this->platformConfig->setReturnPolicyId('RP-789');
$this->platformConfig->setMerchantLocationKey('WAREHOUSE-1');
$this->configRepo->method('findByArticleTypeAndPlatformType')->willReturn($this->platformConfig);
$this->apiClient->expects($this->once())->method('upsertInventoryItem');
$this->apiClient->expects($this->once())
->method('createOffer')
->with($this->callback(static function (array $body): bool {
return isset($body['listingPolicies']['fulfillmentPolicyId'])
&& 'FP-123' === $body['listingPolicies']['fulfillmentPolicyId']
&& 'PP-456' === $body['listingPolicies']['paymentPolicyId']
&& 'RP-789' === $body['listingPolicies']['returnPolicyId']
&& 'WAREHOUSE-1' === $body['merchantLocationKey'];
}))
->willReturn('offer-123');
$this->apiClient->method('publishOffer')->willReturn('listing-456');
$this->adapter->publishListing($this->article);
}
public function testPublishListingOmitsPoliciesWhenNotSet(): void
{
$this->configRepo->method('findByArticleTypeAndPlatformType')->willReturn($this->platformConfig);
$this->apiClient->expects($this->once())->method('upsertInventoryItem');
$this->apiClient->expects($this->once())
->method('createOffer')
->with($this->callback(static function (array $body): bool {
return !isset($body['listingPolicies']) && !isset($body['merchantLocationKey']);
}))
->willReturn('offer-123');
$this->apiClient->method('publishOffer')->willReturn('listing-456');
$this->adapter->publishListing($this->article);
}
public function testDeactivateListingWithdrawsOffer(): void
{ {
$this->article->setEbayListingId('offer-123'); $this->article->setEbayListingId('offer-123');
@ -115,7 +52,7 @@ final class EbayAdapterTest extends TestCase
$this->adapter->deactivateListing($this->article); $this->adapter->deactivateListing($this->article);
} }
public function testDeactivateListingIsNoopWhenNoListingId(): void public function test_deactivate_listing_is_noop_when_no_listing_id(): void
{ {
$this->apiClient->expects($this->never())->method('withdrawOffer'); $this->apiClient->expects($this->never())->method('withdrawOffer');

View file

@ -10,8 +10,6 @@ menu.ai_prompts: 'KI-Prompts'
menu.translations: Übersetzungen menu.translations: Übersetzungen
menu.users: Benutzer menu.users: Benutzer
menu.logs: Logs menu.logs: Logs
menu.section_ebay: eBay
menu.ebay_platform_configs: 'Kategorie-Konfigurationen'
menu.section_sales: Verkauf menu.section_sales: Verkauf
menu.orders: Bestellungen menu.orders: Bestellungen
menu.customers: Kunden menu.customers: Kunden

View file

@ -10,8 +10,6 @@ menu.ai_prompts: 'AI Prompts'
menu.translations: Translations menu.translations: Translations
menu.users: Users menu.users: Users
menu.logs: Logs menu.logs: Logs
menu.section_ebay: eBay
menu.ebay_platform_configs: 'Category Configurations'
menu.section_sales: Sales menu.section_sales: Sales
menu.orders: Orders menu.orders: Orders
menu.customers: Customers menu.customers: Customers