Compare commits
10 commits
818c1ec8f7
...
6bd8e0bec8
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bd8e0bec8 | |||
| 31c5116a1b | |||
| 371213dbbb | |||
| bf1af0a0bf | |||
| 9259b99e7d | |||
| ed2b83ba9f | |||
| 61ce94bc6f | |||
| 929f5a0b2d | |||
| 0fdb4979c1 | |||
| 53e2d36574 |
27 changed files with 1875 additions and 93 deletions
107
CLAUDE.md
Normal file
107
CLAUDE.md
Normal 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.
|
||||
|
|
@ -120,6 +120,8 @@ services:
|
|||
$adapters: !tagged_iterator app.channel_adapter
|
||||
|
||||
App\Infrastructure\Channel\Ebay\EbayAdapter:
|
||||
arguments:
|
||||
$marketplaceId: '%env(EBAY_MARKETPLACE_ID)%'
|
||||
tags: ['app.channel_adapter']
|
||||
|
||||
App\Infrastructure\Channel\Ebay\EbayOAuthClient:
|
||||
|
|
@ -134,6 +136,15 @@ services:
|
|||
$apiBaseUrl: '%env(EBAY_API_BASE_URL)%'
|
||||
$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:
|
||||
arguments:
|
||||
$apiBaseUrl: '%env(EBAY_API_BASE_URL)%'
|
||||
|
|
|
|||
268
docs/superpowers/plans/2026-05-19-07-ebay-admin-policies.md
Normal file
268
docs/superpowers/plans/2026-05-19-07-ebay-admin-policies.md
Normal 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
|
||||
```
|
||||
|
|
@ -29,8 +29,8 @@ src/
|
|||
# Orchestriert Domain über Interfaces (Ports)
|
||||
Infrastructure/
|
||||
Channel/ # EbayAdapter, FrappeErpAdapter, [FutureAdapter]
|
||||
AI/ # OllamaClient, MistralClient (beide hinter OllamaClientInterface)
|
||||
# OllamaVisionAgent, SpecsResearchAgent, JsonCodingAgent, EbayTextAgent
|
||||
AI/ # MistralClient hinter OllamaClientInterface (Interfacename historisch)
|
||||
# VisionAgent, SpecsResearchAgent, JsonCodingAgent, EbayTextAgent
|
||||
Persistence/ # Doctrine Repositories (PostgreSQL)
|
||||
Logging/ # DatabaseLogHandler, ArchiveCommand
|
||||
Http/ # Symfony Controller, Webhook-Listener, EasyAdmin
|
||||
|
|
@ -38,7 +38,7 @@ src/
|
|||
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
|
||||
|
||||
|
|
@ -51,7 +51,7 @@ Jeder externe Dienst (eBay, Frappe ERP, Ollama, SMTP) implementiert ein Interfac
|
|||
| Tests | PHPUnit 11 + Pest |
|
||||
| Datenbank | PostgreSQL 17 (eine Instanz, drei Schemas: `app`, `logs`, `logs_archive`) |
|
||||
| Cache / Queue | Redis 7 |
|
||||
| AI | **Ollama** (lokal, SSH-Tunnel + autossh) **oder Mistral Cloud API** — per Alias in `services.yaml` umschaltbar; Modelle via `AI_TEXT_MODEL` / `AI_VISION_MODEL` env vars konfigurierbar |
|
||||
| AI | **Mistral Cloud API** — Vision: `pixtral-12b-2409`, Text: `mistral-large-latest`; Modelle via `AI_TEXT_MODEL` / `AI_VISION_MODEL` env vars konfigurierbar |
|
||||
| Web-Suche | Tavily API (`TAVILY_API_KEY`) — liefert strukturierte Suchergebnisse für SpecsResearchAgent |
|
||||
| Admin-Panel | EasyAdmin 5 (`#[AdminDashboard]` Attribut, `type: easyadmin.routes` Loader) |
|
||||
| Auth | Symfony Security + scheb/two-factor-bundle (TOTP) |
|
||||
|
|
@ -71,7 +71,7 @@ redis://...?queue_name=orders # Order-Processing, CustomerResolver, Invo
|
|||
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)
|
||||
2. OllamaVisionAgent — LLaVA liest Typenschild
|
||||
2. VisionAgent — Mistral Pixtral liest Typenschild
|
||||
Output: Hersteller, Modellname, Modellnummer, Seriennummer
|
||||
3. Model-Cache-Check — findCompletedByModelNumber() in DB
|
||||
Treffer → copy ebayTitle/ebayDescription/specsText/attributes → Schritt 6 (kein AI mehr)
|
||||
Kein Treffer → weiter mit Schritt 4
|
||||
4. SpecsResearchAgent — Tavily-Suche mit Modellbezeichnung → vollständige Specs (Freitext)
|
||||
Pflichtfeld-Liste kommt aus ArticleType.AttributeDefinitions ({{fields}}-Platzhalter im Prompt)
|
||||
5. JsonCodingAgent — strukturierter 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)
|
||||
✓ → Schritt 7
|
||||
✗ → Retry ab Schritt 5 mit missing_fields im Prompt (max. 3×)
|
||||
|
|
@ -352,7 +352,7 @@ Schema: logs_archive.log_entry # identische Struktur
|
|||
### Dienste
|
||||
- **PostgreSQL:** App-User mit minimalen Rechten (kein Superuser)
|
||||
- **Redis:** `requirepass` gesetzt, nur intern erreichbar
|
||||
- **Ollama:** lokal, Zugriff nur via SSH-Tunnel (autossh für Persistenz + Auto-Reconnect)
|
||||
- **Mistral API:** Key in `.env.local`, nie in Git; HTTPS-only
|
||||
- **Gitea:** Registrierung deaktiviert, nur Admin-Anlage, SSH-Key-Auth
|
||||
|
||||
### 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.
|
||||
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
|
||||
# config/services.yaml
|
||||
App\Infrastructure\AI\OllamaClientInterface:
|
||||
alias: App\Infrastructure\AI\OllamaClient
|
||||
```
|
||||
```dotenv
|
||||
# .env
|
||||
AI_TEXT_MODEL=${OLLAMA_TEXT_MODEL}
|
||||
AI_VISION_MODEL=${OLLAMA_VISION_MODEL}
|
||||
```
|
||||
|
||||
**Mistral Cloud API:**
|
||||
```yaml
|
||||
App\Infrastructure\AI\OllamaClientInterface:
|
||||
alias: App\Infrastructure\AI\MistralClient
|
||||
```
|
||||
```dotenv
|
||||
AI_TEXT_MODEL=${MISTRAL_TEXT_MODEL}
|
||||
AI_VISION_MODEL=${MISTRAL_VISION_MODEL}
|
||||
# .env.local
|
||||
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
|
||||
|
||||
|
|
|
|||
546
docs/superpowers/specs/2026-05-19-infrastructure.md
Normal file
546
docs/superpowers/specs/2026-05-19-infrastructure.md
Normal 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`.
|
||||
42
migrations/Version20260519070206.php
Normal file
42
migrations/Version20260519070206.php
Normal 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');
|
||||
}
|
||||
}
|
||||
26
migrations/Version20260520090000.php
Normal file
26
migrations/Version20260520090000.php
Normal 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");
|
||||
}
|
||||
}
|
||||
44
migrations/Version20260520100000.php
Normal file
44
migrations/Version20260520100000.php
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -135,6 +135,11 @@ class Article
|
|||
return $this->articleType;
|
||||
}
|
||||
|
||||
public function getArticleTypeName(): string
|
||||
{
|
||||
return $this->articleType->getName();
|
||||
}
|
||||
|
||||
public function getSku(): string
|
||||
{
|
||||
return $this->sku;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@ class ArticleType
|
|||
#[ORM\OneToMany(targetEntity: ArticleTypeAttribute::class, mappedBy: 'articleType', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
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() */
|
||||
private ?array $pendingRequired = null;
|
||||
|
||||
|
|
@ -38,6 +42,7 @@ class ArticleType
|
|||
$this->id = Uuid::v7();
|
||||
$this->name = $name;
|
||||
$this->attributeAssignments = new ArrayCollection();
|
||||
$this->ebayMappings = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
|
|
@ -70,6 +75,25 @@ class ArticleType
|
|||
$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> */
|
||||
public function getAttributeAssignments(): Collection
|
||||
{
|
||||
|
|
|
|||
83
src/Domain/Article/ArticleTypeEbayMapping.php
Normal file
83
src/Domain/Article/ArticleTypeEbayMapping.php
Normal 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() ?? '?');
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,18 @@ class ArticleTypePlatformConfig
|
|||
#[ORM\Column(type: 'string', length: 255)]
|
||||
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> */
|
||||
#[ORM\OneToMany(mappedBy: 'platformConfig', targetEntity: AttributeMapping::class, cascade: ['persist', 'remove'])]
|
||||
private Collection $attributeMappings;
|
||||
|
|
@ -68,6 +80,46 @@ class ArticleTypePlatformConfig
|
|||
$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> */
|
||||
public function getAttributeMappings(): Collection
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Domain\Channel\Repository;
|
||||
|
||||
use App\Domain\Article\ArticleType;
|
||||
use App\Domain\Channel\ArticleTypePlatformConfig;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
|
|
@ -16,6 +17,8 @@ interface ArticleTypePlatformConfigRepositoryInterface
|
|||
/** @return list<ArticleTypePlatformConfig> */
|
||||
public function findByArticleType(Uuid $articleTypeId): array;
|
||||
|
||||
public function findByArticleTypeAndPlatformType(ArticleType $articleType, string $platformType): ?ArticleTypePlatformConfig;
|
||||
|
||||
public function save(ArticleTypePlatformConfig $config): void;
|
||||
|
||||
public function remove(ArticleTypePlatformConfig $config): void;
|
||||
|
|
|
|||
80
src/Infrastructure/Channel/Ebay/EbayAccountApiClient.php
Normal file
80
src/Infrastructure/Channel/Ebay/EbayAccountApiClient.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -7,12 +7,16 @@ namespace App\Infrastructure\Channel\Ebay;
|
|||
use App\Application\Channel\ChannelAdapterInterface;
|
||||
use App\Domain\Article\Article;
|
||||
use App\Domain\Article\ArticleCondition;
|
||||
use App\Domain\Article\ArticleTypeEbayMapping;
|
||||
use App\Domain\Channel\Repository\ArticleTypePlatformConfigRepositoryInterface;
|
||||
use App\Domain\Order\Order;
|
||||
|
||||
final class EbayAdapter implements ChannelAdapterInterface
|
||||
{
|
||||
public function __construct(
|
||||
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
|
||||
{
|
||||
$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, [
|
||||
'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,
|
||||
'marketplaceId' => 'EBAY_DE',
|
||||
'marketplaceId' => $this->marketplaceId,
|
||||
'format' => 'FIXED_PRICE',
|
||||
'availableQuantity' => $article->getStock(),
|
||||
'categoryId' => $config->getCategoryId(),
|
||||
'pricingSummary' => [
|
||||
'price' => [
|
||||
'currency' => 'EUR',
|
||||
|
|
@ -52,8 +71,17 @@ final class EbayAdapter implements ChannelAdapterInterface
|
|||
],
|
||||
],
|
||||
'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);
|
||||
}
|
||||
|
|
@ -111,16 +139,36 @@ final class EbayAdapter implements ChannelAdapterInterface
|
|||
private function buildAspects(Article $article): array
|
||||
{
|
||||
$aspects = [];
|
||||
|
||||
$valuesByDefId = [];
|
||||
foreach ($article->getAttributeValues() as $value) {
|
||||
$name = $value->getAttributeDefinition()->getName();
|
||||
$aspects[$name] = [$value->getValue()];
|
||||
$valuesByDefId[$value->getAttributeDefinition()->getId()->toRfc4122()] = $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;
|
||||
}
|
||||
|
||||
private function getCategoryId(): string
|
||||
{
|
||||
return '177';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,17 @@ class EbayInventoryApiClient
|
|||
$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.
|
||||
*
|
||||
|
|
|
|||
104
src/Infrastructure/Channel/Ebay/EbayPolicyProvider.php
Normal file
104
src/Infrastructure/Channel/Ebay/EbayPolicyProvider.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -71,6 +71,9 @@ final class DashboardController extends AbstractDashboardController
|
|||
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(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::linkTo(OrderCrudController::class, $t('menu.orders'), 'fa fa-shopping-cart');
|
||||
yield MenuItem::linkTo(CustomerCrudController::class, $t('menu.customers'), 'fa fa-users');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,11 +5,13 @@ declare(strict_types=1);
|
|||
namespace App\Infrastructure\Http\Controller\Admin;
|
||||
|
||||
use App\Domain\Article\ArticleType;
|
||||
use App\Domain\Article\ArticleTypeEbayMapping;
|
||||
use App\Domain\Article\AttributeDefinition;
|
||||
use App\Domain\Article\AttributeType;
|
||||
use App\Domain\Article\Repository\ArticleTypeRepositoryInterface;
|
||||
use App\Infrastructure\Channel\Ebay\EbayTaxonomyService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
|
@ -25,6 +27,7 @@ final class EbayAspectImportController extends AbstractController
|
|||
private readonly ArticleTypeRepositoryInterface $articleTypeRepo,
|
||||
private readonly EbayTaxonomyService $taxonomy,
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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')]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function __invoke(string $id, Request $request): Response
|
||||
|
|
@ -54,7 +82,6 @@ final class EbayAspectImportController extends AbstractController
|
|||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
// Save category selection (step 1 of the 2-step flow)
|
||||
if ($request->isMethod('POST') && 'set-category' === $request->request->get('_action')) {
|
||||
return $this->handleSetCategory($request, $articleType);
|
||||
}
|
||||
|
|
@ -89,12 +116,14 @@ final class EbayAspectImportController extends AbstractController
|
|||
}
|
||||
|
||||
return $this->render('admin/ebay/aspect_import.html.twig', [
|
||||
'articleType' => $articleType,
|
||||
'rows' => $rows,
|
||||
'allDefs' => $allDefs,
|
||||
'counts' => $counts,
|
||||
'categoryId' => $categoryId,
|
||||
'searchUrl' => $this->generateUrl('admin_ebay_category_search'),
|
||||
'articleType' => $articleType,
|
||||
'rows' => $rows,
|
||||
'allDefs' => $allDefs,
|
||||
'articleFields' => self::ARTICLE_FIELDS,
|
||||
'counts' => $counts,
|
||||
'categoryId' => $categoryId,
|
||||
'searchUrl' => $this->generateUrl('admin_ebay_category_search'),
|
||||
'existingMappings' => $articleType->getEbayMappings(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -106,7 +135,9 @@ final class EbayAspectImportController extends AbstractController
|
|||
$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
|
||||
|
|
@ -124,16 +155,31 @@ final class EbayAspectImportController extends AbstractController
|
|||
if (!\is_array($rawData)) {
|
||||
continue;
|
||||
}
|
||||
// HTTP POST values are always strings; cast once to satisfy PHPStan level 9
|
||||
/** @var array<string, string> $data */
|
||||
$data = array_map(static fn (mixed $v): string => \is_scalar($v) ? (string) $v : '', $rawData);
|
||||
|
||||
$action = $data['action'] ?? 'skip';
|
||||
if ('skip' === $action) {
|
||||
$action = $data['action'] ?? 'skip';
|
||||
$ebayName = $data['ebayName'] ?? '';
|
||||
$isRequired = ($data['ebayRequired'] ?? '0') === '1';
|
||||
|
||||
if ('skip' === $action || '' === $ebayName) {
|
||||
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) {
|
||||
$def = $this->em->find(AttributeDefinition::class, $data['definitionId'] ?? '');
|
||||
|
|
@ -163,6 +209,11 @@ final class EbayAspectImportController extends AbstractController
|
|||
$this->em->persist($def);
|
||||
}
|
||||
|
||||
$mapping = new ArticleTypeEbayMapping($articleType, $ebayName, ArticleTypeEbayMapping::SOURCE_ATTRIBUTE);
|
||||
$mapping->setAttributeDefinition($def);
|
||||
$mapping->setRequired($markRequired);
|
||||
$articleType->upsertEbayMapping($mapping);
|
||||
|
||||
if ($markRequired) {
|
||||
$requiredDefs[] = $def;
|
||||
} 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<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
|
||||
{
|
||||
// Build name → def map for auto-matching
|
||||
$defsByName = [];
|
||||
foreach ($allDefs as $def) {
|
||||
$defsByName[mb_strtolower(trim($def->getName()))] = $def;
|
||||
}
|
||||
|
||||
// Track which defs are already assigned to this article type
|
||||
$assignedDefIds = [];
|
||||
foreach ($articleType->getAttributeAssignments() as $assignment) {
|
||||
$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 = [];
|
||||
foreach ($aspects as $aspect) {
|
||||
$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;
|
||||
$alreadyAssigned = $match && isset($assignedDefIds[$match->getId()->toRfc4122()]);
|
||||
|
||||
if ($match !== null) {
|
||||
if (null !== $match) {
|
||||
$action = 'match';
|
||||
$preMatchId = $match->getId()->toRfc4122();
|
||||
} elseif ($aspect['required'] || 'RECOMMENDED' === $aspect['usage']) {
|
||||
|
|
@ -250,6 +335,7 @@ final class EbayAspectImportController extends AbstractController
|
|||
'aspect' => $aspect,
|
||||
'action' => $action,
|
||||
'preMatchId' => $preMatchId,
|
||||
'preFieldKey' => null,
|
||||
'alreadyAssigned' => $alreadyAssigned,
|
||||
'suggestedType' => $suggestedType,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Infrastructure\Persistence\Repository;
|
||||
|
||||
use App\Domain\Article\ArticleType;
|
||||
use App\Domain\Channel\ArticleTypePlatformConfig;
|
||||
use App\Domain\Channel\Repository\ArticleTypePlatformConfigRepositoryInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
|
@ -46,6 +47,21 @@ final class DoctrineArticleTypePlatformConfigRepository implements ArticleTypePl
|
|||
->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
|
||||
{
|
||||
$this->em->persist($config);
|
||||
|
|
|
|||
|
|
@ -43,6 +43,41 @@
|
|||
|
||||
{% 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 ──────────────────────────────────────────────────── #}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
|
|
@ -119,9 +154,10 @@
|
|||
<select name="aspects[{{ i }}][action]"
|
||||
class="form-select form-select-sm aspect-action"
|
||||
data-index="{{ i }}">
|
||||
<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="create" {% if row.action == 'create' %}selected{% endif %}>Neu anlegen</option>
|
||||
<option value="skip" {% if row.action == 'skip' %}selected{% endif %}>— Überspringen</option>
|
||||
<option value="article_field" {% if row.action == 'article_field' %}selected{% endif %}>→ Artikelfeld</option>
|
||||
<option value="match" {% if row.action == 'match' %}selected{% endif %}>Attribut verknüpfen</option>
|
||||
<option value="create" {% if row.action == 'create' %}selected{% endif %}>Attribut anlegen</option>
|
||||
</select>
|
||||
<input type="hidden" name="aspects[{{ i }}][ebayName]" value="{{ aspect.name }}">
|
||||
<input type="hidden" name="aspects[{{ i }}][ebayValues]" value="{{ aspect.values|join(',') }}">
|
||||
|
|
@ -129,10 +165,19 @@
|
|||
</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 %};">
|
||||
<select name="aspects[{{ i }}][definitionId]" class="form-select form-select-sm">
|
||||
{% 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 }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
|
|
@ -159,7 +204,7 @@
|
|||
</td>
|
||||
|
||||
<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"
|
||||
name="aspects[{{ i }}][required]"
|
||||
value="1"
|
||||
|
|
@ -263,12 +308,12 @@
|
|||
|
||||
/* ── Aspect table JS ─────────────────────────────────────────── */
|
||||
function syncRow(index, action) {
|
||||
['match', 'create', 'skip'].forEach(s => {
|
||||
['article_field', 'match', 'create', 'skip'].forEach(s => {
|
||||
const el = document.querySelector(`.section-${s}-${index}`);
|
||||
if (el) el.style.display = s === action ? 'block' : 'none';
|
||||
});
|
||||
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 => {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ namespace App\Tests\Integration\Infrastructure\Channel\Ebay;
|
|||
use App\Domain\Article\Article;
|
||||
use App\Domain\Article\ArticleCondition;
|
||||
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\EbayInventoryApiClient;
|
||||
use App\Infrastructure\Channel\Ebay\EbayOAuthClient;
|
||||
|
|
@ -35,45 +38,47 @@ final class EbayAdapterIntegrationTest extends TestCase
|
|||
private string $createdListingId = '';
|
||||
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
|
||||
{
|
||||
$clientId = $_SERVER['EBAY_CLIENT_ID'] ?? getenv('EBAY_CLIENT_ID');
|
||||
$clientSecret = $_SERVER['EBAY_CLIENT_SECRET'] ?? getenv('EBAY_CLIENT_SECRET');
|
||||
$apiBaseUrl = $_SERVER['EBAY_API_BASE_URL'] ?? getenv('EBAY_API_BASE_URL');
|
||||
$oauthBaseUrl = $_SERVER['EBAY_OAUTH_BASE_URL'] ?? getenv('EBAY_OAUTH_BASE_URL');
|
||||
$marketplaceId = $_SERVER['EBAY_MARKETPLACE_ID'] ?? getenv('EBAY_MARKETPLACE_ID') ?: 'EBAY_DE';
|
||||
$clientId = self::env('EBAY_CLIENT_ID');
|
||||
$clientSecret = self::env('EBAY_CLIENT_SECRET');
|
||||
$apiBaseUrl = self::env('EBAY_API_BASE_URL');
|
||||
$oauthBaseUrl = self::env('EBAY_OAUTH_BASE_URL');
|
||||
$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');
|
||||
}
|
||||
|
||||
$http = HttpClient::create();
|
||||
$cache = new ArrayAdapter();
|
||||
|
||||
$oauth = new EbayOAuthClient(
|
||||
$http,
|
||||
$cache,
|
||||
(string) $clientId,
|
||||
(string) $clientSecret,
|
||||
(string) $oauthBaseUrl,
|
||||
);
|
||||
$oauth = new EbayOAuthClient($http, $cache, $clientId, $clientSecret, $oauthBaseUrl);
|
||||
|
||||
$this->apiClient = new EbayInventoryApiClient(
|
||||
$http,
|
||||
$oauth,
|
||||
(string) $apiBaseUrl,
|
||||
(string) $marketplaceId,
|
||||
);
|
||||
$this->apiClient = new EbayInventoryApiClient($http, $oauth, $apiBaseUrl, $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');
|
||||
$this->userTokenAvailable = (bool) $userToken;
|
||||
$configRepo = $this->createStub(ArticleTypePlatformConfigRepositoryInterface::class);
|
||||
$configRepo->method('findByArticleTypeAndPlatformType')->willReturn($platformConfig);
|
||||
|
||||
// Build a realistic sandbox article for listing tests
|
||||
$type = new ArticleType('Notebook');
|
||||
$this->adapter = new EbayAdapter($this->apiClient, $configRepo, $marketplaceId);
|
||||
|
||||
$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(
|
||||
$type,
|
||||
$articleType,
|
||||
'SS3K-TEST-'.time(),
|
||||
'INV-TEST-001',
|
||||
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) {
|
||||
$this->markTestSkipped(
|
||||
|
|
@ -116,7 +121,7 @@ final class EbayAdapterIntegrationTest extends TestCase
|
|||
$this->article->setEbayListingId($listingId);
|
||||
}
|
||||
|
||||
public function test_update_stock_changes_quantity(): void
|
||||
public function testUpdateStockChangesQuantity(): void
|
||||
{
|
||||
if (!$this->userTokenAvailable) {
|
||||
$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);
|
||||
}
|
||||
|
||||
public function test_deactivate_listing_withdraws_offer(): void
|
||||
public function testDeactivateListingWithdrawsOffer(): void
|
||||
{
|
||||
if (!$this->userTokenAvailable) {
|
||||
$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);
|
||||
}
|
||||
|
||||
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
|
||||
$this->adapter->deactivateListing($this->article);
|
||||
|
|
|
|||
|
|
@ -134,4 +134,20 @@ final class EbayTaxonomyIntegrationTest extends TestCase
|
|||
$names = array_map(static fn (array $a) => $a['name'], $aspects);
|
||||
$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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ namespace App\Tests\Unit\Infrastructure\Channel\Ebay;
|
|||
use App\Domain\Article\Article;
|
||||
use App\Domain\Article\ArticleCondition;
|
||||
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\EbayInventoryApiClient;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
|
|
@ -15,25 +18,35 @@ use PHPUnit\Framework\TestCase;
|
|||
final class EbayAdapterTest extends TestCase
|
||||
{
|
||||
private EbayInventoryApiClient&MockObject $apiClient;
|
||||
private ArticleTypePlatformConfigRepositoryInterface&MockObject $configRepo;
|
||||
private EbayAdapter $adapter;
|
||||
private Article $article;
|
||||
private ArticleTypePlatformConfig $platformConfig;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->apiClient = $this->createMock(EbayInventoryApiClient::class);
|
||||
$this->adapter = new EbayAdapter($this->apiClient);
|
||||
$this->article = new Article(new ArticleType('Notebook'), 'NB-001', 'INV-001', 1, ArticleCondition::Good);
|
||||
$this->configRepo = $this->createMock(ArticleTypePlatformConfigRepositoryInterface::class);
|
||||
$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->setListingPrice('299.00');
|
||||
}
|
||||
|
||||
public function test_get_type_returns_ebay(): void
|
||||
public function testGetTypeReturnsEbay(): void
|
||||
{
|
||||
$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('createOffer')->willReturn('offer-123');
|
||||
$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);
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
|
|
@ -52,7 +115,7 @@ final class EbayAdapterTest extends TestCase
|
|||
$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');
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ menu.ai_prompts: 'KI-Prompts'
|
|||
menu.translations: Übersetzungen
|
||||
menu.users: Benutzer
|
||||
menu.logs: Logs
|
||||
menu.section_ebay: eBay
|
||||
menu.ebay_platform_configs: 'Kategorie-Konfigurationen'
|
||||
menu.section_sales: Verkauf
|
||||
menu.orders: Bestellungen
|
||||
menu.customers: Kunden
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ menu.ai_prompts: 'AI Prompts'
|
|||
menu.translations: Translations
|
||||
menu.users: Users
|
||||
menu.logs: Logs
|
||||
menu.section_ebay: eBay
|
||||
menu.ebay_platform_configs: 'Category Configurations'
|
||||
menu.section_sales: Sales
|
||||
menu.orders: Orders
|
||||
menu.customers: Customers
|
||||
|
|
|
|||
Loading…
Reference in a new issue