Compare commits
No commits in common. "6bd8e0bec846f74edb540657eb114e031b020f77" and "818c1ec8f7a93432c37c8cda1b8509d30e7aec7d" have entirely different histories.
6bd8e0bec8
...
818c1ec8f7
27 changed files with 93 additions and 1875 deletions
107
CLAUDE.md
107
CLAUDE.md
|
|
@ -1,107 +0,0 @@
|
||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
All PHP commands run inside Docker. The app container is named `app`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Unit tests
|
|
||||||
docker compose exec app php vendor/bin/phpunit tests/Unit/
|
|
||||||
|
|
||||||
# Single test file or method
|
|
||||||
docker compose exec app php vendor/bin/phpunit tests/Unit/Domain/Article/ArticleTest.php
|
|
||||||
docker compose exec app php vendor/bin/phpunit --filter testSomeMethod
|
|
||||||
|
|
||||||
# Integration tests (loads .env.local secrets automatically)
|
|
||||||
bin/test-integration
|
|
||||||
bin/test-integration tests/Integration/Channel/EbayAdapterTest.php
|
|
||||||
|
|
||||||
# PHPStan (level 9 — must be clean before committing)
|
|
||||||
docker compose exec app php vendor/bin/phpstan analyse
|
|
||||||
|
|
||||||
# CS Fixer (dry-run to check, no --dry-run to fix)
|
|
||||||
docker compose exec app php vendor/bin/php-cs-fixer fix --dry-run --diff
|
|
||||||
docker compose exec app php vendor/bin/php-cs-fixer fix
|
|
||||||
|
|
||||||
# Migrations
|
|
||||||
docker compose exec app php bin/console doctrine:migrations:migrate --no-interaction
|
|
||||||
docker compose exec app php bin/console doctrine:migrations:diff # generate from entity changes
|
|
||||||
|
|
||||||
# Cache
|
|
||||||
docker compose exec app php bin/console cache:clear
|
|
||||||
|
|
||||||
# Create first user / API key
|
|
||||||
docker compose exec app php bin/console app:users:create
|
|
||||||
docker compose exec app php bin/console app:api-keys:create
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
Hexagonal architecture: **Domain → Application → Infrastructure**. The boundary is enforced by convention and PHPStan.
|
|
||||||
|
|
||||||
- `src/Domain/` — pure PHP, zero framework imports. Entities, enums, value objects, repository *interfaces*. Doctrine attributes on entities are the pragmatic exception.
|
|
||||||
- `src/Application/` — use cases, message handlers, service interfaces (ports). Orchestrates domain via interfaces only.
|
|
||||||
- `src/Infrastructure/` — all framework/external-system code: Doctrine repositories, Symfony controllers, Messenger handlers, channel adapters, AI clients.
|
|
||||||
|
|
||||||
### Dependency Injection
|
|
||||||
|
|
||||||
Every Domain repository interface is aliased to its Doctrine implementation in `config/services.yaml`. All Application interfaces (ports) are aliased there too. When adding a new interface+implementation pair, add the alias manually — autowiring alone won't resolve interfaces.
|
|
||||||
|
|
||||||
Channel adapters are collected via `tagged_iterator app.channel_adapter` into `ChannelAdapterRegistry`. Tag new adapters in `services.yaml`.
|
|
||||||
|
|
||||||
### Routing (Symfony 8 gotcha)
|
|
||||||
|
|
||||||
`routing.controllers` auto-discovers all controllers. API controllers **must** declare the `/api` prefix in their class-level `#[Route]` attribute — a yaml `prefix:` on top of auto-discovery is silently ignored.
|
|
||||||
|
|
||||||
### AI Pipeline
|
|
||||||
|
|
||||||
The AI backend is `MistralClient` (vision: pixtral-12b, text: mistral-large). The interface is named `OllamaClientInterface` for historical reasons — the `services.yaml` alias points it to `MistralClient`. Web search uses `TavilyWebSearch` behind `WebSearchInterface`.
|
|
||||||
|
|
||||||
Pipeline A (photo) chains Messages sequentially — each handler dispatches the next:
|
|
||||||
`PhotoUpload → SpecsResearch → JsonCoding → Validation → DraftArticle → EbayText`
|
|
||||||
|
|
||||||
After vision, `findCompletedByModelNumber()` checks the DB for a cache hit and skips the remaining AI steps if found.
|
|
||||||
|
|
||||||
`PipelineJobFailureListener` catches `WorkerMessageFailedEvent` after all retries are exhausted and sets `AIPipelineJob.status = failed`.
|
|
||||||
|
|
||||||
### Messenger Transports
|
|
||||||
|
|
||||||
Three isolated Redis streams — a failing worker never blocks the others:
|
|
||||||
|
|
||||||
| Transport | Worker service | Retries | Delay |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `ai_pipeline` | `worker-ai` | 3 | 2 s ×2 |
|
|
||||||
| `orders` | `worker-orders` | 5 | 1 s ×2 |
|
|
||||||
| `channel_sync` | `worker-channel` | 5 | 2 s ×2, max 60 s |
|
|
||||||
|
|
||||||
Exhausted messages land in `failed` transport (persistent). Replay with `messenger:failed:retry`.
|
|
||||||
|
|
||||||
### eBay Integration
|
|
||||||
|
|
||||||
`EbayAdapter` implements `ChannelAdapterInterface`. It uses:
|
|
||||||
- `EbayInventoryApiClient` — inventory items, offers, publish/withdraw, stock updates, tracking
|
|
||||||
- `EbayFulfillmentApiClient` — order fetching
|
|
||||||
- `EbayOAuthClient` — Client-Credentials token with `cache.app` caching
|
|
||||||
- `EbayTaxonomyService` — category/aspect lookup, also cached
|
|
||||||
|
|
||||||
`ArticleTypePlatformConfig` holds per-ArticleType eBay settings (category ID, business policy IDs). `ArticleTypeEbayMapping` maps each eBay aspect name to either an `Article` field (`SOURCE_ARTICLE_FIELD`) or an `AttributeDefinition` (`SOURCE_ATTRIBUTE`). `EbayAdapter.buildAspects()` reads from this table.
|
|
||||||
|
|
||||||
### EasyAdmin (Admin Panel)
|
|
||||||
|
|
||||||
`DashboardController` carries `#[AdminDashboard]` and `configureMenuItems()`. All CRUD controllers in `src/Infrastructure/Http/Controller/Admin/` are auto-discovered. Menu items reference controller class names directly via `MenuItem::linkTo()`.
|
|
||||||
|
|
||||||
### PostgreSQL Schemas
|
|
||||||
|
|
||||||
Three schemas: `app` (all entities), `logs` (live log entries), `logs_archive` (rotated). `doctrine.yaml` sets `schema_filter` to exclude `logs_archive.*` and `app.inventory_seq` from migration diffs — never remove that filter.
|
|
||||||
|
|
||||||
### Auth
|
|
||||||
|
|
||||||
Browser login: form + optional TOTP (`scheb/two-factor-bundle`). API access: `X-Api-Key` header — stored as bcrypt hash with prefix for lookup. `PermissionVoter` checks `User.permissions` and `ApiKey.permissions` (both jsonb) uniformly. Permission constants live in `PermissionVoter`.
|
|
||||||
|
|
||||||
### Docker
|
|
||||||
|
|
||||||
All PHP services (`app`, `worker-*`, `cron`) run as user `1000:1000` with `HOME=/tmp`. Never run commands as root inside the container — it creates root-owned files on the host.
|
|
||||||
|
|
||||||
`docker-compose.override.yml` exists only for local dev (exposes Postgres/Redis ports). Do not use it on the production server.
|
|
||||||
|
|
@ -120,8 +120,6 @@ services:
|
||||||
$adapters: !tagged_iterator app.channel_adapter
|
$adapters: !tagged_iterator app.channel_adapter
|
||||||
|
|
||||||
App\Infrastructure\Channel\Ebay\EbayAdapter:
|
App\Infrastructure\Channel\Ebay\EbayAdapter:
|
||||||
arguments:
|
|
||||||
$marketplaceId: '%env(EBAY_MARKETPLACE_ID)%'
|
|
||||||
tags: ['app.channel_adapter']
|
tags: ['app.channel_adapter']
|
||||||
|
|
||||||
App\Infrastructure\Channel\Ebay\EbayOAuthClient:
|
App\Infrastructure\Channel\Ebay\EbayOAuthClient:
|
||||||
|
|
@ -136,15 +134,6 @@ services:
|
||||||
$apiBaseUrl: '%env(EBAY_API_BASE_URL)%'
|
$apiBaseUrl: '%env(EBAY_API_BASE_URL)%'
|
||||||
$marketplaceId: '%env(EBAY_MARKETPLACE_ID)%'
|
$marketplaceId: '%env(EBAY_MARKETPLACE_ID)%'
|
||||||
|
|
||||||
App\Infrastructure\Channel\Ebay\EbayAccountApiClient:
|
|
||||||
arguments:
|
|
||||||
$apiBaseUrl: '%env(EBAY_API_BASE_URL)%'
|
|
||||||
$marketplaceId: '%env(EBAY_MARKETPLACE_ID)%'
|
|
||||||
|
|
||||||
App\Infrastructure\Channel\Ebay\EbayPolicyProvider:
|
|
||||||
arguments:
|
|
||||||
$cache: '@cache.app'
|
|
||||||
|
|
||||||
App\Infrastructure\Channel\Ebay\EbayTaxonomyService:
|
App\Infrastructure\Channel\Ebay\EbayTaxonomyService:
|
||||||
arguments:
|
arguments:
|
||||||
$apiBaseUrl: '%env(EBAY_API_BASE_URL)%'
|
$apiBaseUrl: '%env(EBAY_API_BASE_URL)%'
|
||||||
|
|
|
||||||
|
|
@ -1,268 +0,0 @@
|
||||||
# SuperSeller3000 — Plan 7: eBay Admin-Navigation & Business Policies
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Jeder Channel-Adapter bekommt einen eigenen Navigationsbereich im Admin-Panel. Für eBay werden alle Pflichtfelder für ein vollständiges Listing abgebildet: Die vier Business Policies (Fulfillment, Payment, Return, Merchant Location) werden direkt aus dem eBay-Account abgerufen und als Dropdown im Admin angeboten — keine manuelle ID-Eingabe.
|
|
||||||
|
|
||||||
**Auslöser:** `publishListing()` scheitert ohne `listingPolicies` + `merchantLocationKey` im Offer-Body. Außerdem ist `getCategoryId()` derzeit hardcoded auf `'177'` — der Adapter liest `ArticleTypePlatformConfig` noch gar nicht.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Was ein vollständiges eBay-Listing braucht
|
|
||||||
|
|
||||||
| Feld | Quelle | Status |
|
|
||||||
|---|---|---|
|
|
||||||
| Titel, Beschreibung | `Article.ebayTitle/ebayDescription` | ✅ |
|
|
||||||
| Kategorie-ID | `ArticleTypePlatformConfig.categoryId` | ⚠️ vorhanden, aber Adapter liest es nicht |
|
|
||||||
| Condition, Fotos | `Article` | ✅ |
|
|
||||||
| Item Specifics (Aspects) | `ArticleTypeEbayMapping` | ✅ |
|
|
||||||
| Preis, Bestand | `Article.listingPrice/stock` | ✅ |
|
|
||||||
| **Fulfillment Policy ID** | eBay Account → `ArticleTypePlatformConfig` | ❌ fehlt |
|
|
||||||
| **Payment Policy ID** | eBay Account → `ArticleTypePlatformConfig` | ❌ fehlt |
|
|
||||||
| **Return Policy ID** | eBay Account → `ArticleTypePlatformConfig` | ❌ fehlt |
|
|
||||||
| **Merchant Location Key** | eBay Account → `ArticleTypePlatformConfig` | ❌ fehlt |
|
|
||||||
|
|
||||||
Business Policies werden einmalig im eBay Verkäuferkonto definiert und danach per ID referenziert. Der Admin ruft die Liste live ab und zeigt sie als Dropdown.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Schritte
|
|
||||||
|
|
||||||
### 1. `ArticleTypePlatformConfig` — 4 neue Felder
|
|
||||||
|
|
||||||
- [ ] In `src/Domain/Channel/ArticleTypePlatformConfig.php` vier nullable String-Felder ergänzen:
|
|
||||||
|
|
||||||
```php
|
|
||||||
#[ORM\Column(type: 'string', length: 100, nullable: true)]
|
|
||||||
private ?string $fulfillmentPolicyId = null;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'string', length: 100, nullable: true)]
|
|
||||||
private ?string $paymentPolicyId = null;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'string', length: 100, nullable: true)]
|
|
||||||
private ?string $returnPolicyId = null;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'string', length: 100, nullable: true)]
|
|
||||||
private ?string $merchantLocationKey = null;
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] Getter + Setter für alle vier Felder
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. `ArticleTypePlatformConfigRepositoryInterface` — neue Query-Methode
|
|
||||||
|
|
||||||
- [ ] In `src/Domain/Channel/Repository/ArticleTypePlatformConfigRepositoryInterface.php` ergänzen:
|
|
||||||
|
|
||||||
```php
|
|
||||||
public function findByArticleTypeAndPlatformType(ArticleType $articleType, string $platformType): ?ArticleTypePlatformConfig;
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] Implementierung in `DoctrineArticleTypePlatformConfigRepository`:
|
|
||||||
|
|
||||||
```php
|
|
||||||
public function findByArticleTypeAndPlatformType(ArticleType $articleType, string $platformType): ?ArticleTypePlatformConfig
|
|
||||||
{
|
|
||||||
return $this->em->createQuery('
|
|
||||||
SELECT c FROM App\Domain\Channel\ArticleTypePlatformConfig c
|
|
||||||
JOIN c.platform p
|
|
||||||
WHERE c.articleType = :articleType AND p.type = :platformType
|
|
||||||
')
|
|
||||||
->setParameter('articleType', $articleType)
|
|
||||||
->setParameter('platformType', $platformType)
|
|
||||||
->getOneOrNullResult();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. Migration
|
|
||||||
|
|
||||||
- [ ] `docker compose exec app php bin/console doctrine:migrations:diff` ausführen
|
|
||||||
- [ ] Generierte Migration prüfen — erwartet: 4 `ADD COLUMN ... nullable` auf `app.article_type_platform_configs`
|
|
||||||
- [ ] Dateipfad-Konvention: `migrations/Version2026MMDD000001.php`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. `EbayAccountApiClient` (neu)
|
|
||||||
|
|
||||||
Neuer Client für die eBay Account API (`/sell/account/v1`). Gleiche Auth-Struktur wie `EbayInventoryApiClient`.
|
|
||||||
|
|
||||||
- [ ] `src/Infrastructure/Channel/Ebay/EbayAccountApiClient.php` erstellen:
|
|
||||||
|
|
||||||
```php
|
|
||||||
final class EbayAccountApiClient
|
|
||||||
{
|
|
||||||
private const ACCOUNT_BASE = '/sell/account/v1';
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly HttpClientInterface $httpClient,
|
|
||||||
private readonly EbayOAuthClient $oauthClient,
|
|
||||||
private readonly string $apiBaseUrl,
|
|
||||||
private readonly string $marketplaceId,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/** @return list<array{fulfillmentPolicyId: string, name: string}> */
|
|
||||||
public function getFulfillmentPolicies(): array { ... }
|
|
||||||
|
|
||||||
/** @return list<array{paymentPolicyId: string, name: string}> */
|
|
||||||
public function getPaymentPolicies(): array { ... }
|
|
||||||
|
|
||||||
/** @return list<array{returnPolicyId: string, name: string}> */
|
|
||||||
public function getReturnPolicies(): array { ... }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Alle drei rufen `GET {BASE}/{resource}?marketplace_id={marketplaceId}` auf.
|
|
||||||
Response-Key: `fulfillmentPolicies` / `paymentPolicies` / `returnPolicies`.
|
|
||||||
|
|
||||||
- [ ] `EbayInventoryApiClient` um `getLocations()` erweitern:
|
|
||||||
|
|
||||||
```php
|
|
||||||
/** @return list<array{merchantLocationKey: string, name: string}> */
|
|
||||||
public function getLocations(): array
|
|
||||||
{
|
|
||||||
// GET /sell/inventory/v1/location
|
|
||||||
$data = $this->request('GET', self::INVENTORY_BASE.'/location', []);
|
|
||||||
return $data['locations'] ?? [];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] `services.yaml`: `EbayAccountApiClient` registrieren (gleiche Argumente wie `EbayInventoryApiClient`):
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
App\Infrastructure\Channel\Ebay\EbayAccountApiClient:
|
|
||||||
arguments:
|
|
||||||
$apiBaseUrl: '%env(EBAY_API_BASE_URL)%'
|
|
||||||
$marketplaceId: '%env(EBAY_MARKETPLACE_ID)%'
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. `EbayPolicyProvider` (neu)
|
|
||||||
|
|
||||||
Service, der Policy-Listen für EasyAdmin-Formulare aufbereitet und cached.
|
|
||||||
|
|
||||||
- [ ] `src/Infrastructure/Channel/Ebay/EbayPolicyProvider.php` erstellen:
|
|
||||||
|
|
||||||
```php
|
|
||||||
final class EbayPolicyProvider
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly EbayAccountApiClient $accountClient,
|
|
||||||
private readonly EbayInventoryApiClient $inventoryClient,
|
|
||||||
private readonly CacheInterface $cache,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/** @return array<string, string> Label => ID */
|
|
||||||
public function getFulfillmentChoices(): array { ... }
|
|
||||||
|
|
||||||
/** @return array<string, string> */
|
|
||||||
public function getPaymentChoices(): array { ... }
|
|
||||||
|
|
||||||
/** @return array<string, string> */
|
|
||||||
public function getReturnChoices(): array { ... }
|
|
||||||
|
|
||||||
/** @return array<string, string> Label => merchantLocationKey */
|
|
||||||
public function getLocationChoices(): array { ... }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- Label-Format: `"Policyname (ID)"` → Value: die ID
|
|
||||||
- Cache-Key pro Policy-Typ, TTL 300 s (`cache.app`)
|
|
||||||
- Bei Exception (API nicht erreichbar, fehlende Credentials): leeres Array zurückgeben — der CRUD-Controller behandelt das
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6. `EbayAdapter` — Config wirklich lesen, Policies übergeben
|
|
||||||
|
|
||||||
- [ ] `EbayAdapter` bekommt `ArticleTypePlatformConfigRepositoryInterface` injiziert
|
|
||||||
- [ ] `publishListing()` lädt die Config über `findByArticleTypeAndPlatformType($article->getArticleType(), 'ebay')`
|
|
||||||
- [ ] Exception werfen wenn kein Config-Eintrag existiert: `"No eBay platform config for ArticleType {name}"`
|
|
||||||
- [ ] `createOffer()` body erweitern:
|
|
||||||
|
|
||||||
```php
|
|
||||||
'categoryId' => $config->getCategoryId(),
|
|
||||||
'listingPolicies' => array_filter([ // array_filter entfernt null-Werte
|
|
||||||
'fulfillmentPolicyId' => $config->getFulfillmentPolicyId(),
|
|
||||||
'paymentPolicyId' => $config->getPaymentPolicyId(),
|
|
||||||
'returnPolicyId' => $config->getReturnPolicyId(),
|
|
||||||
]),
|
|
||||||
...($config->getMerchantLocationKey() !== null ? [
|
|
||||||
'merchantLocationKey' => $config->getMerchantLocationKey(),
|
|
||||||
] : []),
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] Hardcoded `return '177';` in `getCategoryId()` entfernen (Methode fällt weg, Config übernimmt)
|
|
||||||
- [ ] Bestehende Unit-Tests für `EbayAdapter` anpassen
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 7. EasyAdmin CRUD — `EbayArticleTypePlatformConfigCrudController`
|
|
||||||
|
|
||||||
- [ ] `src/Infrastructure/Http/Controller/Admin/EbayArticleTypePlatformConfigCrudController.php` erstellen
|
|
||||||
- [ ] Entity: `ArticleTypePlatformConfig`
|
|
||||||
- [ ] `createIndexQueryBuilder()` überschreiben → filtert auf `platform.type = 'ebay'`
|
|
||||||
|
|
||||||
**`configureFields()`:**
|
|
||||||
|
|
||||||
```php
|
|
||||||
public function configureFields(string $pageName): iterable
|
|
||||||
{
|
|
||||||
yield AssociationField::new('articleType', 'Artikel-Typ');
|
|
||||||
yield TextField::new('categoryId', 'eBay Kategorie-ID');
|
|
||||||
|
|
||||||
$choices = $this->tryGetChoices('fulfillment'); // siehe unten
|
|
||||||
yield $choices !== null
|
|
||||||
? ChoiceField::new('fulfillmentPolicyId', 'Versand-Policy')->setChoices($choices)
|
|
||||||
: TextField::new('fulfillmentPolicyId', 'Versand-Policy (ID)');
|
|
||||||
|
|
||||||
// analog für payment, return, merchantLocationKey
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `tryGetChoices(string $type): ?array<string,string>` — ruft `EbayPolicyProvider` auf, gibt `null` zurück bei leerer Liste oder Exception
|
|
||||||
- Wenn `null`: `TextField` statt `ChoiceField` + einmalige Flash-Warnung `"eBay API nicht erreichbar — ID manuell eingeben"`
|
|
||||||
- [ ] `services.yaml` — `EbayPolicyProvider` in den Controller injizieren
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 8. Navigation — eBay-Bereich in `DashboardController`
|
|
||||||
|
|
||||||
- [ ] In `configureMenuItems()` einen eBay-Submenü-Block einfügen:
|
|
||||||
|
|
||||||
```php
|
|
||||||
yield MenuItem::subMenu('eBay', 'fa fa-store')->setSubItems([
|
|
||||||
MenuItem::linkTo(EbayArticleTypePlatformConfigCrudController::class, 'Kategorie & Policies', 'fa fa-sliders'),
|
|
||||||
MenuItem::linkToRoute('eBay Aspect Import', 'fa fa-download', 'admin_ebay_aspect_import'),
|
|
||||||
]);
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] Übersetzungsschlüssel in `messages.de.yaml` / `messages.en.yaml` ergänzen
|
|
||||||
- [ ] `ArticleTypePlatformConfig` aus dem allgemeinen Bereich entfernen, falls er dort noch auftaucht (war bisher nicht in der Nav)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Nicht in diesem Plan
|
|
||||||
|
|
||||||
- Weitere Adapter-Sektionen (Amazon, Kaufland) — Struktur ist vorbereitet, wird angelegt wenn die Adapter existieren
|
|
||||||
- Sandbox-Policies abrufen — die Account-API ist auf dem echten eBay-Account. Für Tests: Policy-IDs aus Sandbox-Account manuell in `.env.local` setzen oder `EbayPolicyProvider` mocken
|
|
||||||
- Kategorie-ID als Typeahead (statt Text-Input) — ist ein separates Thema
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dateien die geändert werden
|
|
||||||
|
|
||||||
```
|
|
||||||
src/Domain/Channel/ArticleTypePlatformConfig.php ← 4 neue Felder
|
|
||||||
src/Domain/Channel/Repository/ArticleTypePlatformConfigRepositoryInterface.php ← neue Methode
|
|
||||||
src/Infrastructure/Persistence/Repository/DoctrineArticleTypePlatformConfigRepository.php ← Implementierung
|
|
||||||
src/Infrastructure/Channel/Ebay/EbayAccountApiClient.php ← NEU
|
|
||||||
src/Infrastructure/Channel/Ebay/EbayInventoryApiClient.php ← getLocations()
|
|
||||||
src/Infrastructure/Channel/Ebay/EbayPolicyProvider.php ← NEU
|
|
||||||
src/Infrastructure/Channel/Ebay/EbayAdapter.php ← Config lesen, Policies übergeben
|
|
||||||
src/Infrastructure/Http/Controller/Admin/EbayArticleTypePlatformConfigCrudController.php ← NEU
|
|
||||||
src/Infrastructure/Http/Controller/Admin/DashboardController.php ← Navigation
|
|
||||||
config/services.yaml ← neue Services
|
|
||||||
migrations/Version2026...php ← NEU
|
|
||||||
```
|
|
||||||
|
|
@ -29,8 +29,8 @@ src/
|
||||||
# Orchestriert Domain über Interfaces (Ports)
|
# Orchestriert Domain über Interfaces (Ports)
|
||||||
Infrastructure/
|
Infrastructure/
|
||||||
Channel/ # EbayAdapter, FrappeErpAdapter, [FutureAdapter]
|
Channel/ # EbayAdapter, FrappeErpAdapter, [FutureAdapter]
|
||||||
AI/ # MistralClient hinter OllamaClientInterface (Interfacename historisch)
|
AI/ # OllamaClient, MistralClient (beide hinter OllamaClientInterface)
|
||||||
# VisionAgent, SpecsResearchAgent, JsonCodingAgent, EbayTextAgent
|
# OllamaVisionAgent, SpecsResearchAgent, JsonCodingAgent, EbayTextAgent
|
||||||
Persistence/ # Doctrine Repositories (PostgreSQL)
|
Persistence/ # Doctrine Repositories (PostgreSQL)
|
||||||
Logging/ # DatabaseLogHandler, ArchiveCommand
|
Logging/ # DatabaseLogHandler, ArchiveCommand
|
||||||
Http/ # Symfony Controller, Webhook-Listener, EasyAdmin
|
Http/ # Symfony Controller, Webhook-Listener, EasyAdmin
|
||||||
|
|
@ -38,7 +38,7 @@ src/
|
||||||
Storage/ # StorageManager
|
Storage/ # StorageManager
|
||||||
```
|
```
|
||||||
|
|
||||||
Jeder externe Dienst (eBay, Frappe ERP, Mistral, SMTP) implementiert ein Interface aus dem Application-Layer. Neue Plattform = neue Adapter-Klasse, Domain bleibt unberührt.
|
Jeder externe Dienst (eBay, Frappe ERP, Ollama, SMTP) implementiert ein Interface aus dem Application-Layer. Neue Plattform = neue Adapter-Klasse, Domain bleibt unberührt.
|
||||||
|
|
||||||
### 2.2 Tech-Stack
|
### 2.2 Tech-Stack
|
||||||
|
|
||||||
|
|
@ -51,7 +51,7 @@ Jeder externe Dienst (eBay, Frappe ERP, Mistral, SMTP) implementiert ein Interfa
|
||||||
| Tests | PHPUnit 11 + Pest |
|
| Tests | PHPUnit 11 + Pest |
|
||||||
| Datenbank | PostgreSQL 17 (eine Instanz, drei Schemas: `app`, `logs`, `logs_archive`) |
|
| Datenbank | PostgreSQL 17 (eine Instanz, drei Schemas: `app`, `logs`, `logs_archive`) |
|
||||||
| Cache / Queue | Redis 7 |
|
| Cache / Queue | Redis 7 |
|
||||||
| AI | **Mistral Cloud API** — Vision: `pixtral-12b-2409`, Text: `mistral-large-latest`; Modelle via `AI_TEXT_MODEL` / `AI_VISION_MODEL` env vars konfigurierbar |
|
| AI | **Ollama** (lokal, SSH-Tunnel + autossh) **oder Mistral Cloud API** — per Alias in `services.yaml` umschaltbar; Modelle via `AI_TEXT_MODEL` / `AI_VISION_MODEL` env vars konfigurierbar |
|
||||||
| Web-Suche | Tavily API (`TAVILY_API_KEY`) — liefert strukturierte Suchergebnisse für SpecsResearchAgent |
|
| Web-Suche | Tavily API (`TAVILY_API_KEY`) — liefert strukturierte Suchergebnisse für SpecsResearchAgent |
|
||||||
| Admin-Panel | EasyAdmin 5 (`#[AdminDashboard]` Attribut, `type: easyadmin.routes` Loader) |
|
| Admin-Panel | EasyAdmin 5 (`#[AdminDashboard]` Attribut, `type: easyadmin.routes` Loader) |
|
||||||
| Auth | Symfony Security + scheb/two-factor-bundle (TOTP) |
|
| Auth | Symfony Security + scheb/two-factor-bundle (TOTP) |
|
||||||
|
|
@ -71,7 +71,7 @@ redis://...?queue_name=orders # Order-Processing, CustomerResolver, Invo
|
||||||
redis://...?queue_name=channel_sync # Listing-Publish, Bestand-Sync, Deaktivierung, Tracking
|
redis://...?queue_name=channel_sync # Listing-Publish, Bestand-Sync, Deaktivierung, Tracking
|
||||||
```
|
```
|
||||||
|
|
||||||
**AI-Backend-Ausfall:** AI-Pipeline-Jobs verbleiben in der Queue. Symfony Messenger wiederholt mit Backoff, nach 3 Versuchen → Failed Transport (persistent, nicht verloren). Operativer Betrieb (Verkäufe, Rechnungen, Bestand) läuft völlig unabhängig durch. Replay via `messenger:failed:retry` wenn das AI-Backend wieder erreichbar ist.
|
**Ollama-Ausfall:** AI-Pipeline-Jobs verbleiben in der Queue. Symfony Messenger wiederholt mit Backoff, nach 3 Versuchen → Failed Transport (persistent, nicht verloren). Operativer Betrieb (Verkäufe, Rechnungen, Bestand) läuft völlig unabhängig durch. Replay via `messenger:failed:retry` wenn Ollama wieder verfügbar.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -216,14 +216,14 @@ ApiKey
|
||||||
|
|
||||||
```
|
```
|
||||||
1. Foto-Upload → PhotoUploadMessage in Queue (redis://ai_pipeline)
|
1. Foto-Upload → PhotoUploadMessage in Queue (redis://ai_pipeline)
|
||||||
2. VisionAgent — Mistral Pixtral liest Typenschild
|
2. OllamaVisionAgent — LLaVA liest Typenschild
|
||||||
Output: Hersteller, Modellname, Modellnummer, Seriennummer
|
Output: Hersteller, Modellname, Modellnummer, Seriennummer
|
||||||
3. Model-Cache-Check — findCompletedByModelNumber() in DB
|
3. Model-Cache-Check — findCompletedByModelNumber() in DB
|
||||||
Treffer → copy ebayTitle/ebayDescription/specsText/attributes → Schritt 6 (kein AI mehr)
|
Treffer → copy ebayTitle/ebayDescription/specsText/attributes → Schritt 6 (kein AI mehr)
|
||||||
Kein Treffer → weiter mit Schritt 4
|
Kein Treffer → weiter mit Schritt 4
|
||||||
4. SpecsResearchAgent — Tavily-Suche mit Modellbezeichnung → vollständige Specs (Freitext)
|
4. SpecsResearchAgent — Tavily-Suche mit Modellbezeichnung → vollständige Specs (Freitext)
|
||||||
Pflichtfeld-Liste kommt aus ArticleType.AttributeDefinitions ({{fields}}-Platzhalter im Prompt)
|
Pflichtfeld-Liste kommt aus ArticleType.AttributeDefinitions ({{fields}}-Platzhalter im Prompt)
|
||||||
5. JsonCodingAgent — strukturierter Mistral-Call: Specs-Text → JSON gegen ArticleType-Schema
|
5. JsonCodingAgent — strukturierter Ollama-Call: Specs-Text → JSON gegen ArticleType-Schema
|
||||||
6. ValidationGate — alle Pflichtfelder gesetzt? (Schema + eBay-Kategorie-Pflichtfelder)
|
6. ValidationGate — alle Pflichtfelder gesetzt? (Schema + eBay-Kategorie-Pflichtfelder)
|
||||||
✓ → Schritt 7
|
✓ → Schritt 7
|
||||||
✗ → Retry ab Schritt 5 mit missing_fields im Prompt (max. 3×)
|
✗ → Retry ab Schritt 5 mit missing_fields im Prompt (max. 3×)
|
||||||
|
|
@ -352,7 +352,7 @@ Schema: logs_archive.log_entry # identische Struktur
|
||||||
### Dienste
|
### Dienste
|
||||||
- **PostgreSQL:** App-User mit minimalen Rechten (kein Superuser)
|
- **PostgreSQL:** App-User mit minimalen Rechten (kein Superuser)
|
||||||
- **Redis:** `requirepass` gesetzt, nur intern erreichbar
|
- **Redis:** `requirepass` gesetzt, nur intern erreichbar
|
||||||
- **Mistral API:** Key in `.env.local`, nie in Git; HTTPS-only
|
- **Ollama:** lokal, Zugriff nur via SSH-Tunnel (autossh für Persistenz + Auto-Reconnect)
|
||||||
- **Gitea:** Registrierung deaktiviert, nur Admin-Anlage, SSH-Key-Auth
|
- **Gitea:** Registrierung deaktiviert, nur Admin-Anlage, SSH-Key-Auth
|
||||||
|
|
||||||
### Applikation
|
### Applikation
|
||||||
|
|
@ -440,21 +440,31 @@ docker compose exec app php bin/console app:api-keys:create --env=prod
|
||||||
Der Key wird als bcrypt-Hash gespeichert. Prefix (erste 8 Zeichen) dient als Lookup-Key.
|
Der Key wird als bcrypt-Hash gespeichert. Prefix (erste 8 Zeichen) dient als Lookup-Key.
|
||||||
Verwendung: `X-Api-Key: <rawKey>` HTTP-Header.
|
Verwendung: `X-Api-Key: <rawKey>` HTTP-Header.
|
||||||
|
|
||||||
### 11.3 AI-Konfiguration
|
### 11.3 AI-Backend wechseln
|
||||||
|
|
||||||
**Primär: Mistral Cloud API** (Standard, bereits so konfiguriert):
|
**Ollama (Standard, lokal):**
|
||||||
```yaml
|
```yaml
|
||||||
# config/services.yaml
|
# config/services.yaml
|
||||||
|
App\Infrastructure\AI\OllamaClientInterface:
|
||||||
|
alias: App\Infrastructure\AI\OllamaClient
|
||||||
|
```
|
||||||
|
```dotenv
|
||||||
|
# .env
|
||||||
|
AI_TEXT_MODEL=${OLLAMA_TEXT_MODEL}
|
||||||
|
AI_VISION_MODEL=${OLLAMA_VISION_MODEL}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mistral Cloud API:**
|
||||||
|
```yaml
|
||||||
App\Infrastructure\AI\OllamaClientInterface:
|
App\Infrastructure\AI\OllamaClientInterface:
|
||||||
alias: App\Infrastructure\AI\MistralClient
|
alias: App\Infrastructure\AI\MistralClient
|
||||||
```
|
```
|
||||||
```dotenv
|
```dotenv
|
||||||
# .env.local
|
AI_TEXT_MODEL=${MISTRAL_TEXT_MODEL}
|
||||||
|
AI_VISION_MODEL=${MISTRAL_VISION_MODEL}
|
||||||
MISTRAL_API_KEY=sk-...
|
MISTRAL_API_KEY=sk-...
|
||||||
TAVILY_API_KEY=tvly-...
|
|
||||||
# Modelle in .env vorbelegt: mistral-large-latest / pixtral-12b-2409
|
|
||||||
```
|
```
|
||||||
Nach Schlüsseländerung: `docker compose exec app php bin/console cache:clear`.
|
Vision erfordert Pixtral (`pixtral-12b-2409`). Nach Änderung: `cache:clear` + FPM reload.
|
||||||
|
|
||||||
### 11.4 Pipeline starten
|
### 11.4 Pipeline starten
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,546 +0,0 @@
|
||||||
# SuperSeller3000 — Infrastruktur & Deployment
|
|
||||||
|
|
||||||
**Zuletzt aktualisiert:** 2026-05-19
|
|
||||||
**Zielumgebung:** VPS (Ubuntu/Debian), Docker Compose
|
|
||||||
**Domain:** `ss3k.schaunwama.de`
|
|
||||||
|
|
||||||
> Dieses Dokument beschreibt den vollständigen Stand der Docker-Infrastruktur und alle Schritte, um das System auf einem neuen Server hochzuziehen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Übersicht der Services
|
|
||||||
|
|
||||||
| Service | Image / Build | Zweck |
|
|
||||||
|---|---|---|
|
|
||||||
| `app` | Build `docker/app/Dockerfile` | PHP 8.4-FPM — Symfony-Applikation |
|
|
||||||
| `caddy` | `caddy:2-alpine` | Reverse Proxy, Auto-HTTPS (Let's Encrypt) |
|
|
||||||
| `postgres` | `postgres:17-alpine` | Datenbank (Schemas: `app`, `logs`, `logs_archive`) |
|
|
||||||
| `redis` | `redis:7-alpine` | Queue-Backend + Session-Cache |
|
|
||||||
| `worker-ai` | Build `docker/app/Dockerfile` | Messenger Worker — Transport `ai_pipeline` |
|
|
||||||
| `worker-orders` | Build `docker/app/Dockerfile` | Messenger Worker — Transport `orders` |
|
|
||||||
| `worker-channel` | Build `docker/app/Dockerfile` | Messenger Worker — Transport `channel_sync` |
|
|
||||||
| `cron` | Build `docker/app/Dockerfile` | Log-Rotation täglich (`app:logs:rotate`) |
|
|
||||||
|
|
||||||
**Nicht im Prod-Setup:** kein Staging-ERP-Container. Frappe ERP läuft extern (eigene Instanz, `FRAPPE_ERP_BASE_URL` in `.env.local`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Queue-Architektur (Symfony Messenger + Redis Streams)
|
|
||||||
|
|
||||||
Drei isolierte Redis-Streams — ein ausgefallener Worker blockiert die anderen nie.
|
|
||||||
|
|
||||||
### Transports & Retry-Strategie
|
|
||||||
|
|
||||||
| Transport | Redis Stream | Worker | max_retries | Delay / Backoff |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `ai_pipeline` | `ai_pipeline` | `worker-ai` | 3 | 2 s · ×2 |
|
|
||||||
| `orders` | `orders` | `worker-orders` | 5 | 1 s · ×2 |
|
|
||||||
| `channel_sync` | `channel_sync` | `worker-channel` | 5 | 2 s · ×2 · max 60 s |
|
|
||||||
| `failed` | `failed` | — | — | persistent, nie verloren |
|
|
||||||
|
|
||||||
Nach Erschöpfung aller Retries landet die Message im `failed`-Transport. Replay: `messenger:failed:retry`.
|
|
||||||
|
|
||||||
### Messages pro Transport
|
|
||||||
|
|
||||||
#### `ai_pipeline` → `worker-ai`
|
|
||||||
|
|
||||||
Die Pipeline-Schritte werden sequenziell als separate Messages dispatcht — jeder Handler dispatcht den Nachfolger.
|
|
||||||
|
|
||||||
| Message | Handler-Logik |
|
|
||||||
|---|---|
|
|
||||||
| `PhotoUploadMessage` | Startet Pipeline A: `VisionAgent` (Mistral Pixtral) liest Typenschild → Hersteller, Modell, SN |
|
|
||||||
| `SpecsResearchMessage` | `SpecsResearchAgent`: Tavily-Suche mit Modellbezeichnung → Specs-Freitext; Pflichtfelder aus `ArticleType.AttributeDefinitions` (`{{fields}}`-Platzhalter im Prompt) |
|
|
||||||
| `JsonCodingMessage` | `JsonCodingAgent`: Specs-Text → strukturiertes JSON gegen ArticleType-Schema |
|
|
||||||
| `ValidationMessage` | Pflichtfelder vollständig? → weiter; sonst Retry ab `JsonCodingMessage` (max. 3×, `missing_fields` im Prompt) → `needs_review` |
|
|
||||||
| `DraftArticleMessage` | Article anlegen (status: `draft`), Inventurnummer vergeben, Attribute + Foto persistieren |
|
|
||||||
| `EbayTextMessage` | `EbayTextAgent`: eBay-Titel + Beschreibung aus Attributen generieren, an Article speichern |
|
|
||||||
| `PxeInventoryMessage` | Startet Pipeline B (PXE): PXE-Dump direkt an `JsonCodingAgent` — SpecsResearch entfällt |
|
|
||||||
|
|
||||||
**Model-Cache:** Nach `PhotoUploadMessage` prüft `findCompletedByModelNumber()` die DB. Treffer → alle Daten kopieren, `SpecsResearch`/`JsonCoding`/`EbayText` überspringen.
|
|
||||||
**Fehlerfall:** `PipelineJobFailureListener` fängt `WorkerMessageFailedEvent` ab und setzt `AIPipelineJob.status = failed` mit dem echten Fehlertext.
|
|
||||||
|
|
||||||
#### `orders` → `worker-orders`
|
|
||||||
|
|
||||||
| Message | Handler-Logik |
|
|
||||||
|---|---|
|
|
||||||
| `OrderReceivedMessage` | Vollständiger 13-Schritt-Order-Flow: Idempotenz-Check → atomarer Inventory-Lock (`stock - 1`) → `CustomerResolver` (Matching-Kaskade) → Order anlegen → Frappe Sales Invoice → PDF abrufen → `StorageManager` → Invoice-Record → PDF per SMTP an Lieferant → `UpdateStockOnChannelsMessage` oder `DeactivateListingMessage` dispatchen → Order: `completed` |
|
|
||||||
|
|
||||||
#### `channel_sync` → `worker-channel`
|
|
||||||
|
|
||||||
| Message | Handler-Logik |
|
|
||||||
|---|---|
|
|
||||||
| `PublishToChannelMessage` | `ChannelAdapterRegistry` → richtiger Adapter (z.B. `EbayAdapter`) → `publishListing()` → `ebay_listing_id` an Article zurückschreiben |
|
|
||||||
| `UpdateStockOnChannelsMessage` | `updateStock()` auf allen aktiven Plattformen des Artikels |
|
|
||||||
| `DeactivateListingMessage` | `deactivateListing()` — wird ausgelöst wenn `stock = 0` nach Verkauf |
|
|
||||||
| `TrackingPushMessage` | `pushTracking()` → Plattform als versandt markieren → `tracking_pushed_to_ebay_at` setzen |
|
|
||||||
|
|
||||||
### AI-Backend-Ausfall
|
|
||||||
|
|
||||||
AI-Worker-Messages verbleiben in der Queue. Messenger wiederholt mit Backoff, nach 3 Versuchen → `failed`-Transport. Operativer Betrieb (Verkäufe, Rechnungen, Bestand) läuft völlig unabhängig durch. Replay via `messenger:failed:retry` wenn das AI-Backend wieder erreichbar ist.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Netzwerk
|
|
||||||
|
|
||||||
Alle Services kommunizieren über ein internes Docker-Bridge-Network. Nur Caddy exponiert Ports nach außen.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
networks:
|
|
||||||
default:
|
|
||||||
driver: bridge
|
|
||||||
ipam:
|
|
||||||
config:
|
|
||||||
- subnet: 172.18.0.0/24
|
|
||||||
gateway: 172.18.0.1
|
|
||||||
```
|
|
||||||
|
|
||||||
Das Gateway `172.18.0.1` ist die Host-Adresse innerhalb des Docker-Netzes (erreichbar von allen Containern).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. docker-compose.yml (Prod)
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
app:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: docker/app/Dockerfile
|
|
||||||
user: "1000:1000"
|
|
||||||
environment:
|
|
||||||
HOME: /tmp
|
|
||||||
volumes:
|
|
||||||
- .:/var/www
|
|
||||||
- ./docker/app/php.ini:/usr/local/etc/php/conf.d/app.ini:ro
|
|
||||||
- ./docker/app/zz-fpm-pool.conf:/usr/local/etc/php-fpm.d/zzz-pool.conf:ro
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
redis:
|
|
||||||
condition: service_healthy
|
|
||||||
env_file:
|
|
||||||
- path: .env
|
|
||||||
required: true
|
|
||||||
- path: .env.local
|
|
||||||
required: false
|
|
||||||
|
|
||||||
caddy:
|
|
||||||
image: caddy:2-alpine
|
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
- "443:443"
|
|
||||||
volumes:
|
|
||||||
- ./docker/caddy/Caddyfile:/etc/caddy/Caddyfile
|
|
||||||
- .:/var/www
|
|
||||||
- ./docker/app/php.ini:/usr/local/etc/php/conf.d/app.ini:ro
|
|
||||||
- caddy_data:/data
|
|
||||||
depends_on:
|
|
||||||
- app
|
|
||||||
|
|
||||||
postgres:
|
|
||||||
image: postgres:17-alpine
|
|
||||||
env_file: .env
|
|
||||||
volumes:
|
|
||||||
- postgres_data:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
command: redis-server --requirepass ${REDIS_PASSWORD}
|
|
||||||
volumes:
|
|
||||||
- redis_data:/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
worker-ai:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: docker/app/Dockerfile
|
|
||||||
user: "1000:1000"
|
|
||||||
environment:
|
|
||||||
HOME: /tmp
|
|
||||||
command: php bin/console messenger:consume ai_pipeline --time-limit=3600 --memory-limit=256M
|
|
||||||
volumes:
|
|
||||||
- .:/var/www
|
|
||||||
- ./docker/app/php.ini:/usr/local/etc/php/conf.d/app.ini:ro
|
|
||||||
depends_on:
|
|
||||||
- postgres
|
|
||||||
- redis
|
|
||||||
env_file:
|
|
||||||
- path: .env
|
|
||||||
required: true
|
|
||||||
- path: .env.local
|
|
||||||
required: false
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
worker-orders:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: docker/app/Dockerfile
|
|
||||||
user: "1000:1000"
|
|
||||||
environment:
|
|
||||||
HOME: /tmp
|
|
||||||
command: php bin/console messenger:consume orders --time-limit=3600 --memory-limit=256M
|
|
||||||
volumes:
|
|
||||||
- .:/var/www
|
|
||||||
- ./docker/app/php.ini:/usr/local/etc/php/conf.d/app.ini:ro
|
|
||||||
depends_on:
|
|
||||||
- postgres
|
|
||||||
- redis
|
|
||||||
env_file:
|
|
||||||
- path: .env
|
|
||||||
required: true
|
|
||||||
- path: .env.local
|
|
||||||
required: false
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
worker-channel:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: docker/app/Dockerfile
|
|
||||||
user: "1000:1000"
|
|
||||||
environment:
|
|
||||||
HOME: /tmp
|
|
||||||
command: php bin/console messenger:consume channel_sync --time-limit=3600 --memory-limit=256M
|
|
||||||
volumes:
|
|
||||||
- .:/var/www
|
|
||||||
- ./docker/app/php.ini:/usr/local/etc/php/conf.d/app.ini:ro
|
|
||||||
depends_on:
|
|
||||||
- postgres
|
|
||||||
- redis
|
|
||||||
env_file:
|
|
||||||
- path: .env
|
|
||||||
required: true
|
|
||||||
- path: .env.local
|
|
||||||
required: false
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
cron:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: docker/app/Dockerfile
|
|
||||||
user: "1000:1000"
|
|
||||||
environment:
|
|
||||||
HOME: /tmp
|
|
||||||
command: >
|
|
||||||
sh -c "while true; do
|
|
||||||
php bin/console app:logs:rotate;
|
|
||||||
sleep 86400;
|
|
||||||
done"
|
|
||||||
volumes:
|
|
||||||
- .:/var/www
|
|
||||||
- ./docker/app/php.ini:/usr/local/etc/php/conf.d/app.ini:ro
|
|
||||||
depends_on:
|
|
||||||
- postgres
|
|
||||||
env_file:
|
|
||||||
- path: .env
|
|
||||||
required: true
|
|
||||||
- path: .env.local
|
|
||||||
required: false
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
postgres_data:
|
|
||||||
redis_data:
|
|
||||||
caddy_data:
|
|
||||||
|
|
||||||
networks:
|
|
||||||
default:
|
|
||||||
driver: bridge
|
|
||||||
ipam:
|
|
||||||
config:
|
|
||||||
- subnet: 172.18.0.0/24
|
|
||||||
gateway: 172.18.0.1
|
|
||||||
```
|
|
||||||
|
|
||||||
**Hinweis:** `docker-compose.override.yml` existiert nur für lokale Entwicklung (Port-Bindings für Postgres/Redis zum direkten Zugriff vom Host). Auf dem Prod-Server nicht verwenden.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Dockerfile — `docker/app/Dockerfile`
|
|
||||||
|
|
||||||
```dockerfile
|
|
||||||
FROM php:8.4-fpm-alpine
|
|
||||||
|
|
||||||
RUN apk add --no-cache \
|
|
||||||
postgresql-dev \
|
|
||||||
icu-dev \
|
|
||||||
libzip-dev \
|
|
||||||
unzip \
|
|
||||||
git \
|
|
||||||
$PHPIZE_DEPS \
|
|
||||||
&& docker-php-ext-install \
|
|
||||||
pdo_pgsql \
|
|
||||||
intl \
|
|
||||||
zip \
|
|
||||||
opcache \
|
|
||||||
&& pecl install redis \
|
|
||||||
&& docker-php-ext-enable redis \
|
|
||||||
&& apk del $PHPIZE_DEPS
|
|
||||||
|
|
||||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
|
||||||
|
|
||||||
WORKDIR /var/www
|
|
||||||
|
|
||||||
COPY docker/app/php.ini /usr/local/etc/php/conf.d/app.ini
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. PHP-Konfiguration
|
|
||||||
|
|
||||||
### `docker/app/php.ini`
|
|
||||||
|
|
||||||
```ini
|
|
||||||
opcache.enable=0
|
|
||||||
memory_limit=256M
|
|
||||||
upload_max_filesize=20M
|
|
||||||
post_max_size=20M
|
|
||||||
```
|
|
||||||
|
|
||||||
`opcache.enable=0` weil der Dev-Build keinen Preload hat — in Prod kann auf `1` + `opcache.preload` umgestellt werden, wenn gewünscht.
|
|
||||||
|
|
||||||
### `docker/app/zz-fpm-pool.conf`
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[www]
|
|
||||||
; SSE connections hold a worker for up to 90 s each — raise the pool ceiling
|
|
||||||
; so regular requests are not starved.
|
|
||||||
pm = dynamic
|
|
||||||
pm.max_children = 30
|
|
||||||
pm.start_servers = 4
|
|
||||||
pm.min_spare_servers = 2
|
|
||||||
pm.max_spare_servers = 8
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Caddyfile (Prod) — `docker/caddy/Caddyfile`
|
|
||||||
|
|
||||||
```
|
|
||||||
{
|
|
||||||
admin off
|
|
||||||
}
|
|
||||||
|
|
||||||
ss3k.schaunwama.de {
|
|
||||||
root * /var/www/public
|
|
||||||
php_fastcgi app:9000
|
|
||||||
file_server
|
|
||||||
encode gzip
|
|
||||||
|
|
||||||
header {
|
|
||||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
|
||||||
X-Content-Type-Options "nosniff"
|
|
||||||
X-Frame-Options "DENY"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Prod enthält keinen `erpstaging`-Block.** Frappe ERP läuft auf einer eigenen Instanz (extern, `FRAPPE_ERP_BASE_URL` in `.env.local`).
|
|
||||||
|
|
||||||
> Aktueller Dev-Stand hat noch den `erpstaging.schaunwama.de`-Block für die Staging-ERP-Weiterleitung — der muss vor dem ersten Prod-Deploy entfernt werden.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Umgebungsvariablen
|
|
||||||
|
|
||||||
### `.env` (ohne Credentials, in Git)
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
APP_ENV=prod
|
|
||||||
APP_SECRET=change_me_in_env_local
|
|
||||||
POSTGRES_DB=superseller
|
|
||||||
POSTGRES_USER=superseller
|
|
||||||
POSTGRES_PASSWORD=change_me
|
|
||||||
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?serverVersion=17&charset=utf8"
|
|
||||||
REDIS_PASSWORD=change_me
|
|
||||||
REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379
|
|
||||||
MESSENGER_TRANSPORT_DSN=redis://:${REDIS_PASSWORD}@redis:6379/messages
|
|
||||||
MAILER_DSN=smtp://localhost
|
|
||||||
TAVILY_API_KEY=
|
|
||||||
MISTRAL_BASE_URL=https://api.mistral.ai
|
|
||||||
MISTRAL_API_KEY=
|
|
||||||
MISTRAL_VISION_MODEL=pixtral-12b-2409
|
|
||||||
MISTRAL_TEXT_MODEL=mistral-large-latest
|
|
||||||
AI_TEXT_MODEL=${MISTRAL_TEXT_MODEL}
|
|
||||||
AI_VISION_MODEL=${MISTRAL_VISION_MODEL}
|
|
||||||
EBAY_CLIENT_ID=
|
|
||||||
EBAY_CLIENT_SECRET=
|
|
||||||
EBAY_MARKETPLACE_ID=EBAY_DE
|
|
||||||
EBAY_API_BASE_URL=https://api.ebay.com
|
|
||||||
EBAY_OAUTH_BASE_URL=https://api.ebay.com
|
|
||||||
EBAY_VERIFICATION_TOKEN=
|
|
||||||
EBAY_ENDPOINT_URL=https://ss3k.schaunwama.de/webhooks/ebay
|
|
||||||
FRAPPE_ERP_BASE_URL=https://erp.example.com
|
|
||||||
FRAPPE_ERP_API_KEY=changeme
|
|
||||||
FRAPPE_ERP_API_SECRET=changeme
|
|
||||||
FRAPPE_GENERIC_ITEM_CODE=REFURB-HW
|
|
||||||
SUPPLIER_EMAIL=lieferant@example.com
|
|
||||||
SENDER_EMAIL=noreply@superseller3000.de
|
|
||||||
```
|
|
||||||
|
|
||||||
### `.env.local` (nur auf Server, nie in Git)
|
|
||||||
|
|
||||||
Alle echten Credentials überschreiben hier die Platzhalter aus `.env`:
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
APP_SECRET=<random-32-char-hex>
|
|
||||||
POSTGRES_PASSWORD=<sicheres-passwort>
|
|
||||||
REDIS_PASSWORD=<sicheres-passwort>
|
|
||||||
MAILER_DSN=smtp://user:pass@mailserver:587
|
|
||||||
TAVILY_API_KEY=tvly-...
|
|
||||||
MISTRAL_API_KEY=sk-...
|
|
||||||
EBAY_CLIENT_ID=...
|
|
||||||
EBAY_CLIENT_SECRET=...
|
|
||||||
EBAY_VERIFICATION_TOKEN=... # aus eBay Developer Portal
|
|
||||||
FRAPPE_ERP_BASE_URL=https://erp.meinefirma.de
|
|
||||||
FRAPPE_ERP_API_KEY=...
|
|
||||||
FRAPPE_ERP_API_SECRET=...
|
|
||||||
SUPPLIER_EMAIL=lieferant@meinefirma.de
|
|
||||||
SENDER_EMAIL=noreply@meinefirma.de
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. VPS-Voraussetzungen
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Docker + Compose Plugin
|
|
||||||
apt install docker.io docker-compose-plugin
|
|
||||||
|
|
||||||
# User 1000 muss existieren (PHP läuft als 1000:1000)
|
|
||||||
id -u superseller # sollte 1000 sein; ggf. anpassen
|
|
||||||
|
|
||||||
# UFW: nur Caddy-Ports + SSH
|
|
||||||
ufw allow 22/tcp
|
|
||||||
ufw allow 80/tcp
|
|
||||||
ufw allow 443/tcp
|
|
||||||
ufw enable
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Erste Inbetriebnahme (Schritt für Schritt)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Repo klonen
|
|
||||||
git clone <gitea-url>/superseller3000 /home/superseller/SuperSeller3000
|
|
||||||
cd /home/superseller/SuperSeller3000
|
|
||||||
|
|
||||||
# 2. .env.local mit echten Credentials anlegen (siehe Abschnitt 7)
|
|
||||||
nano .env.local
|
|
||||||
|
|
||||||
# 3. Caddyfile prüfen — kein erpstaging-Block!
|
|
||||||
# (docker/caddy/Caddyfile muss nur den ss3k.schaunwama.de-Block enthalten)
|
|
||||||
|
|
||||||
# 4. Images bauen + DB hochfahren
|
|
||||||
docker compose up -d postgres redis
|
|
||||||
# warten bis postgres healthy
|
|
||||||
|
|
||||||
# 5. Composer install
|
|
||||||
docker compose run --rm app composer install --no-dev --optimize-autoloader
|
|
||||||
|
|
||||||
# 6. Datenbankmigrationen ausführen
|
|
||||||
docker compose run --rm app php bin/console doctrine:migrations:migrate --no-interaction
|
|
||||||
|
|
||||||
# 7. Cache aufwärmen
|
|
||||||
docker compose run --rm app php bin/console cache:warmup --env=prod
|
|
||||||
|
|
||||||
# 8. Ersten Admin-User anlegen
|
|
||||||
docker compose run --rm app php bin/console app:users:create --env=prod
|
|
||||||
|
|
||||||
# 9. Ersten API-Key anlegen (Klartext wird nur einmal angezeigt)
|
|
||||||
docker compose run --rm app php bin/console app:api-keys:create --env=prod
|
|
||||||
|
|
||||||
# 10. Alle Services starten
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# 11. eBay-Webhook registrieren (URL in .env.local: EBAY_ENDPOINT_URL)
|
|
||||||
# Im eBay Developer Portal: Notification URL = https://ss3k.schaunwama.de/webhooks/ebay
|
|
||||||
# Verification Token = EBAY_VERIFICATION_TOKEN
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Laufender Betrieb
|
|
||||||
|
|
||||||
### Worker-Status prüfen
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose ps
|
|
||||||
docker compose logs -f worker-ai
|
|
||||||
docker compose logs -f worker-orders
|
|
||||||
docker compose logs -f worker-channel
|
|
||||||
```
|
|
||||||
|
|
||||||
### Failed Messages (nach Erschöpfung aller Retries)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Übersicht
|
|
||||||
docker compose exec app php bin/console messenger:failed:show
|
|
||||||
|
|
||||||
# Einzelne Message erneut versuchen
|
|
||||||
docker compose exec app php bin/console messenger:failed:retry
|
|
||||||
|
|
||||||
# Alle nochmal
|
|
||||||
docker compose exec app php bin/console messenger:failed:retry --all
|
|
||||||
```
|
|
||||||
|
|
||||||
### Updates deployen
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git pull
|
|
||||||
docker compose build app worker-ai worker-orders worker-channel cron
|
|
||||||
docker compose run --rm app composer install --no-dev --optimize-autoloader
|
|
||||||
docker compose run --rm app php bin/console doctrine:migrations:migrate --no-interaction
|
|
||||||
docker compose run --rm app php bin/console cache:warmup --env=prod
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### Logs (Admin-Panel)
|
|
||||||
|
|
||||||
`https://ss3k.schaunwama.de/admin` → **Log Entries** — Fulltext-Suche, Filter nach Level/Channel/Zeitraum.
|
|
||||||
Rotation läuft täglich automatisch (Cron-Service): Einträge > 90 Tage → `logs_archive`, danach gelöscht.
|
|
||||||
|
|
||||||
### Backup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# PostgreSQL-Dump
|
|
||||||
docker compose exec postgres pg_dump -U superseller superseller | gzip > backup_$(date +%Y%m%d).sql.gz
|
|
||||||
|
|
||||||
# Artikel-Fotos und Rechnungs-PDFs (Docker Volumes)
|
|
||||||
docker run --rm -v superseller3000_postgres_data:/data -v $(pwd):/backup \
|
|
||||||
alpine tar czf /backup/volume_postgres_$(date +%Y%m%d).tar.gz /data
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. AI-Konfiguration
|
|
||||||
|
|
||||||
### Primäres Backend: Mistral Cloud API
|
|
||||||
|
|
||||||
Standardkonfiguration — `config/services.yaml` bereits so ausgeliefert:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
App\Infrastructure\AI\OllamaClientInterface:
|
|
||||||
alias: App\Infrastructure\AI\MistralClient
|
|
||||||
```
|
|
||||||
|
|
||||||
`.env.local`:
|
|
||||||
```dotenv
|
|
||||||
MISTRAL_API_KEY=sk-...
|
|
||||||
# Modelle sind in .env vorbelegt:
|
|
||||||
# AI_TEXT_MODEL=${MISTRAL_TEXT_MODEL} → mistral-large-latest
|
|
||||||
# AI_VISION_MODEL=${MISTRAL_VISION_MODEL} → pixtral-12b-2409
|
|
||||||
```
|
|
||||||
|
|
||||||
Web-Suche (SpecsResearchAgent): **Tavily API** — `TAVILY_API_KEY=tvly-...` in `.env.local`.
|
|
||||||
|
|
||||||
Nach Schlüsseländerung: `docker compose exec app php bin/console cache:clear`.
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace DoctrineMigrations;
|
|
||||||
|
|
||||||
use Doctrine\DBAL\Schema\Schema;
|
|
||||||
use Doctrine\Migrations\AbstractMigration;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Auto-generated Migration: Please modify to your needs!
|
|
||||||
*/
|
|
||||||
final class Version20260519070206 extends AbstractMigration
|
|
||||||
{
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function up(Schema $schema): void
|
|
||||||
{
|
|
||||||
// this up() migration is auto-generated, please modify it to your needs
|
|
||||||
$this->addSql('ALTER TABLE app.article_type_ebay_mappings ALTER required DROP DEFAULT');
|
|
||||||
$this->addSql('ALTER INDEX app.article_type_ebay_mappings_article_type_id_ebay_aspect_name_key RENAME TO uq_ebay_mapping');
|
|
||||||
$this->addSql('ALTER TABLE app.article_type_platform_configs ADD fulfillment_policy_id VARCHAR(100) DEFAULT NULL');
|
|
||||||
$this->addSql('ALTER TABLE app.article_type_platform_configs ADD payment_policy_id VARCHAR(100) DEFAULT NULL');
|
|
||||||
$this->addSql('ALTER TABLE app.article_type_platform_configs ADD return_policy_id VARCHAR(100) DEFAULT NULL');
|
|
||||||
$this->addSql('ALTER TABLE app.article_type_platform_configs ADD merchant_location_key VARCHAR(100) DEFAULT NULL');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
|
||||||
{
|
|
||||||
// this down() migration is auto-generated, please modify it to your needs
|
|
||||||
$this->addSql('CREATE SCHEMA logs_archive');
|
|
||||||
$this->addSql('ALTER TABLE app.article_type_ebay_mappings ALTER required SET DEFAULT false');
|
|
||||||
$this->addSql('ALTER INDEX app.uq_ebay_mapping RENAME TO article_type_ebay_mappings_article_type_id_ebay_aspect_name_key');
|
|
||||||
$this->addSql('ALTER TABLE app.article_type_platform_configs DROP fulfillment_policy_id');
|
|
||||||
$this->addSql('ALTER TABLE app.article_type_platform_configs DROP payment_policy_id');
|
|
||||||
$this->addSql('ALTER TABLE app.article_type_platform_configs DROP return_policy_id');
|
|
||||||
$this->addSql('ALTER TABLE app.article_type_platform_configs DROP merchant_location_key');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace DoctrineMigrations;
|
|
||||||
|
|
||||||
use Doctrine\DBAL\Schema\Schema;
|
|
||||||
use Doctrine\Migrations\AbstractMigration;
|
|
||||||
|
|
||||||
final class Version20260520090000 extends AbstractMigration
|
|
||||||
{
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return 'Add ebay_aspect_field_mappings JSON column to article_types';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function up(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql("ALTER TABLE app.article_types ADD COLUMN ebay_aspect_field_mappings JSON DEFAULT NULL");
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql("ALTER TABLE app.article_types DROP COLUMN ebay_aspect_field_mappings");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace DoctrineMigrations;
|
|
||||||
|
|
||||||
use Doctrine\DBAL\Schema\Schema;
|
|
||||||
use Doctrine\Migrations\AbstractMigration;
|
|
||||||
|
|
||||||
final class Version20260520100000 extends AbstractMigration
|
|
||||||
{
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return 'Replace ebay_aspect_field_mappings JSON with article_type_ebay_mappings table';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function up(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql("
|
|
||||||
CREATE TABLE app.article_type_ebay_mappings (
|
|
||||||
id UUID NOT NULL,
|
|
||||||
article_type_id UUID NOT NULL,
|
|
||||||
ebay_aspect_name VARCHAR(255) NOT NULL,
|
|
||||||
source_type VARCHAR(30) NOT NULL,
|
|
||||||
article_field_key VARCHAR(100) DEFAULT NULL,
|
|
||||||
attribute_definition_id UUID DEFAULT NULL,
|
|
||||||
required BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
UNIQUE (article_type_id, ebay_aspect_name),
|
|
||||||
CONSTRAINT fk_etm_article_type FOREIGN KEY (article_type_id)
|
|
||||||
REFERENCES app.article_types (id) ON DELETE CASCADE,
|
|
||||||
CONSTRAINT fk_etm_attr_def FOREIGN KEY (attribute_definition_id)
|
|
||||||
REFERENCES app.attribute_definitions (id) ON DELETE SET NULL
|
|
||||||
)
|
|
||||||
");
|
|
||||||
$this->addSql("ALTER TABLE app.article_types DROP COLUMN IF EXISTS ebay_aspect_field_mappings");
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql("DROP TABLE app.article_type_ebay_mappings");
|
|
||||||
$this->addSql("ALTER TABLE app.article_types ADD COLUMN ebay_aspect_field_mappings JSON DEFAULT NULL");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -135,11 +135,6 @@ class Article
|
||||||
return $this->articleType;
|
return $this->articleType;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getArticleTypeName(): string
|
|
||||||
{
|
|
||||||
return $this->articleType->getName();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getSku(): string
|
public function getSku(): string
|
||||||
{
|
{
|
||||||
return $this->sku;
|
return $this->sku;
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,6 @@ class ArticleType
|
||||||
#[ORM\OneToMany(targetEntity: ArticleTypeAttribute::class, mappedBy: 'articleType', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
#[ORM\OneToMany(targetEntity: ArticleTypeAttribute::class, mappedBy: 'articleType', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||||
private Collection $attributeAssignments;
|
private Collection $attributeAssignments;
|
||||||
|
|
||||||
/** @var Collection<int, ArticleTypeEbayMapping> */
|
|
||||||
#[ORM\OneToMany(targetEntity: ArticleTypeEbayMapping::class, mappedBy: 'articleType', cascade: ['persist', 'remove'], orphanRemoval: true, indexBy: 'ebayAspectName')]
|
|
||||||
private Collection $ebayMappings;
|
|
||||||
|
|
||||||
/** @var list<AttributeDefinition>|null pending from form — applied by applyAttributeAssignments() */
|
/** @var list<AttributeDefinition>|null pending from form — applied by applyAttributeAssignments() */
|
||||||
private ?array $pendingRequired = null;
|
private ?array $pendingRequired = null;
|
||||||
|
|
||||||
|
|
@ -42,7 +38,6 @@ class ArticleType
|
||||||
$this->id = Uuid::v7();
|
$this->id = Uuid::v7();
|
||||||
$this->name = $name;
|
$this->name = $name;
|
||||||
$this->attributeAssignments = new ArrayCollection();
|
$this->attributeAssignments = new ArrayCollection();
|
||||||
$this->ebayMappings = new ArrayCollection();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function __toString(): string
|
public function __toString(): string
|
||||||
|
|
@ -75,25 +70,6 @@ class ArticleType
|
||||||
$this->ebayCategoryId = $id;
|
$this->ebayCategoryId = $id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return Collection<int, ArticleTypeEbayMapping> */
|
|
||||||
public function getEbayMappings(): Collection
|
|
||||||
{
|
|
||||||
return $this->ebayMappings;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function upsertEbayMapping(ArticleTypeEbayMapping $mapping): void
|
|
||||||
{
|
|
||||||
foreach ($this->ebayMappings as $existing) {
|
|
||||||
if ($existing->getEbayAspectName() === $mapping->getEbayAspectName()) {
|
|
||||||
$existing->setArticleFieldKey($mapping->getArticleFieldKey());
|
|
||||||
$existing->setAttributeDefinition($mapping->getAttributeDefinition());
|
|
||||||
$existing->setRequired($mapping->isRequired());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$this->ebayMappings->add($mapping);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return Collection<int, ArticleTypeAttribute> */
|
/** @return Collection<int, ArticleTypeAttribute> */
|
||||||
public function getAttributeAssignments(): Collection
|
public function getAttributeAssignments(): Collection
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Domain\Article;
|
|
||||||
|
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
|
||||||
use Symfony\Component\Uid\Uuid;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Explicit mapping: eBay aspect name → source field for a given ArticleType.
|
|
||||||
* sourceType 'article_field' reads a direct Article getter (manufacturer, modelNumber, …).
|
|
||||||
* sourceType 'attribute' reads an AttributeValue linked to an AttributeDefinition.
|
|
||||||
*/
|
|
||||||
#[ORM\Entity]
|
|
||||||
#[ORM\Table(name: 'article_type_ebay_mappings', schema: 'app')]
|
|
||||||
#[ORM\UniqueConstraint(name: 'uq_ebay_mapping', columns: ['article_type_id', 'ebay_aspect_name'])]
|
|
||||||
class ArticleTypeEbayMapping
|
|
||||||
{
|
|
||||||
public const SOURCE_ARTICLE_FIELD = 'article_field';
|
|
||||||
public const SOURCE_ATTRIBUTE = 'attribute';
|
|
||||||
|
|
||||||
#[ORM\Id]
|
|
||||||
#[ORM\Column(type: 'uuid')]
|
|
||||||
private Uuid $id;
|
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: ArticleType::class, inversedBy: 'ebayMappings')]
|
|
||||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
|
||||||
private ArticleType $articleType;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'string', length: 255)]
|
|
||||||
private string $ebayAspectName;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'string', length: 30)]
|
|
||||||
private string $sourceType;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'string', length: 100, nullable: true)]
|
|
||||||
private ?string $articleFieldKey = null;
|
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: AttributeDefinition::class)]
|
|
||||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
|
||||||
private ?AttributeDefinition $attributeDefinition = null;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'boolean')]
|
|
||||||
private bool $required = false;
|
|
||||||
|
|
||||||
public function __construct(ArticleType $articleType, string $ebayAspectName, string $sourceType)
|
|
||||||
{
|
|
||||||
$this->id = Uuid::v7();
|
|
||||||
$this->articleType = $articleType;
|
|
||||||
$this->ebayAspectName = $ebayAspectName;
|
|
||||||
$this->sourceType = $sourceType;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getId(): Uuid { return $this->id; }
|
|
||||||
|
|
||||||
public function getArticleType(): ArticleType { return $this->articleType; }
|
|
||||||
|
|
||||||
public function getEbayAspectName(): string { return $this->ebayAspectName; }
|
|
||||||
|
|
||||||
public function getSourceType(): string { return $this->sourceType; }
|
|
||||||
|
|
||||||
public function getArticleFieldKey(): ?string { return $this->articleFieldKey; }
|
|
||||||
|
|
||||||
public function setArticleFieldKey(?string $key): void { $this->articleFieldKey = $key; }
|
|
||||||
|
|
||||||
public function getAttributeDefinition(): ?AttributeDefinition { return $this->attributeDefinition; }
|
|
||||||
|
|
||||||
public function setAttributeDefinition(?AttributeDefinition $def): void { $this->attributeDefinition = $def; }
|
|
||||||
|
|
||||||
public function isRequired(): bool { return $this->required; }
|
|
||||||
|
|
||||||
public function setRequired(bool $required): void { $this->required = $required; }
|
|
||||||
|
|
||||||
public function getSourceLabel(): string
|
|
||||||
{
|
|
||||||
if (self::SOURCE_ARTICLE_FIELD === $this->sourceType) {
|
|
||||||
return 'Artikelfeld: '.($this->articleFieldKey ?? '?');
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'Attribut: '.($this->attributeDefinition?->getName() ?? '?');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -30,18 +30,6 @@ class ArticleTypePlatformConfig
|
||||||
#[ORM\Column(type: 'string', length: 255)]
|
#[ORM\Column(type: 'string', length: 255)]
|
||||||
private string $categoryId;
|
private string $categoryId;
|
||||||
|
|
||||||
#[ORM\Column(type: 'string', length: 100, nullable: true)]
|
|
||||||
private ?string $fulfillmentPolicyId = null;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'string', length: 100, nullable: true)]
|
|
||||||
private ?string $paymentPolicyId = null;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'string', length: 100, nullable: true)]
|
|
||||||
private ?string $returnPolicyId = null;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'string', length: 100, nullable: true)]
|
|
||||||
private ?string $merchantLocationKey = null;
|
|
||||||
|
|
||||||
/** @var Collection<int, AttributeMapping> */
|
/** @var Collection<int, AttributeMapping> */
|
||||||
#[ORM\OneToMany(mappedBy: 'platformConfig', targetEntity: AttributeMapping::class, cascade: ['persist', 'remove'])]
|
#[ORM\OneToMany(mappedBy: 'platformConfig', targetEntity: AttributeMapping::class, cascade: ['persist', 'remove'])]
|
||||||
private Collection $attributeMappings;
|
private Collection $attributeMappings;
|
||||||
|
|
@ -80,46 +68,6 @@ class ArticleTypePlatformConfig
|
||||||
$this->categoryId = $categoryId;
|
$this->categoryId = $categoryId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getFulfillmentPolicyId(): ?string
|
|
||||||
{
|
|
||||||
return $this->fulfillmentPolicyId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setFulfillmentPolicyId(?string $fulfillmentPolicyId): void
|
|
||||||
{
|
|
||||||
$this->fulfillmentPolicyId = $fulfillmentPolicyId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getPaymentPolicyId(): ?string
|
|
||||||
{
|
|
||||||
return $this->paymentPolicyId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setPaymentPolicyId(?string $paymentPolicyId): void
|
|
||||||
{
|
|
||||||
$this->paymentPolicyId = $paymentPolicyId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getReturnPolicyId(): ?string
|
|
||||||
{
|
|
||||||
return $this->returnPolicyId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setReturnPolicyId(?string $returnPolicyId): void
|
|
||||||
{
|
|
||||||
$this->returnPolicyId = $returnPolicyId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getMerchantLocationKey(): ?string
|
|
||||||
{
|
|
||||||
return $this->merchantLocationKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setMerchantLocationKey(?string $merchantLocationKey): void
|
|
||||||
{
|
|
||||||
$this->merchantLocationKey = $merchantLocationKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return Collection<int, AttributeMapping> */
|
/** @return Collection<int, AttributeMapping> */
|
||||||
public function getAttributeMappings(): Collection
|
public function getAttributeMappings(): Collection
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Domain\Channel\Repository;
|
namespace App\Domain\Channel\Repository;
|
||||||
|
|
||||||
use App\Domain\Article\ArticleType;
|
|
||||||
use App\Domain\Channel\ArticleTypePlatformConfig;
|
use App\Domain\Channel\ArticleTypePlatformConfig;
|
||||||
use Symfony\Component\Uid\Uuid;
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
|
||||||
|
|
@ -17,8 +16,6 @@ interface ArticleTypePlatformConfigRepositoryInterface
|
||||||
/** @return list<ArticleTypePlatformConfig> */
|
/** @return list<ArticleTypePlatformConfig> */
|
||||||
public function findByArticleType(Uuid $articleTypeId): array;
|
public function findByArticleType(Uuid $articleTypeId): array;
|
||||||
|
|
||||||
public function findByArticleTypeAndPlatformType(ArticleType $articleType, string $platformType): ?ArticleTypePlatformConfig;
|
|
||||||
|
|
||||||
public function save(ArticleTypePlatformConfig $config): void;
|
public function save(ArticleTypePlatformConfig $config): void;
|
||||||
|
|
||||||
public function remove(ArticleTypePlatformConfig $config): void;
|
public function remove(ArticleTypePlatformConfig $config): void;
|
||||||
|
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Infrastructure\Channel\Ebay;
|
|
||||||
|
|
||||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
|
||||||
|
|
||||||
final class EbayAccountApiClient
|
|
||||||
{
|
|
||||||
private const ACCOUNT_BASE = '/sell/account/v1';
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly HttpClientInterface $httpClient,
|
|
||||||
private readonly EbayOAuthClient $oauthClient,
|
|
||||||
private readonly string $apiBaseUrl,
|
|
||||||
private readonly string $marketplaceId,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<array{fulfillmentPolicyId: string, name: string}>
|
|
||||||
*/
|
|
||||||
public function getFulfillmentPolicies(): array
|
|
||||||
{
|
|
||||||
$data = $this->request('GET', self::ACCOUNT_BASE.'/fulfillment_policy?marketplace_id='.$this->marketplaceId);
|
|
||||||
|
|
||||||
/** @var list<array{fulfillmentPolicyId: string, name: string}> */
|
|
||||||
return $data['fulfillmentPolicies'] ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<array{paymentPolicyId: string, name: string}>
|
|
||||||
*/
|
|
||||||
public function getPaymentPolicies(): array
|
|
||||||
{
|
|
||||||
$data = $this->request('GET', self::ACCOUNT_BASE.'/payment_policy?marketplace_id='.$this->marketplaceId);
|
|
||||||
|
|
||||||
/** @var list<array{paymentPolicyId: string, name: string}> */
|
|
||||||
return $data['paymentPolicies'] ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<array{returnPolicyId: string, name: string}>
|
|
||||||
*/
|
|
||||||
public function getReturnPolicies(): array
|
|
||||||
{
|
|
||||||
$data = $this->request('GET', self::ACCOUNT_BASE.'/return_policy?marketplace_id='.$this->marketplaceId);
|
|
||||||
|
|
||||||
/** @var list<array{returnPolicyId: string, name: string}> */
|
|
||||||
return $data['returnPolicies'] ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function request(string $method, string $path): array
|
|
||||||
{
|
|
||||||
$token = $this->oauthClient->getAccessToken();
|
|
||||||
|
|
||||||
$response = $this->httpClient->request($method, $this->apiBaseUrl.$path, [
|
|
||||||
'headers' => [
|
|
||||||
'Authorization' => 'Bearer '.$token,
|
|
||||||
'Content-Type' => 'application/json',
|
|
||||||
'X-EBAY-C-MARKETPLACE-ID' => $this->marketplaceId,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$statusCode = $response->getStatusCode();
|
|
||||||
if ($statusCode >= 400) {
|
|
||||||
$content = $response->getContent(false);
|
|
||||||
throw new \RuntimeException("eBay Account API error {$statusCode}: {$content}");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var array<string, mixed> $data */
|
|
||||||
$data = $response->toArray();
|
|
||||||
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -7,16 +7,12 @@ namespace App\Infrastructure\Channel\Ebay;
|
||||||
use App\Application\Channel\ChannelAdapterInterface;
|
use App\Application\Channel\ChannelAdapterInterface;
|
||||||
use App\Domain\Article\Article;
|
use App\Domain\Article\Article;
|
||||||
use App\Domain\Article\ArticleCondition;
|
use App\Domain\Article\ArticleCondition;
|
||||||
use App\Domain\Article\ArticleTypeEbayMapping;
|
|
||||||
use App\Domain\Channel\Repository\ArticleTypePlatformConfigRepositoryInterface;
|
|
||||||
use App\Domain\Order\Order;
|
use App\Domain\Order\Order;
|
||||||
|
|
||||||
final class EbayAdapter implements ChannelAdapterInterface
|
final class EbayAdapter implements ChannelAdapterInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly EbayInventoryApiClient $apiClient,
|
private readonly EbayInventoryApiClient $apiClient,
|
||||||
private readonly ArticleTypePlatformConfigRepositoryInterface $platformConfigRepository,
|
|
||||||
private readonly string $marketplaceId,
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -28,14 +24,6 @@ final class EbayAdapter implements ChannelAdapterInterface
|
||||||
public function publishListing(Article $article): string
|
public function publishListing(Article $article): string
|
||||||
{
|
{
|
||||||
$sku = $article->getSku();
|
$sku = $article->getSku();
|
||||||
$config = $this->platformConfigRepository->findByArticleTypeAndPlatformType(
|
|
||||||
$article->getArticleType(),
|
|
||||||
'ebay'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (null === $config) {
|
|
||||||
throw new \RuntimeException(\sprintf('No eBay platform config found for ArticleType "%s"', $article->getArticleType()->getName()));
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->apiClient->upsertInventoryItem($sku, [
|
$this->apiClient->upsertInventoryItem($sku, [
|
||||||
'availability' => [
|
'availability' => [
|
||||||
|
|
@ -52,18 +40,11 @@ final class EbayAdapter implements ChannelAdapterInterface
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$listingPolicies = array_filter([
|
$offerId = $this->apiClient->createOffer([
|
||||||
'fulfillmentPolicyId' => $config->getFulfillmentPolicyId(),
|
|
||||||
'paymentPolicyId' => $config->getPaymentPolicyId(),
|
|
||||||
'returnPolicyId' => $config->getReturnPolicyId(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$offerBody = [
|
|
||||||
'sku' => $sku,
|
'sku' => $sku,
|
||||||
'marketplaceId' => $this->marketplaceId,
|
'marketplaceId' => 'EBAY_DE',
|
||||||
'format' => 'FIXED_PRICE',
|
'format' => 'FIXED_PRICE',
|
||||||
'availableQuantity' => $article->getStock(),
|
'availableQuantity' => $article->getStock(),
|
||||||
'categoryId' => $config->getCategoryId(),
|
|
||||||
'pricingSummary' => [
|
'pricingSummary' => [
|
||||||
'price' => [
|
'price' => [
|
||||||
'currency' => 'EUR',
|
'currency' => 'EUR',
|
||||||
|
|
@ -71,17 +52,8 @@ final class EbayAdapter implements ChannelAdapterInterface
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'listingDescription' => $article->getEbayDescription() ?? '',
|
'listingDescription' => $article->getEbayDescription() ?? '',
|
||||||
];
|
'categoryId' => $this->getCategoryId(),
|
||||||
|
]);
|
||||||
if ([] !== $listingPolicies) {
|
|
||||||
$offerBody['listingPolicies'] = $listingPolicies;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (null !== $config->getMerchantLocationKey()) {
|
|
||||||
$offerBody['merchantLocationKey'] = $config->getMerchantLocationKey();
|
|
||||||
}
|
|
||||||
|
|
||||||
$offerId = $this->apiClient->createOffer($offerBody);
|
|
||||||
|
|
||||||
return $this->apiClient->publishOffer($offerId);
|
return $this->apiClient->publishOffer($offerId);
|
||||||
}
|
}
|
||||||
|
|
@ -139,36 +111,16 @@ final class EbayAdapter implements ChannelAdapterInterface
|
||||||
private function buildAspects(Article $article): array
|
private function buildAspects(Article $article): array
|
||||||
{
|
{
|
||||||
$aspects = [];
|
$aspects = [];
|
||||||
|
|
||||||
$valuesByDefId = [];
|
|
||||||
foreach ($article->getAttributeValues() as $value) {
|
foreach ($article->getAttributeValues() as $value) {
|
||||||
$valuesByDefId[$value->getAttributeDefinition()->getId()->toRfc4122()] = $value->getValue();
|
$name = $value->getAttributeDefinition()->getName();
|
||||||
}
|
$aspects[$name] = [$value->getValue()];
|
||||||
|
|
||||||
foreach ($article->getArticleType()->getEbayMappings() as $mapping) {
|
|
||||||
$ebayName = $mapping->getEbayAspectName();
|
|
||||||
|
|
||||||
if (ArticleTypeEbayMapping::SOURCE_ARTICLE_FIELD === $mapping->getSourceType()) {
|
|
||||||
$getter = 'get'.ucfirst((string) $mapping->getArticleFieldKey());
|
|
||||||
if (!method_exists($article, $getter)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$fieldValue = $article->$getter();
|
|
||||||
if (null !== $fieldValue && '' !== (string) $fieldValue) {
|
|
||||||
$aspects[$ebayName] = [(string) $fieldValue];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$def = $mapping->getAttributeDefinition();
|
|
||||||
if (null === $def) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$val = $valuesByDefId[$def->getId()->toRfc4122()] ?? null;
|
|
||||||
if (null !== $val) {
|
|
||||||
$aspects[$ebayName] = [$val];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $aspects;
|
return $aspects;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function getCategoryId(): string
|
||||||
|
{
|
||||||
|
return '177';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,17 +74,6 @@ class EbayInventoryApiClient
|
||||||
$this->request('POST', self::INVENTORY_BASE.'/bulk_update_price_quantity', $quantityUpdate);
|
$this->request('POST', self::INVENTORY_BASE.'/bulk_update_price_quantity', $quantityUpdate);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<array{merchantLocationKey: string, name: string}>
|
|
||||||
*/
|
|
||||||
public function getLocations(): array
|
|
||||||
{
|
|
||||||
$data = $this->request('GET', self::INVENTORY_BASE.'/location', []);
|
|
||||||
|
|
||||||
/** @var list<array{merchantLocationKey: string, name: string}> */
|
|
||||||
return $data['locations'] ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds tracking info to an order.
|
* Adds tracking info to an order.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Infrastructure\Channel\Ebay;
|
|
||||||
|
|
||||||
use Psr\Cache\CacheItemPoolInterface;
|
|
||||||
|
|
||||||
final class EbayPolicyProvider
|
|
||||||
{
|
|
||||||
private const TTL = 300;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly EbayAccountApiClient $accountClient,
|
|
||||||
private readonly EbayInventoryApiClient $inventoryClient,
|
|
||||||
private readonly CacheItemPoolInterface $cache,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string> "Name (ID)" => policyId
|
|
||||||
*/
|
|
||||||
public function getFulfillmentChoices(): array
|
|
||||||
{
|
|
||||||
return $this->cached('ebay_policy_fulfillment', function (): array {
|
|
||||||
$choices = [];
|
|
||||||
foreach ($this->accountClient->getFulfillmentPolicies() as $policy) {
|
|
||||||
$choices[$policy['name'].' ('.$policy['fulfillmentPolicyId'].')'] = $policy['fulfillmentPolicyId'];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $choices;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string> "Name (ID)" => policyId
|
|
||||||
*/
|
|
||||||
public function getPaymentChoices(): array
|
|
||||||
{
|
|
||||||
return $this->cached('ebay_policy_payment', function (): array {
|
|
||||||
$choices = [];
|
|
||||||
foreach ($this->accountClient->getPaymentPolicies() as $policy) {
|
|
||||||
$choices[$policy['name'].' ('.$policy['paymentPolicyId'].')'] = $policy['paymentPolicyId'];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $choices;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string> "Name (ID)" => policyId
|
|
||||||
*/
|
|
||||||
public function getReturnChoices(): array
|
|
||||||
{
|
|
||||||
return $this->cached('ebay_policy_return', function (): array {
|
|
||||||
$choices = [];
|
|
||||||
foreach ($this->accountClient->getReturnPolicies() as $policy) {
|
|
||||||
$choices[$policy['name'].' ('.$policy['returnPolicyId'].')'] = $policy['returnPolicyId'];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $choices;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string> "Name (key)" => merchantLocationKey
|
|
||||||
*/
|
|
||||||
public function getLocationChoices(): array
|
|
||||||
{
|
|
||||||
return $this->cached('ebay_policy_location', function (): array {
|
|
||||||
$choices = [];
|
|
||||||
foreach ($this->inventoryClient->getLocations() as $location) {
|
|
||||||
$choices[$location['name'].' ('.$location['merchantLocationKey'].')'] = $location['merchantLocationKey'];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $choices;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param callable(): array<string, string> $loader
|
|
||||||
*
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
private function cached(string $key, callable $loader): array
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$item = $this->cache->getItem($key);
|
|
||||||
if ($item->isHit()) {
|
|
||||||
/** @var array<string, string> */
|
|
||||||
return $item->get();
|
|
||||||
}
|
|
||||||
|
|
||||||
$choices = $loader();
|
|
||||||
$item->set($choices);
|
|
||||||
$item->expiresAfter(self::TTL);
|
|
||||||
$this->cache->save($item);
|
|
||||||
|
|
||||||
return $choices;
|
|
||||||
} catch (\Throwable) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -71,9 +71,6 @@ final class DashboardController extends AbstractDashboardController
|
||||||
yield MenuItem::linkTo(TranslationCrudController::class, $t('menu.translations'), 'fa fa-language');
|
yield MenuItem::linkTo(TranslationCrudController::class, $t('menu.translations'), 'fa fa-language');
|
||||||
yield MenuItem::linkTo(UserCrudController::class, $t('menu.users'), 'fa fa-users');
|
yield MenuItem::linkTo(UserCrudController::class, $t('menu.users'), 'fa fa-users');
|
||||||
yield MenuItem::linkTo(LogEntryCrudController::class, $t('menu.logs'), 'fa fa-list');
|
yield MenuItem::linkTo(LogEntryCrudController::class, $t('menu.logs'), 'fa fa-list');
|
||||||
yield MenuItem::subMenu($t('menu.section_ebay'), 'fa fa-store')->setSubItems([
|
|
||||||
MenuItem::linkTo(EbayArticleTypePlatformConfigCrudController::class, $t('menu.ebay_platform_configs'), 'fa fa-sliders'),
|
|
||||||
]);
|
|
||||||
yield MenuItem::section($t('menu.section_sales'));
|
yield MenuItem::section($t('menu.section_sales'));
|
||||||
yield MenuItem::linkTo(OrderCrudController::class, $t('menu.orders'), 'fa fa-shopping-cart');
|
yield MenuItem::linkTo(OrderCrudController::class, $t('menu.orders'), 'fa fa-shopping-cart');
|
||||||
yield MenuItem::linkTo(CustomerCrudController::class, $t('menu.customers'), 'fa fa-users');
|
yield MenuItem::linkTo(CustomerCrudController::class, $t('menu.customers'), 'fa fa-users');
|
||||||
|
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Infrastructure\Http\Controller\Admin;
|
|
||||||
|
|
||||||
use App\Domain\Channel\ArticleTypePlatformConfig;
|
|
||||||
use App\Infrastructure\Channel\Ebay\EbayPolicyProvider;
|
|
||||||
use Doctrine\ORM\QueryBuilder;
|
|
||||||
use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection;
|
|
||||||
use EasyCorp\Bundle\EasyAdminBundle\Collection\FilterCollection;
|
|
||||||
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
|
|
||||||
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
|
|
||||||
use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
|
|
||||||
use EasyCorp\Bundle\EasyAdminBundle\Dto\SearchDto;
|
|
||||||
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
|
|
||||||
use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
|
|
||||||
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
|
|
||||||
use EasyCorp\Bundle\EasyAdminBundle\Orm\EntityRepository;
|
|
||||||
|
|
||||||
/** @extends AbstractCrudController<ArticleTypePlatformConfig> */
|
|
||||||
final class EbayArticleTypePlatformConfigCrudController extends AbstractCrudController
|
|
||||||
{
|
|
||||||
public function __construct(private readonly EbayPolicyProvider $policyProvider)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getEntityFqcn(): string
|
|
||||||
{
|
|
||||||
return ArticleTypePlatformConfig::class;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function configureCrud(Crud $crud): Crud
|
|
||||||
{
|
|
||||||
return $crud
|
|
||||||
->setEntityLabelInSingular('eBay Kategorie-Konfiguration')
|
|
||||||
->setEntityLabelInPlural('eBay Kategorie-Konfigurationen')
|
|
||||||
->setDefaultSort(['id' => 'ASC']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder
|
|
||||||
{
|
|
||||||
$qb = $this->container->get(EntityRepository::class)->createQueryBuilder($searchDto, $entityDto, $fields, $filters);
|
|
||||||
$qb->join(\sprintf('%s.platform', $qb->getRootAliases()[0]), 'ebay_platform')
|
|
||||||
->andWhere('ebay_platform.type = :platformType')
|
|
||||||
->setParameter('platformType', 'ebay');
|
|
||||||
|
|
||||||
return $qb;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function configureFields(string $pageName): iterable
|
|
||||||
{
|
|
||||||
yield AssociationField::new('articleType', 'Artikel-Typ');
|
|
||||||
yield TextField::new('categoryId', 'eBay Kategorie-ID');
|
|
||||||
|
|
||||||
$fulfillmentChoices = $this->policyProvider->getFulfillmentChoices();
|
|
||||||
if ([] !== $fulfillmentChoices) {
|
|
||||||
yield ChoiceField::new('fulfillmentPolicyId', 'Versand-Policy')
|
|
||||||
->setChoices($fulfillmentChoices)
|
|
||||||
->setRequired(false);
|
|
||||||
} else {
|
|
||||||
yield TextField::new('fulfillmentPolicyId', 'Versand-Policy (ID)')
|
|
||||||
->setHelp('eBay-API nicht erreichbar — ID manuell eingeben')
|
|
||||||
->setRequired(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
$paymentChoices = $this->policyProvider->getPaymentChoices();
|
|
||||||
if ([] !== $paymentChoices) {
|
|
||||||
yield ChoiceField::new('paymentPolicyId', 'Zahlungs-Policy')
|
|
||||||
->setChoices($paymentChoices)
|
|
||||||
->setRequired(false);
|
|
||||||
} else {
|
|
||||||
yield TextField::new('paymentPolicyId', 'Zahlungs-Policy (ID)')
|
|
||||||
->setHelp('eBay-API nicht erreichbar — ID manuell eingeben')
|
|
||||||
->setRequired(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
$returnChoices = $this->policyProvider->getReturnChoices();
|
|
||||||
if ([] !== $returnChoices) {
|
|
||||||
yield ChoiceField::new('returnPolicyId', 'Rückgabe-Policy')
|
|
||||||
->setChoices($returnChoices)
|
|
||||||
->setRequired(false);
|
|
||||||
} else {
|
|
||||||
yield TextField::new('returnPolicyId', 'Rückgabe-Policy (ID)')
|
|
||||||
->setHelp('eBay-API nicht erreichbar — ID manuell eingeben')
|
|
||||||
->setRequired(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
$locationChoices = $this->policyProvider->getLocationChoices();
|
|
||||||
if ([] !== $locationChoices) {
|
|
||||||
yield ChoiceField::new('merchantLocationKey', 'Lagerstandort')
|
|
||||||
->setChoices($locationChoices)
|
|
||||||
->setRequired(false);
|
|
||||||
} else {
|
|
||||||
yield TextField::new('merchantLocationKey', 'Lagerstandort (Key)')
|
|
||||||
->setHelp('eBay-API nicht erreichbar — Key manuell eingeben')
|
|
||||||
->setRequired(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -5,13 +5,11 @@ declare(strict_types=1);
|
||||||
namespace App\Infrastructure\Http\Controller\Admin;
|
namespace App\Infrastructure\Http\Controller\Admin;
|
||||||
|
|
||||||
use App\Domain\Article\ArticleType;
|
use App\Domain\Article\ArticleType;
|
||||||
use App\Domain\Article\ArticleTypeEbayMapping;
|
|
||||||
use App\Domain\Article\AttributeDefinition;
|
use App\Domain\Article\AttributeDefinition;
|
||||||
use App\Domain\Article\AttributeType;
|
use App\Domain\Article\AttributeType;
|
||||||
use App\Domain\Article\Repository\ArticleTypeRepositoryInterface;
|
use App\Domain\Article\Repository\ArticleTypeRepositoryInterface;
|
||||||
use App\Infrastructure\Channel\Ebay\EbayTaxonomyService;
|
use App\Infrastructure\Channel\Ebay\EbayTaxonomyService;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
@ -27,7 +25,6 @@ final class EbayAspectImportController extends AbstractController
|
||||||
private readonly ArticleTypeRepositoryInterface $articleTypeRepo,
|
private readonly ArticleTypeRepositoryInterface $articleTypeRepo,
|
||||||
private readonly EbayTaxonomyService $taxonomy,
|
private readonly EbayTaxonomyService $taxonomy,
|
||||||
private readonly EntityManagerInterface $em,
|
private readonly EntityManagerInterface $em,
|
||||||
private readonly AdminUrlGenerator $adminUrlGenerator,
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,31 +45,6 @@ final class EbayAspectImportController extends AbstractController
|
||||||
return $this->json(array_slice($results, 0, 15));
|
return $this->json(array_slice($results, 0, 15));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Article fields that can be mapped directly from eBay aspects.
|
|
||||||
* key = Article getter suffix / property name, value = German UI label.
|
|
||||||
*/
|
|
||||||
private const ARTICLE_FIELDS = [
|
|
||||||
'articleTypeName' => 'Produktart (Artikel-Typ)',
|
|
||||||
'manufacturer' => 'Hersteller (Marke)',
|
|
||||||
'modelNumber' => 'Herstellernummer (PN / MPN)',
|
|
||||||
'modelName' => 'Modellname',
|
|
||||||
'serialNumber' => 'Seriennummer',
|
|
||||||
];
|
|
||||||
|
|
||||||
/** eBay aspect names (lowercase) that auto-match to article fields. */
|
|
||||||
private const ARTICLE_FIELD_ALIASES = [
|
|
||||||
'produktart' => 'articleTypeName',
|
|
||||||
'marke' => 'manufacturer',
|
|
||||||
'brand' => 'manufacturer',
|
|
||||||
'hersteller' => 'manufacturer',
|
|
||||||
'herstellernummer' => 'modelNumber',
|
|
||||||
'mpn' => 'modelNumber',
|
|
||||||
'teilenummer' => 'modelNumber',
|
|
||||||
'modell' => 'modelName',
|
|
||||||
'seriennummer' => 'serialNumber',
|
|
||||||
];
|
|
||||||
|
|
||||||
#[Route('/admin/ebay/aspect-import/{id}', name: 'admin_ebay_aspect_import')]
|
#[Route('/admin/ebay/aspect-import/{id}', name: 'admin_ebay_aspect_import')]
|
||||||
#[IsGranted('ROLE_USER')]
|
#[IsGranted('ROLE_USER')]
|
||||||
public function __invoke(string $id, Request $request): Response
|
public function __invoke(string $id, Request $request): Response
|
||||||
|
|
@ -82,6 +54,7 @@ final class EbayAspectImportController extends AbstractController
|
||||||
throw $this->createNotFoundException();
|
throw $this->createNotFoundException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save category selection (step 1 of the 2-step flow)
|
||||||
if ($request->isMethod('POST') && 'set-category' === $request->request->get('_action')) {
|
if ($request->isMethod('POST') && 'set-category' === $request->request->get('_action')) {
|
||||||
return $this->handleSetCategory($request, $articleType);
|
return $this->handleSetCategory($request, $articleType);
|
||||||
}
|
}
|
||||||
|
|
@ -119,11 +92,9 @@ final class EbayAspectImportController extends AbstractController
|
||||||
'articleType' => $articleType,
|
'articleType' => $articleType,
|
||||||
'rows' => $rows,
|
'rows' => $rows,
|
||||||
'allDefs' => $allDefs,
|
'allDefs' => $allDefs,
|
||||||
'articleFields' => self::ARTICLE_FIELDS,
|
|
||||||
'counts' => $counts,
|
'counts' => $counts,
|
||||||
'categoryId' => $categoryId,
|
'categoryId' => $categoryId,
|
||||||
'searchUrl' => $this->generateUrl('admin_ebay_category_search'),
|
'searchUrl' => $this->generateUrl('admin_ebay_category_search'),
|
||||||
'existingMappings' => $articleType->getEbayMappings(),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -135,9 +106,7 @@ final class EbayAspectImportController extends AbstractController
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->redirect(
|
return $this->redirectToRoute('admin_ebay_aspect_import', ['id' => $articleType->getId()->toRfc4122()]);
|
||||||
$this->adminUrlGenerator->setRoute('admin_ebay_aspect_import', ['id' => $articleType->getId()->toRfc4122()])->generateUrl()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function handleImport(Request $request, ArticleType $articleType): Response
|
private function handleImport(Request $request, ArticleType $articleType): Response
|
||||||
|
|
@ -155,31 +124,16 @@ final class EbayAspectImportController extends AbstractController
|
||||||
if (!\is_array($rawData)) {
|
if (!\is_array($rawData)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// HTTP POST values are always strings; cast once to satisfy PHPStan level 9
|
||||||
/** @var array<string, string> $data */
|
/** @var array<string, string> $data */
|
||||||
$data = array_map(static fn (mixed $v): string => \is_scalar($v) ? (string) $v : '', $rawData);
|
$data = array_map(static fn (mixed $v): string => \is_scalar($v) ? (string) $v : '', $rawData);
|
||||||
|
|
||||||
$action = $data['action'] ?? 'skip';
|
$action = $data['action'] ?? 'skip';
|
||||||
$ebayName = $data['ebayName'] ?? '';
|
if ('skip' === $action) {
|
||||||
$isRequired = ($data['ebayRequired'] ?? '0') === '1';
|
|
||||||
|
|
||||||
if ('skip' === $action || '' === $ebayName) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('article_field' === $action) {
|
$markRequired = ($data['required'] ?? '0') === '1';
|
||||||
$fieldKey = $data['articleField'] ?? '';
|
|
||||||
if ('' === $fieldKey || !isset(self::ARTICLE_FIELDS[$fieldKey])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$mapping = new ArticleTypeEbayMapping($articleType, $ebayName, ArticleTypeEbayMapping::SOURCE_ARTICLE_FIELD);
|
|
||||||
$mapping->setArticleFieldKey($fieldKey);
|
|
||||||
$mapping->setRequired($isRequired);
|
|
||||||
$articleType->upsertEbayMapping($mapping);
|
|
||||||
++$imported;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$markRequired = $isRequired || ($data['required'] ?? '0') === '1';
|
|
||||||
|
|
||||||
if ('match' === $action) {
|
if ('match' === $action) {
|
||||||
$def = $this->em->find(AttributeDefinition::class, $data['definitionId'] ?? '');
|
$def = $this->em->find(AttributeDefinition::class, $data['definitionId'] ?? '');
|
||||||
|
|
@ -209,11 +163,6 @@ final class EbayAspectImportController extends AbstractController
|
||||||
$this->em->persist($def);
|
$this->em->persist($def);
|
||||||
}
|
}
|
||||||
|
|
||||||
$mapping = new ArticleTypeEbayMapping($articleType, $ebayName, ArticleTypeEbayMapping::SOURCE_ATTRIBUTE);
|
|
||||||
$mapping->setAttributeDefinition($def);
|
|
||||||
$mapping->setRequired($markRequired);
|
|
||||||
$articleType->upsertEbayMapping($mapping);
|
|
||||||
|
|
||||||
if ($markRequired) {
|
if ($markRequired) {
|
||||||
$requiredDefs[] = $def;
|
$requiredDefs[] = $def;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -260,63 +209,29 @@ final class EbayAspectImportController extends AbstractController
|
||||||
* @param list<array{name: string, required: bool, usage: string, values: list<string>}> $aspects
|
* @param list<array{name: string, required: bool, usage: string, values: list<string>}> $aspects
|
||||||
* @param list<AttributeDefinition> $allDefs
|
* @param list<AttributeDefinition> $allDefs
|
||||||
*
|
*
|
||||||
* @return list<array{aspect: array{name: string, required: bool, usage: string, values: list<string>}, action: string, preMatchId: string|null, preFieldKey: string|null, alreadyAssigned: bool, suggestedType: string}>
|
* @return list<array{aspect: array{name: string, required: bool, usage: string, values: list<string>}, action: string, preMatchId: string|null, suggestedType: string}>
|
||||||
*/
|
*/
|
||||||
private function buildRows(array $aspects, array $allDefs, ArticleType $articleType): array
|
private function buildRows(array $aspects, array $allDefs, ArticleType $articleType): array
|
||||||
{
|
{
|
||||||
|
// Build name → def map for auto-matching
|
||||||
$defsByName = [];
|
$defsByName = [];
|
||||||
foreach ($allDefs as $def) {
|
foreach ($allDefs as $def) {
|
||||||
$defsByName[mb_strtolower(trim($def->getName()))] = $def;
|
$defsByName[mb_strtolower(trim($def->getName()))] = $def;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track which defs are already assigned to this article type
|
||||||
$assignedDefIds = [];
|
$assignedDefIds = [];
|
||||||
foreach ($articleType->getAttributeAssignments() as $assignment) {
|
foreach ($articleType->getAttributeAssignments() as $assignment) {
|
||||||
$assignedDefIds[$assignment->getAttributeDefinition()->getId()->toRfc4122()] = true;
|
$assignedDefIds[$assignment->getAttributeDefinition()->getId()->toRfc4122()] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Index existing mappings by eBay aspect name for O(1) lookup
|
|
||||||
$existingMappings = [];
|
|
||||||
foreach ($articleType->getEbayMappings() as $m) {
|
|
||||||
$existingMappings[$m->getEbayAspectName()] = $m;
|
|
||||||
}
|
|
||||||
|
|
||||||
$rows = [];
|
$rows = [];
|
||||||
foreach ($aspects as $aspect) {
|
foreach ($aspects as $aspect) {
|
||||||
$normalized = mb_strtolower(trim($aspect['name']));
|
$normalized = mb_strtolower(trim($aspect['name']));
|
||||||
|
|
||||||
// Highest priority: already stored mapping
|
|
||||||
if (isset($existingMappings[$aspect['name']])) {
|
|
||||||
$existing = $existingMappings[$aspect['name']];
|
|
||||||
$isFieldMapping = ArticleTypeEbayMapping::SOURCE_ARTICLE_FIELD === $existing->getSourceType();
|
|
||||||
$rows[] = [
|
|
||||||
'aspect' => $aspect,
|
|
||||||
'action' => $isFieldMapping ? 'article_field' : 'match',
|
|
||||||
'preMatchId' => $isFieldMapping ? null : $existing->getAttributeDefinition()?->getId()->toRfc4122(),
|
|
||||||
'preFieldKey' => $isFieldMapping ? $existing->getArticleFieldKey() : null,
|
|
||||||
'alreadyAssigned' => true,
|
|
||||||
'suggestedType' => 'string',
|
|
||||||
];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-detect article field by alias
|
|
||||||
$autoField = self::ARTICLE_FIELD_ALIASES[$normalized] ?? null;
|
|
||||||
if (null !== $autoField) {
|
|
||||||
$rows[] = [
|
|
||||||
'aspect' => $aspect,
|
|
||||||
'action' => 'article_field',
|
|
||||||
'preMatchId' => null,
|
|
||||||
'preFieldKey' => $autoField,
|
|
||||||
'alreadyAssigned' => false,
|
|
||||||
'suggestedType' => 'string',
|
|
||||||
];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$match = $defsByName[$normalized] ?? null;
|
$match = $defsByName[$normalized] ?? null;
|
||||||
$alreadyAssigned = $match && isset($assignedDefIds[$match->getId()->toRfc4122()]);
|
$alreadyAssigned = $match && isset($assignedDefIds[$match->getId()->toRfc4122()]);
|
||||||
|
|
||||||
if (null !== $match) {
|
if ($match !== null) {
|
||||||
$action = 'match';
|
$action = 'match';
|
||||||
$preMatchId = $match->getId()->toRfc4122();
|
$preMatchId = $match->getId()->toRfc4122();
|
||||||
} elseif ($aspect['required'] || 'RECOMMENDED' === $aspect['usage']) {
|
} elseif ($aspect['required'] || 'RECOMMENDED' === $aspect['usage']) {
|
||||||
|
|
@ -335,7 +250,6 @@ final class EbayAspectImportController extends AbstractController
|
||||||
'aspect' => $aspect,
|
'aspect' => $aspect,
|
||||||
'action' => $action,
|
'action' => $action,
|
||||||
'preMatchId' => $preMatchId,
|
'preMatchId' => $preMatchId,
|
||||||
'preFieldKey' => null,
|
|
||||||
'alreadyAssigned' => $alreadyAssigned,
|
'alreadyAssigned' => $alreadyAssigned,
|
||||||
'suggestedType' => $suggestedType,
|
'suggestedType' => $suggestedType,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Infrastructure\Persistence\Repository;
|
namespace App\Infrastructure\Persistence\Repository;
|
||||||
|
|
||||||
use App\Domain\Article\ArticleType;
|
|
||||||
use App\Domain\Channel\ArticleTypePlatformConfig;
|
use App\Domain\Channel\ArticleTypePlatformConfig;
|
||||||
use App\Domain\Channel\Repository\ArticleTypePlatformConfigRepositoryInterface;
|
use App\Domain\Channel\Repository\ArticleTypePlatformConfigRepositoryInterface;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
@ -47,21 +46,6 @@ final class DoctrineArticleTypePlatformConfigRepository implements ArticleTypePl
|
||||||
->getResult();
|
->getResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function findByArticleTypeAndPlatformType(ArticleType $articleType, string $platformType): ?ArticleTypePlatformConfig
|
|
||||||
{
|
|
||||||
/** @var ArticleTypePlatformConfig|null */
|
|
||||||
return $this->em->getRepository(ArticleTypePlatformConfig::class)
|
|
||||||
->createQueryBuilder('c')
|
|
||||||
->join('c.platform', 'p')
|
|
||||||
->where('c.articleType = :articleType')
|
|
||||||
->andWhere('p.type = :platformType')
|
|
||||||
->setParameter('articleType', $articleType)
|
|
||||||
->setParameter('platformType', $platformType)
|
|
||||||
->setMaxResults(1)
|
|
||||||
->getQuery()
|
|
||||||
->getOneOrNullResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function save(ArticleTypePlatformConfig $config): void
|
public function save(ArticleTypePlatformConfig $config): void
|
||||||
{
|
{
|
||||||
$this->em->persist($config);
|
$this->em->persist($config);
|
||||||
|
|
|
||||||
|
|
@ -43,41 +43,6 @@
|
||||||
|
|
||||||
{% if categoryId %}
|
{% if categoryId %}
|
||||||
|
|
||||||
{# ── Existing mappings table ──────────────────────────────────────── #}
|
|
||||||
{% if existingMappings|length > 0 %}
|
|
||||||
<div class="card mb-4">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="mb-0"><i class="fa fa-table me-2"></i>Aktive Mappings <span class="badge bg-secondary ms-1">{{ existingMappings|length }}</span></h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body p-0">
|
|
||||||
<table class="table table-sm mb-0">
|
|
||||||
<thead class="table-light">
|
|
||||||
<tr>
|
|
||||||
<th>eBay-Aspekt (Ziel)</th>
|
|
||||||
<th>Quelle</th>
|
|
||||||
<th class="text-center" style="width:6rem">Pflicht?</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for mapping in existingMappings %}
|
|
||||||
<tr>
|
|
||||||
<td class="fw-medium">{{ mapping.ebayAspectName }}</td>
|
|
||||||
<td class="text-muted">{{ mapping.sourceLabel }}</td>
|
|
||||||
<td class="text-center">
|
|
||||||
{% if mapping.required %}
|
|
||||||
<span class="badge bg-danger">Ja</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-muted">—</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# ── Summary bar ──────────────────────────────────────────────────── #}
|
{# ── Summary bar ──────────────────────────────────────────────────── #}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<div class="d-flex gap-2 align-items-center">
|
<div class="d-flex gap-2 align-items-center">
|
||||||
|
|
@ -155,9 +120,8 @@
|
||||||
class="form-select form-select-sm aspect-action"
|
class="form-select form-select-sm aspect-action"
|
||||||
data-index="{{ i }}">
|
data-index="{{ i }}">
|
||||||
<option value="skip" {% if row.action == 'skip' %}selected{% endif %}>— Überspringen</option>
|
<option value="skip" {% if row.action == 'skip' %}selected{% endif %}>— Überspringen</option>
|
||||||
<option value="article_field" {% if row.action == 'article_field' %}selected{% endif %}>→ Artikelfeld</option>
|
<option value="match" {% if row.action == 'match' %}selected{% endif %}>Vorhandenes verknüpfen</option>
|
||||||
<option value="match" {% if row.action == 'match' %}selected{% endif %}>Attribut verknüpfen</option>
|
<option value="create" {% if row.action == 'create' %}selected{% endif %}>Neu anlegen</option>
|
||||||
<option value="create" {% if row.action == 'create' %}selected{% endif %}>Attribut anlegen</option>
|
|
||||||
</select>
|
</select>
|
||||||
<input type="hidden" name="aspects[{{ i }}][ebayName]" value="{{ aspect.name }}">
|
<input type="hidden" name="aspects[{{ i }}][ebayName]" value="{{ aspect.name }}">
|
||||||
<input type="hidden" name="aspects[{{ i }}][ebayValues]" value="{{ aspect.values|join(',') }}">
|
<input type="hidden" name="aspects[{{ i }}][ebayValues]" value="{{ aspect.values|join(',') }}">
|
||||||
|
|
@ -165,19 +129,10 @@
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<div class="section-article_field-{{ i }}" style="display:{% if row.action == 'article_field' %}block{% else %}none{% endif %};">
|
|
||||||
<select name="aspects[{{ i }}][articleField]" class="form-select form-select-sm">
|
|
||||||
{% for fieldKey, fieldLabel in articleFields %}
|
|
||||||
<option value="{{ fieldKey }}" {% if row.preFieldKey == fieldKey %}selected{% endif %}>
|
|
||||||
{{ fieldLabel }}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="section-match-{{ i }}" style="display:{% if row.action == 'match' %}block{% else %}none{% endif %};">
|
<div class="section-match-{{ i }}" style="display:{% if row.action == 'match' %}block{% else %}none{% endif %};">
|
||||||
<select name="aspects[{{ i }}][definitionId]" class="form-select form-select-sm">
|
<select name="aspects[{{ i }}][definitionId]" class="form-select form-select-sm">
|
||||||
{% for def in allDefs %}
|
{% for def in allDefs %}
|
||||||
<option value="{{ def.id.toRfc4122() }}" {% if row.preMatchId == def.id.toRfc4122() %}selected{% endif %}>
|
<option value="{{ def.id }}" {% if row.preMatchId == def.id|toString %}selected{% endif %}>
|
||||||
{{ def.name }} ({{ def.type.value }})
|
{{ def.name }} ({{ def.type.value }})
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
@ -204,7 +159,7 @@
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<div class="section-req-{{ i }}" style="display:{% if row.action != 'skip' and row.action != 'article_field' %}block{% else %}none{% endif %};">
|
<div class="section-req-{{ i }}" style="display:{% if row.action != 'skip' %}block{% else %}none{% endif %};">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
name="aspects[{{ i }}][required]"
|
name="aspects[{{ i }}][required]"
|
||||||
value="1"
|
value="1"
|
||||||
|
|
@ -308,12 +263,12 @@
|
||||||
|
|
||||||
/* ── Aspect table JS ─────────────────────────────────────────── */
|
/* ── Aspect table JS ─────────────────────────────────────────── */
|
||||||
function syncRow(index, action) {
|
function syncRow(index, action) {
|
||||||
['article_field', 'match', 'create', 'skip'].forEach(s => {
|
['match', 'create', 'skip'].forEach(s => {
|
||||||
const el = document.querySelector(`.section-${s}-${index}`);
|
const el = document.querySelector(`.section-${s}-${index}`);
|
||||||
if (el) el.style.display = s === action ? 'block' : 'none';
|
if (el) el.style.display = s === action ? 'block' : 'none';
|
||||||
});
|
});
|
||||||
const req = document.querySelector(`.section-req-${index}`);
|
const req = document.querySelector(`.section-req-${index}`);
|
||||||
if (req) req.style.display = (action !== 'skip' && action !== 'article_field') ? 'block' : 'none';
|
if (req) req.style.display = action !== 'skip' ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelectorAll('.aspect-action').forEach(sel => {
|
document.querySelectorAll('.aspect-action').forEach(sel => {
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,6 @@ namespace App\Tests\Integration\Infrastructure\Channel\Ebay;
|
||||||
use App\Domain\Article\Article;
|
use App\Domain\Article\Article;
|
||||||
use App\Domain\Article\ArticleCondition;
|
use App\Domain\Article\ArticleCondition;
|
||||||
use App\Domain\Article\ArticleType;
|
use App\Domain\Article\ArticleType;
|
||||||
use App\Domain\Channel\ArticleTypePlatformConfig;
|
|
||||||
use App\Domain\Channel\Platform;
|
|
||||||
use App\Domain\Channel\Repository\ArticleTypePlatformConfigRepositoryInterface;
|
|
||||||
use App\Infrastructure\Channel\Ebay\EbayAdapter;
|
use App\Infrastructure\Channel\Ebay\EbayAdapter;
|
||||||
use App\Infrastructure\Channel\Ebay\EbayInventoryApiClient;
|
use App\Infrastructure\Channel\Ebay\EbayInventoryApiClient;
|
||||||
use App\Infrastructure\Channel\Ebay\EbayOAuthClient;
|
use App\Infrastructure\Channel\Ebay\EbayOAuthClient;
|
||||||
|
|
@ -38,47 +35,45 @@ final class EbayAdapterIntegrationTest extends TestCase
|
||||||
private string $createdListingId = '';
|
private string $createdListingId = '';
|
||||||
private bool $userTokenAvailable = false;
|
private bool $userTokenAvailable = false;
|
||||||
|
|
||||||
private static function env(string $key, string $default = ''): string
|
|
||||||
{
|
|
||||||
$val = $_SERVER[$key] ?? getenv($key);
|
|
||||||
|
|
||||||
return \is_string($val) ? $val : $default;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$clientId = self::env('EBAY_CLIENT_ID');
|
$clientId = $_SERVER['EBAY_CLIENT_ID'] ?? getenv('EBAY_CLIENT_ID');
|
||||||
$clientSecret = self::env('EBAY_CLIENT_SECRET');
|
$clientSecret = $_SERVER['EBAY_CLIENT_SECRET'] ?? getenv('EBAY_CLIENT_SECRET');
|
||||||
$apiBaseUrl = self::env('EBAY_API_BASE_URL');
|
$apiBaseUrl = $_SERVER['EBAY_API_BASE_URL'] ?? getenv('EBAY_API_BASE_URL');
|
||||||
$oauthBaseUrl = self::env('EBAY_OAUTH_BASE_URL');
|
$oauthBaseUrl = $_SERVER['EBAY_OAUTH_BASE_URL'] ?? getenv('EBAY_OAUTH_BASE_URL');
|
||||||
$marketplaceId = self::env('EBAY_MARKETPLACE_ID', 'EBAY_DE');
|
$marketplaceId = $_SERVER['EBAY_MARKETPLACE_ID'] ?? getenv('EBAY_MARKETPLACE_ID') ?: 'EBAY_DE';
|
||||||
|
|
||||||
if ('' === $clientId || '' === $clientSecret || '' === $apiBaseUrl || '' === $oauthBaseUrl) {
|
if (!$clientId || !$clientSecret || !$apiBaseUrl || !$oauthBaseUrl) {
|
||||||
$this->markTestSkipped('EBAY_* env vars not set');
|
$this->markTestSkipped('EBAY_* env vars not set');
|
||||||
}
|
}
|
||||||
|
|
||||||
$http = HttpClient::create();
|
$http = HttpClient::create();
|
||||||
$cache = new ArrayAdapter();
|
$cache = new ArrayAdapter();
|
||||||
|
|
||||||
$oauth = new EbayOAuthClient($http, $cache, $clientId, $clientSecret, $oauthBaseUrl);
|
$oauth = new EbayOAuthClient(
|
||||||
|
$http,
|
||||||
|
$cache,
|
||||||
|
(string) $clientId,
|
||||||
|
(string) $clientSecret,
|
||||||
|
(string) $oauthBaseUrl,
|
||||||
|
);
|
||||||
|
|
||||||
$this->apiClient = new EbayInventoryApiClient($http, $oauth, $apiBaseUrl, $marketplaceId);
|
$this->apiClient = new EbayInventoryApiClient(
|
||||||
|
$http,
|
||||||
|
$oauth,
|
||||||
|
(string) $apiBaseUrl,
|
||||||
|
(string) $marketplaceId,
|
||||||
|
);
|
||||||
|
|
||||||
$articleType = new ArticleType('Notebook');
|
$this->adapter = new EbayAdapter($this->apiClient);
|
||||||
$platform = new Platform('ebay', 'eBay');
|
|
||||||
// Sandbox category 177 = Notebooks; no policies needed for sandbox withdraw/stock tests
|
|
||||||
$platformConfig = new ArticleTypePlatformConfig($articleType, $platform, '177');
|
|
||||||
|
|
||||||
$configRepo = $this->createStub(ArticleTypePlatformConfigRepositoryInterface::class);
|
$userToken = $_SERVER['EBAY_USER_TOKEN'] ?? getenv('EBAY_USER_TOKEN');
|
||||||
$configRepo->method('findByArticleTypeAndPlatformType')->willReturn($platformConfig);
|
$this->userTokenAvailable = (bool) $userToken;
|
||||||
|
|
||||||
$this->adapter = new EbayAdapter($this->apiClient, $configRepo, $marketplaceId);
|
// Build a realistic sandbox article for listing tests
|
||||||
|
$type = new ArticleType('Notebook');
|
||||||
$this->userTokenAvailable = '' !== self::env('EBAY_USER_TOKEN');
|
|
||||||
|
|
||||||
// Build a realistic sandbox article for listing tests (reuse same $articleType so config matches)
|
|
||||||
$this->article = new Article(
|
$this->article = new Article(
|
||||||
$articleType,
|
$type,
|
||||||
'SS3K-TEST-'.time(),
|
'SS3K-TEST-'.time(),
|
||||||
'INV-TEST-001',
|
'INV-TEST-001',
|
||||||
1,
|
1,
|
||||||
|
|
@ -103,7 +98,7 @@ final class EbayAdapterIntegrationTest extends TestCase
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testPublishListingCreatesLiveSandboxListing(): void
|
public function test_publish_listing_creates_live_sandbox_listing(): void
|
||||||
{
|
{
|
||||||
if (!$this->userTokenAvailable) {
|
if (!$this->userTokenAvailable) {
|
||||||
$this->markTestSkipped(
|
$this->markTestSkipped(
|
||||||
|
|
@ -121,7 +116,7 @@ final class EbayAdapterIntegrationTest extends TestCase
|
||||||
$this->article->setEbayListingId($listingId);
|
$this->article->setEbayListingId($listingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testUpdateStockChangesQuantity(): void
|
public function test_update_stock_changes_quantity(): void
|
||||||
{
|
{
|
||||||
if (!$this->userTokenAvailable) {
|
if (!$this->userTokenAvailable) {
|
||||||
$this->markTestSkipped('EBAY_USER_TOKEN not set — see test_publish_listing_creates_live_sandbox_listing');
|
$this->markTestSkipped('EBAY_USER_TOKEN not set — see test_publish_listing_creates_live_sandbox_listing');
|
||||||
|
|
@ -136,7 +131,7 @@ final class EbayAdapterIntegrationTest extends TestCase
|
||||||
$this->addToAssertionCount(1);
|
$this->addToAssertionCount(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDeactivateListingWithdrawsOffer(): void
|
public function test_deactivate_listing_withdraws_offer(): void
|
||||||
{
|
{
|
||||||
if (!$this->userTokenAvailable) {
|
if (!$this->userTokenAvailable) {
|
||||||
$this->markTestSkipped('EBAY_USER_TOKEN not set — see test_publish_listing_creates_live_sandbox_listing');
|
$this->markTestSkipped('EBAY_USER_TOKEN not set — see test_publish_listing_creates_live_sandbox_listing');
|
||||||
|
|
@ -151,7 +146,7 @@ final class EbayAdapterIntegrationTest extends TestCase
|
||||||
$this->addToAssertionCount(1);
|
$this->addToAssertionCount(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDeactivateListingIsNoopWhenNoListingId(): void
|
public function test_deactivate_listing_is_noop_when_no_listing_id(): void
|
||||||
{
|
{
|
||||||
// No listing ID set — should silently return, no API call
|
// No listing ID set — should silently return, no API call
|
||||||
$this->adapter->deactivateListing($this->article);
|
$this->adapter->deactivateListing($this->article);
|
||||||
|
|
|
||||||
|
|
@ -134,20 +134,4 @@ final class EbayTaxonomyIntegrationTest extends TestCase
|
||||||
$names = array_map(static fn (array $a) => $a['name'], $aspects);
|
$names = array_map(static fn (array $a) => $a['name'], $aspects);
|
||||||
$this->assertNotEmpty($names);
|
$this->assertNotEmpty($names);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_category_suggestions_returns_results(): void
|
|
||||||
{
|
|
||||||
$results = $this->taxonomy->getCategorySuggestions('Notebook');
|
|
||||||
|
|
||||||
$this->assertNotEmpty($results, 'Sandbox must return at least one suggestion for "Notebook"');
|
|
||||||
|
|
||||||
foreach ($results as $result) {
|
|
||||||
$this->assertArrayHasKey('id', $result);
|
|
||||||
$this->assertArrayHasKey('name', $result);
|
|
||||||
$this->assertArrayHasKey('path', $result);
|
|
||||||
$this->assertIsString($result['id']);
|
|
||||||
$this->assertNotEmpty($result['id']);
|
|
||||||
$this->assertIsString($result['name']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,6 @@ namespace App\Tests\Unit\Infrastructure\Channel\Ebay;
|
||||||
use App\Domain\Article\Article;
|
use App\Domain\Article\Article;
|
||||||
use App\Domain\Article\ArticleCondition;
|
use App\Domain\Article\ArticleCondition;
|
||||||
use App\Domain\Article\ArticleType;
|
use App\Domain\Article\ArticleType;
|
||||||
use App\Domain\Channel\ArticleTypePlatformConfig;
|
|
||||||
use App\Domain\Channel\Platform;
|
|
||||||
use App\Domain\Channel\Repository\ArticleTypePlatformConfigRepositoryInterface;
|
|
||||||
use App\Infrastructure\Channel\Ebay\EbayAdapter;
|
use App\Infrastructure\Channel\Ebay\EbayAdapter;
|
||||||
use App\Infrastructure\Channel\Ebay\EbayInventoryApiClient;
|
use App\Infrastructure\Channel\Ebay\EbayInventoryApiClient;
|
||||||
use PHPUnit\Framework\MockObject\MockObject;
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
|
|
@ -18,35 +15,25 @@ use PHPUnit\Framework\TestCase;
|
||||||
final class EbayAdapterTest extends TestCase
|
final class EbayAdapterTest extends TestCase
|
||||||
{
|
{
|
||||||
private EbayInventoryApiClient&MockObject $apiClient;
|
private EbayInventoryApiClient&MockObject $apiClient;
|
||||||
private ArticleTypePlatformConfigRepositoryInterface&MockObject $configRepo;
|
|
||||||
private EbayAdapter $adapter;
|
private EbayAdapter $adapter;
|
||||||
private Article $article;
|
private Article $article;
|
||||||
private ArticleTypePlatformConfig $platformConfig;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$this->apiClient = $this->createMock(EbayInventoryApiClient::class);
|
$this->apiClient = $this->createMock(EbayInventoryApiClient::class);
|
||||||
$this->configRepo = $this->createMock(ArticleTypePlatformConfigRepositoryInterface::class);
|
$this->adapter = new EbayAdapter($this->apiClient);
|
||||||
$this->adapter = new EbayAdapter($this->apiClient, $this->configRepo, 'EBAY_DE');
|
$this->article = new Article(new ArticleType('Notebook'), 'NB-001', 'INV-001', 1, ArticleCondition::Good);
|
||||||
|
|
||||||
$articleType = new ArticleType('Notebook');
|
|
||||||
$platform = new Platform('ebay', 'eBay');
|
|
||||||
$this->platformConfig = new ArticleTypePlatformConfig($articleType, $platform, '177');
|
|
||||||
|
|
||||||
$this->article = new Article($articleType, 'NB-001', 'INV-001', 1, ArticleCondition::Good);
|
|
||||||
$this->article->setEbayTitle('Dell Latitude 5520');
|
$this->article->setEbayTitle('Dell Latitude 5520');
|
||||||
$this->article->setListingPrice('299.00');
|
$this->article->setListingPrice('299.00');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testGetTypeReturnsEbay(): void
|
public function test_get_type_returns_ebay(): void
|
||||||
{
|
{
|
||||||
$this->assertSame('ebay', $this->adapter->getType());
|
$this->assertSame('ebay', $this->adapter->getType());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testPublishListingCallsUpsertCreateAndPublish(): void
|
public function test_publish_listing_calls_upsert_create_and_publish(): void
|
||||||
{
|
{
|
||||||
$this->configRepo->method('findByArticleTypeAndPlatformType')->willReturn($this->platformConfig);
|
|
||||||
|
|
||||||
$this->apiClient->expects($this->once())->method('upsertInventoryItem');
|
$this->apiClient->expects($this->once())->method('upsertInventoryItem');
|
||||||
$this->apiClient->expects($this->once())->method('createOffer')->willReturn('offer-123');
|
$this->apiClient->expects($this->once())->method('createOffer')->willReturn('offer-123');
|
||||||
$this->apiClient->expects($this->once())->method('publishOffer')->with('offer-123')->willReturn('listing-456');
|
$this->apiClient->expects($this->once())->method('publishOffer')->with('offer-123')->willReturn('listing-456');
|
||||||
|
|
@ -56,57 +43,7 @@ final class EbayAdapterTest extends TestCase
|
||||||
$this->assertSame('listing-456', $listingId);
|
$this->assertSame('listing-456', $listingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testPublishListingThrowsWhenNoPlatformConfig(): void
|
public function test_deactivate_listing_withdraws_offer(): void
|
||||||
{
|
|
||||||
$this->configRepo->method('findByArticleTypeAndPlatformType')->willReturn(null);
|
|
||||||
|
|
||||||
$this->expectException(\RuntimeException::class);
|
|
||||||
$this->expectExceptionMessage('No eBay platform config found for ArticleType');
|
|
||||||
|
|
||||||
$this->adapter->publishListing($this->article);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testPublishListingIncludesPoliciesWhenSet(): void
|
|
||||||
{
|
|
||||||
$this->platformConfig->setFulfillmentPolicyId('FP-123');
|
|
||||||
$this->platformConfig->setPaymentPolicyId('PP-456');
|
|
||||||
$this->platformConfig->setReturnPolicyId('RP-789');
|
|
||||||
$this->platformConfig->setMerchantLocationKey('WAREHOUSE-1');
|
|
||||||
$this->configRepo->method('findByArticleTypeAndPlatformType')->willReturn($this->platformConfig);
|
|
||||||
|
|
||||||
$this->apiClient->expects($this->once())->method('upsertInventoryItem');
|
|
||||||
$this->apiClient->expects($this->once())
|
|
||||||
->method('createOffer')
|
|
||||||
->with($this->callback(static function (array $body): bool {
|
|
||||||
return isset($body['listingPolicies']['fulfillmentPolicyId'])
|
|
||||||
&& 'FP-123' === $body['listingPolicies']['fulfillmentPolicyId']
|
|
||||||
&& 'PP-456' === $body['listingPolicies']['paymentPolicyId']
|
|
||||||
&& 'RP-789' === $body['listingPolicies']['returnPolicyId']
|
|
||||||
&& 'WAREHOUSE-1' === $body['merchantLocationKey'];
|
|
||||||
}))
|
|
||||||
->willReturn('offer-123');
|
|
||||||
$this->apiClient->method('publishOffer')->willReturn('listing-456');
|
|
||||||
|
|
||||||
$this->adapter->publishListing($this->article);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testPublishListingOmitsPoliciesWhenNotSet(): void
|
|
||||||
{
|
|
||||||
$this->configRepo->method('findByArticleTypeAndPlatformType')->willReturn($this->platformConfig);
|
|
||||||
|
|
||||||
$this->apiClient->expects($this->once())->method('upsertInventoryItem');
|
|
||||||
$this->apiClient->expects($this->once())
|
|
||||||
->method('createOffer')
|
|
||||||
->with($this->callback(static function (array $body): bool {
|
|
||||||
return !isset($body['listingPolicies']) && !isset($body['merchantLocationKey']);
|
|
||||||
}))
|
|
||||||
->willReturn('offer-123');
|
|
||||||
$this->apiClient->method('publishOffer')->willReturn('listing-456');
|
|
||||||
|
|
||||||
$this->adapter->publishListing($this->article);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testDeactivateListingWithdrawsOffer(): void
|
|
||||||
{
|
{
|
||||||
$this->article->setEbayListingId('offer-123');
|
$this->article->setEbayListingId('offer-123');
|
||||||
|
|
||||||
|
|
@ -115,7 +52,7 @@ final class EbayAdapterTest extends TestCase
|
||||||
$this->adapter->deactivateListing($this->article);
|
$this->adapter->deactivateListing($this->article);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDeactivateListingIsNoopWhenNoListingId(): void
|
public function test_deactivate_listing_is_noop_when_no_listing_id(): void
|
||||||
{
|
{
|
||||||
$this->apiClient->expects($this->never())->method('withdrawOffer');
|
$this->apiClient->expects($this->never())->method('withdrawOffer');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,6 @@ menu.ai_prompts: 'KI-Prompts'
|
||||||
menu.translations: Übersetzungen
|
menu.translations: Übersetzungen
|
||||||
menu.users: Benutzer
|
menu.users: Benutzer
|
||||||
menu.logs: Logs
|
menu.logs: Logs
|
||||||
menu.section_ebay: eBay
|
|
||||||
menu.ebay_platform_configs: 'Kategorie-Konfigurationen'
|
|
||||||
menu.section_sales: Verkauf
|
menu.section_sales: Verkauf
|
||||||
menu.orders: Bestellungen
|
menu.orders: Bestellungen
|
||||||
menu.customers: Kunden
|
menu.customers: Kunden
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,6 @@ menu.ai_prompts: 'AI Prompts'
|
||||||
menu.translations: Translations
|
menu.translations: Translations
|
||||||
menu.users: Users
|
menu.users: Users
|
||||||
menu.logs: Logs
|
menu.logs: Logs
|
||||||
menu.section_ebay: eBay
|
|
||||||
menu.ebay_platform_configs: 'Category Configurations'
|
|
||||||
menu.section_sales: Sales
|
menu.section_sales: Sales
|
||||||
menu.orders: Orders
|
menu.orders: Orders
|
||||||
menu.customers: Customers
|
menu.customers: Customers
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue