diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9ff5306 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,107 @@ +# 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. diff --git a/docs/superpowers/plans/2026-05-19-07-ebay-admin-policies.md b/docs/superpowers/plans/2026-05-19-07-ebay-admin-policies.md new file mode 100644 index 0000000..a3f38dc --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-07-ebay-admin-policies.md @@ -0,0 +1,268 @@ +# 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 */ + public function getFulfillmentPolicies(): array { ... } + + /** @return list */ + public function getPaymentPolicies(): array { ... } + + /** @return list */ + 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 */ +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 Label => ID */ + public function getFulfillmentChoices(): array { ... } + + /** @return array */ + public function getPaymentChoices(): array { ... } + + /** @return array */ + public function getReturnChoices(): array { ... } + + /** @return array 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` — 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 +```