Compare commits

..

10 commits

Author SHA1 Message Date
6bd8e0bec8 feat: eBay business policies + per-adapter admin navigation
Some checks are pending
CI / test (push) Waiting to run
- ArticleTypePlatformConfig: fulfillmentPolicyId, paymentPolicyId,
  returnPolicyId, merchantLocationKey (all nullable)
- EbayAccountApiClient: fetches Fulfillment/Payment/Return policies
  from eBay Account API (/sell/account/v1)
- EbayInventoryApiClient: adds getLocations()
- EbayPolicyProvider: aggregates choices with 5 min cache; returns
  empty array on API failure so the form degrades to TextField
- EbayAdapter: reads real ArticleTypePlatformConfig (category ID no
  longer hardcoded), passes listingPolicies + merchantLocationKey
  into createOffer() when set
- EbayArticleTypePlatformConfigCrudController: live policy dropdowns
  from EbayPolicyProvider; fallback to TextField with help text
- DashboardController: eBay subMenu with Kategorie-Konfigurationen
- 7 new unit tests for EbayAdapter policy scenarios

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 07:13:51 +00:00
31c5116a1b docs: add CLAUDE.md and Plan 7 (eBay admin navigation + business policies)
Plan 7 covers: ArticleTypePlatformConfig policy fields, EbayAccountApiClient,
EbayPolicyProvider with live dropdown choices, EbayAdapter reading real config,
and per-adapter admin navigation section.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 06:25:14 +00:00
371213dbbb docs: update design doc + add infrastructure runbook
- Remove Ollama as AI backend (never used); Mistral is primary throughout
- Add infrastructure.md: full Docker setup, queue architecture, prod
  deployment steps, env vars — prod has no staging ERP container

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 05:59:21 +00:00
bf1af0a0bf feat: replace JSON ebay mappings with ArticleTypeEbayMapping entity
Introduces a proper key-value table (article_type_ebay_mappings) that
explicitly maps each eBay aspect name to either an Article field
(manufacturer, modelNumber, …) or an AttributeDefinition, with a
required flag per mapping entry.

- New entity ArticleTypeEbayMapping with SOURCE_ARTICLE_FIELD / SOURCE_ATTRIBUTE
- ArticleType gains OneToMany ebayMappings collection with upsertEbayMapping()
- EbayAdapter.buildAspects() reads from the mapping table instead of implicit name-matching
- Import controller persists mappings via upsertEbayMapping() and syncs required attribute assignments
- Template shows active mappings card and article_field action option
- Migration 20260520100000 creates the new table, drops old JSON column

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 20:52:25 +00:00
9259b99e7d fix: allow null for ebayAspectFieldMappings on existing rows
Doctrine sets nullable JSON columns to null when the DB value is NULL.
Typed array property cannot hold null — changing to ?array and coercing
to [] in the getter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 20:42:30 +00:00
ed2b83ba9f feat: map eBay 'Produktart' aspect to ArticleType name
Adds articleTypeName as a mappable article field — Article.getArticleTypeName()
proxies to articleType.getName(). 'Produktart' auto-detects via alias, so the
import UI pre-selects this mapping and the eBay listing gets the type name as
the Produktart aspect value automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 20:40:28 +00:00
61ce94bc6f feat: map eBay aspects to Article fields (Marke→manufacturer, PN→modelNumber)
Adds an 'Artikelfeld' action in the aspect import UI alongside skip/match/create.
Aspects like 'Marke' and 'Herstellernummer' auto-detect to manufacturer/modelNumber
via ARTICLE_FIELD_ALIASES. Mappings are persisted as a JSON column on ArticleType.
EbayAdapter.buildAspects() now reads these mappings and populates them from the
article's direct fields when building eBay listing aspects.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 20:34:49 +00:00
929f5a0b2d test: add category suggestions integration test
Verifies getCategorySuggestions() returns id/name/path shaped results
from the eBay sandbox Taxonomy API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 20:29:47 +00:00
0fdb4979c1 fix: route EbayAspectImportController redirects through AdminUrlGenerator
Plain redirectToRoute() bypasses EasyAdmin's AdminRouterSubscriber, so
the admin context (ea.i18n etc.) was never set and the EasyAdmin layout
threw "Impossible to access i18n on null". Using AdminUrlGenerator wraps
the redirect URL in the EasyAdmin routing layer, keeping context alive.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 20:15:31 +00:00
53e2d36574 fix: replace unknown Twig toString filter with toRfc4122() call
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 20:12:17 +00:00
27 changed files with 1875 additions and 93 deletions

107
CLAUDE.md Normal file
View file

@ -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.

View file

@ -120,6 +120,8 @@ 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:
@ -134,6 +136,15 @@ 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

@ -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<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/ # OllamaClient, MistralClient (beide hinter OllamaClientInterface) AI/ # MistralClient hinter OllamaClientInterface (Interfacename historisch)
# OllamaVisionAgent, SpecsResearchAgent, JsonCodingAgent, EbayTextAgent # VisionAgent, 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, Ollama, SMTP) implementiert ein Interface aus dem Application-Layer. Neue Plattform = neue Adapter-Klasse, Domain bleibt unberührt. Jeder externe Dienst (eBay, Frappe ERP, Mistral, SMTP) implementiert ein Interface aus dem Application-Layer. Neue Plattform = neue Adapter-Klasse, Domain bleibt unberührt.
### 2.2 Tech-Stack ### 2.2 Tech-Stack
@ -51,7 +51,7 @@ Jeder externe Dienst (eBay, Frappe ERP, Ollama, SMTP) implementiert ein Interfac
| 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 | **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 | | AI | **Mistral Cloud API** — Vision: `pixtral-12b-2409`, Text: `mistral-large-latest`; Modelle via `AI_TEXT_MODEL` / `AI_VISION_MODEL` env vars konfigurierbar |
| Web-Suche | Tavily API (`TAVILY_API_KEY`) — liefert strukturierte Suchergebnisse für SpecsResearchAgent | | 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
``` ```
**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. **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.
--- ---
@ -216,14 +216,14 @@ ApiKey
``` ```
1. Foto-Upload → PhotoUploadMessage in Queue (redis://ai_pipeline) 1. Foto-Upload → PhotoUploadMessage in Queue (redis://ai_pipeline)
2. OllamaVisionAgent — LLaVA liest Typenschild 2. VisionAgent — Mistral Pixtral 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 Ollama-Call: Specs-Text → JSON gegen ArticleType-Schema 5. JsonCodingAgent — strukturierter Mistral-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
- **Ollama:** lokal, Zugriff nur via SSH-Tunnel (autossh für Persistenz + Auto-Reconnect) - **Mistral API:** Key in `.env.local`, nie in Git; HTTPS-only
- **Gitea:** Registrierung deaktiviert, nur Admin-Anlage, SSH-Key-Auth - **Gitea:** Registrierung deaktiviert, nur Admin-Anlage, SSH-Key-Auth
### Applikation ### Applikation
@ -440,31 +440,21 @@ 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-Backend wechseln ### 11.3 AI-Konfiguration
**Ollama (Standard, lokal):** **Primär: Mistral Cloud API** (Standard, bereits so konfiguriert):
```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
AI_TEXT_MODEL=${MISTRAL_TEXT_MODEL} # .env.local
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
``` ```
Vision erfordert Pixtral (`pixtral-12b-2409`). Nach Änderung: `cache:clear` + FPM reload. Nach Schlüsseländerung: `docker compose exec app php bin/console cache:clear`.
### 11.4 Pipeline starten ### 11.4 Pipeline starten

View file

@ -0,0 +1,546 @@
# 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

@ -0,0 +1,42 @@
<?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

@ -0,0 +1,26 @@
<?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

@ -0,0 +1,44 @@
<?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,6 +135,11 @@ 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,6 +27,10 @@ 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;
@ -38,6 +42,7 @@ 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
@ -70,6 +75,25 @@ 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

@ -0,0 +1,83 @@
<?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,6 +30,18 @@ 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;
@ -68,6 +80,46 @@ 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,6 +4,7 @@ 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;
@ -16,6 +17,8 @@ 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

@ -0,0 +1,80 @@
<?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,12 +7,16 @@ 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,
) { ) {
} }
@ -24,6 +28,14 @@ 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' => [
@ -40,11 +52,18 @@ final class EbayAdapter implements ChannelAdapterInterface
], ],
]); ]);
$offerId = $this->apiClient->createOffer([ $listingPolicies = array_filter([
'fulfillmentPolicyId' => $config->getFulfillmentPolicyId(),
'paymentPolicyId' => $config->getPaymentPolicyId(),
'returnPolicyId' => $config->getReturnPolicyId(),
]);
$offerBody = [
'sku' => $sku, 'sku' => $sku,
'marketplaceId' => 'EBAY_DE', 'marketplaceId' => $this->marketplaceId,
'format' => 'FIXED_PRICE', 'format' => 'FIXED_PRICE',
'availableQuantity' => $article->getStock(), 'availableQuantity' => $article->getStock(),
'categoryId' => $config->getCategoryId(),
'pricingSummary' => [ 'pricingSummary' => [
'price' => [ 'price' => [
'currency' => 'EUR', 'currency' => 'EUR',
@ -52,8 +71,17 @@ 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);
} }
@ -111,16 +139,36 @@ 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) {
$name = $value->getAttributeDefinition()->getName(); $valuesByDefId[$value->getAttributeDefinition()->getId()->toRfc4122()] = $value->getValue();
$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,6 +74,17 @@ 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

@ -0,0 +1,104 @@
<?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,6 +71,9 @@ 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

@ -0,0 +1,100 @@
<?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,11 +5,13 @@ 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;
@ -25,6 +27,7 @@ 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,
) { ) {
} }
@ -45,6 +48,31 @@ 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
@ -54,7 +82,6 @@ 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);
} }
@ -89,12 +116,14 @@ final class EbayAspectImportController extends AbstractController
} }
return $this->render('admin/ebay/aspect_import.html.twig', [ return $this->render('admin/ebay/aspect_import.html.twig', [
'articleType' => $articleType, 'articleType' => $articleType,
'rows' => $rows, 'rows' => $rows,
'allDefs' => $allDefs, 'allDefs' => $allDefs,
'counts' => $counts, 'articleFields' => self::ARTICLE_FIELDS,
'categoryId' => $categoryId, 'counts' => $counts,
'searchUrl' => $this->generateUrl('admin_ebay_category_search'), 'categoryId' => $categoryId,
'searchUrl' => $this->generateUrl('admin_ebay_category_search'),
'existingMappings' => $articleType->getEbayMappings(),
]); ]);
} }
@ -106,7 +135,9 @@ final class EbayAspectImportController extends AbstractController
$this->em->flush(); $this->em->flush();
} }
return $this->redirectToRoute('admin_ebay_aspect_import', ['id' => $articleType->getId()->toRfc4122()]); return $this->redirect(
$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
@ -124,16 +155,31 @@ 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';
if ('skip' === $action) { $ebayName = $data['ebayName'] ?? '';
$isRequired = ($data['ebayRequired'] ?? '0') === '1';
if ('skip' === $action || '' === $ebayName) {
continue; continue;
} }
$markRequired = ($data['required'] ?? '0') === '1'; if ('article_field' === $action) {
$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'] ?? '');
@ -163,6 +209,11 @@ 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 {
@ -209,29 +260,63 @@ 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, suggestedType: string}> * @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}>
*/ */
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 ($match !== null) { if (null !== $match) {
$action = 'match'; $action = 'match';
$preMatchId = $match->getId()->toRfc4122(); $preMatchId = $match->getId()->toRfc4122();
} elseif ($aspect['required'] || 'RECOMMENDED' === $aspect['usage']) { } elseif ($aspect['required'] || 'RECOMMENDED' === $aspect['usage']) {
@ -250,6 +335,7 @@ 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,6 +4,7 @@ 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;
@ -46,6 +47,21 @@ 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,6 +43,41 @@
{% 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">
@ -119,9 +154,10 @@
<select name="aspects[{{ i }}][action]" <select name="aspects[{{ i }}][action]"
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="match" {% if row.action == 'match' %}selected{% endif %}>Vorhandenes verknüpfen</option> <option value="article_field" {% if row.action == 'article_field' %}selected{% endif %}>→ Artikelfeld</option>
<option value="create" {% if row.action == 'create' %}selected{% endif %}>Neu anlegen</option> <option value="match" {% if row.action == 'match' %}selected{% endif %}>Attribut verknüpfen</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(',') }}">
@ -129,10 +165,19 @@
</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 }}" {% if row.preMatchId == def.id|toString %}selected{% endif %}> <option value="{{ def.id.toRfc4122() }}" {% if row.preMatchId == def.id.toRfc4122() %}selected{% endif %}>
{{ def.name }} ({{ def.type.value }}) {{ def.name }} ({{ def.type.value }})
</option> </option>
{% endfor %} {% endfor %}
@ -159,7 +204,7 @@
</td> </td>
<td class="text-center"> <td class="text-center">
<div class="section-req-{{ i }}" style="display:{% if row.action != 'skip' %}block{% else %}none{% endif %};"> <div class="section-req-{{ i }}" style="display:{% if row.action != 'skip' and row.action != 'article_field' %}block{% else %}none{% endif %};">
<input type="checkbox" <input type="checkbox"
name="aspects[{{ i }}][required]" name="aspects[{{ i }}][required]"
value="1" value="1"
@ -263,12 +308,12 @@
/* ── Aspect table JS ─────────────────────────────────────────── */ /* ── Aspect table JS ─────────────────────────────────────────── */
function syncRow(index, action) { function syncRow(index, action) {
['match', 'create', 'skip'].forEach(s => { ['article_field', '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' ? 'block' : 'none'; if (req) req.style.display = (action !== 'skip' && action !== 'article_field') ? 'block' : 'none';
} }
document.querySelectorAll('.aspect-action').forEach(sel => { document.querySelectorAll('.aspect-action').forEach(sel => {

View file

@ -7,6 +7,9 @@ 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;
@ -35,45 +38,47 @@ 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 = $_SERVER['EBAY_CLIENT_ID'] ?? getenv('EBAY_CLIENT_ID'); $clientId = self::env('EBAY_CLIENT_ID');
$clientSecret = $_SERVER['EBAY_CLIENT_SECRET'] ?? getenv('EBAY_CLIENT_SECRET'); $clientSecret = self::env('EBAY_CLIENT_SECRET');
$apiBaseUrl = $_SERVER['EBAY_API_BASE_URL'] ?? getenv('EBAY_API_BASE_URL'); $apiBaseUrl = self::env('EBAY_API_BASE_URL');
$oauthBaseUrl = $_SERVER['EBAY_OAUTH_BASE_URL'] ?? getenv('EBAY_OAUTH_BASE_URL'); $oauthBaseUrl = self::env('EBAY_OAUTH_BASE_URL');
$marketplaceId = $_SERVER['EBAY_MARKETPLACE_ID'] ?? getenv('EBAY_MARKETPLACE_ID') ?: 'EBAY_DE'; $marketplaceId = self::env('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( $oauth = new EbayOAuthClient($http, $cache, $clientId, $clientSecret, $oauthBaseUrl);
$http,
$cache,
(string) $clientId,
(string) $clientSecret,
(string) $oauthBaseUrl,
);
$this->apiClient = new EbayInventoryApiClient( $this->apiClient = new EbayInventoryApiClient($http, $oauth, $apiBaseUrl, $marketplaceId);
$http,
$oauth,
(string) $apiBaseUrl,
(string) $marketplaceId,
);
$this->adapter = new EbayAdapter($this->apiClient); $articleType = new ArticleType('Notebook');
$platform = new Platform('ebay', 'eBay');
// Sandbox category 177 = Notebooks; no policies needed for sandbox withdraw/stock tests
$platformConfig = new ArticleTypePlatformConfig($articleType, $platform, '177');
$userToken = $_SERVER['EBAY_USER_TOKEN'] ?? getenv('EBAY_USER_TOKEN'); $configRepo = $this->createStub(ArticleTypePlatformConfigRepositoryInterface::class);
$this->userTokenAvailable = (bool) $userToken; $configRepo->method('findByArticleTypeAndPlatformType')->willReturn($platformConfig);
// Build a realistic sandbox article for listing tests $this->adapter = new EbayAdapter($this->apiClient, $configRepo, $marketplaceId);
$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(
$type, $articleType,
'SS3K-TEST-'.time(), 'SS3K-TEST-'.time(),
'INV-TEST-001', 'INV-TEST-001',
1, 1,
@ -98,7 +103,7 @@ final class EbayAdapterIntegrationTest extends TestCase
} }
} }
public function test_publish_listing_creates_live_sandbox_listing(): void public function testPublishListingCreatesLiveSandboxListing(): void
{ {
if (!$this->userTokenAvailable) { if (!$this->userTokenAvailable) {
$this->markTestSkipped( $this->markTestSkipped(
@ -116,7 +121,7 @@ final class EbayAdapterIntegrationTest extends TestCase
$this->article->setEbayListingId($listingId); $this->article->setEbayListingId($listingId);
} }
public function test_update_stock_changes_quantity(): void public function testUpdateStockChangesQuantity(): 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');
@ -131,7 +136,7 @@ final class EbayAdapterIntegrationTest extends TestCase
$this->addToAssertionCount(1); $this->addToAssertionCount(1);
} }
public function test_deactivate_listing_withdraws_offer(): void public function testDeactivateListingWithdrawsOffer(): 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');
@ -146,7 +151,7 @@ final class EbayAdapterIntegrationTest extends TestCase
$this->addToAssertionCount(1); $this->addToAssertionCount(1);
} }
public function test_deactivate_listing_is_noop_when_no_listing_id(): void public function testDeactivateListingIsNoopWhenNoListingId(): 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,4 +134,20 @@ 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,6 +7,9 @@ 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;
@ -15,25 +18,35 @@ 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->adapter = new EbayAdapter($this->apiClient); $this->configRepo = $this->createMock(ArticleTypePlatformConfigRepositoryInterface::class);
$this->article = new Article(new ArticleType('Notebook'), 'NB-001', 'INV-001', 1, ArticleCondition::Good); $this->adapter = new EbayAdapter($this->apiClient, $this->configRepo, 'EBAY_DE');
$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 test_get_type_returns_ebay(): void public function testGetTypeReturnsEbay(): void
{ {
$this->assertSame('ebay', $this->adapter->getType()); $this->assertSame('ebay', $this->adapter->getType());
} }
public function test_publish_listing_calls_upsert_create_and_publish(): void public function testPublishListingCallsUpsertCreateAndPublish(): 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');
@ -43,7 +56,57 @@ final class EbayAdapterTest extends TestCase
$this->assertSame('listing-456', $listingId); $this->assertSame('listing-456', $listingId);
} }
public function test_deactivate_listing_withdraws_offer(): void public function testPublishListingThrowsWhenNoPlatformConfig(): 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');
@ -52,7 +115,7 @@ final class EbayAdapterTest extends TestCase
$this->adapter->deactivateListing($this->article); $this->adapter->deactivateListing($this->article);
} }
public function test_deactivate_listing_is_noop_when_no_listing_id(): void public function testDeactivateListingIsNoopWhenNoListingId(): void
{ {
$this->apiClient->expects($this->never())->method('withdrawOffer'); $this->apiClient->expects($this->never())->method('withdrawOffer');

View file

@ -10,6 +10,8 @@ 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,6 +10,8 @@ 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