diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6699076 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +# editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[{compose.yaml,compose.*.yaml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.env.dev b/.env.dev new file mode 100644 index 0000000..f2eafc4 --- /dev/null +++ b/.env.dev @@ -0,0 +1,4 @@ + +###> symfony/framework-bundle ### +APP_SECRET=7ac10a47af9d9582c01fe117c77e4c53 +###< symfony/framework-bundle ### diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..64bd111 --- /dev/null +++ b/.env.test @@ -0,0 +1,3 @@ +# define your env variables for the test env here +KERNEL_CLASS='App\Kernel' +APP_SECRET='$ecretf0rt3st' diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..1c883f0 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,17 @@ +in(__DIR__) + ->exclude('var') + ->notPath([ + 'config/bundles.php', + 'config/reference.php', + ]) +; + +return (new PhpCsFixer\Config()) + ->setRules([ + '@Symfony' => true, + ]) + ->setFinder($finder) +; diff --git a/bin/phpunit b/bin/phpunit new file mode 100755 index 0000000..ac5eef1 --- /dev/null +++ b/bin/phpunit @@ -0,0 +1,4 @@ +#!/usr/bin/env php + +# +# Prerequisites (local machine): +# apt/brew: openssh-client autossh +# Ollama running on localhost:11434 + +set -euo pipefail + +SERVER="${1:?Usage: $0 }" +TUNNEL_USER="ollama-tunnel" +REMOTE_PORT=11434 +LOCAL_PORT=11434 +KEY_FILE="${HOME}/.ssh/id_ed25519" + +# Generate key if it doesn't exist +if [[ ! -f "${KEY_FILE}" ]]; then + echo "[+] Generating SSH key ${KEY_FILE} ..." + ssh-keygen -t ed25519 -f "${KEY_FILE}" -N "" -C "ollama-tunnel@$(hostname)" +fi + +# Copy public key to server +echo "[+] Copying public key to ${TUNNEL_USER}@${SERVER} ..." +echo " You will be prompted for sudo on the server (or use the superseller account)." +PUBKEY=$(cat "${KEY_FILE}.pub") +ssh superseller@"${SERVER}" "sudo bash -c 'echo \"${PUBKEY}\" >> /home/${TUNNEL_USER}/.ssh/authorized_keys && sort -u /home/${TUNNEL_USER}/.ssh/authorized_keys -o /home/${TUNNEL_USER}/.ssh/authorized_keys'" + +echo "[+] Testing tunnel connection ..." +ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=5 \ + -N -i "${KEY_FILE}" \ + -R "172.18.0.1:${REMOTE_PORT}:localhost:${LOCAL_PORT}" \ + "${TUNNEL_USER}@${SERVER}" & +SSH_PID=$! +sleep 2 +if kill -0 "${SSH_PID}" 2>/dev/null; then + echo "[+] Tunnel works! Stopping test connection." + kill "${SSH_PID}" +else + echo "[!] Tunnel test failed. Check sshd config and firewall on the server." + exit 1 +fi + +# Install systemd service +install_systemd_service() { + local service_file="${HOME}/.config/systemd/user/ollama-tunnel.service" + mkdir -p "$(dirname "${service_file}")" + cat > "${service_file}" << EOF +[Unit] +Description=Ollama SSH reverse tunnel to SuperSeller3000 server +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/bin/autossh -M 0 \\ + -o "ServerAliveInterval=30" \\ + -o "ServerAliveCountMax=3" \\ + -o "ExitOnForwardFailure=yes" \\ + -o "StrictHostKeyChecking=accept-new" \\ + -N -i ${KEY_FILE} \\ + -R 172.18.0.1:${REMOTE_PORT}:localhost:${LOCAL_PORT} \\ + ${TUNNEL_USER}@${SERVER} +Restart=always +RestartSec=10 + +[Install] +WantedBy=default.target +EOF + + systemctl --user daemon-reload + systemctl --user enable ollama-tunnel.service + systemctl --user start ollama-tunnel.service + echo "[+] systemd service installed and started." + echo " Status: systemctl --user status ollama-tunnel" +} + +if command -v autossh &>/dev/null && command -v systemctl &>/dev/null; then + echo "[+] Installing autossh systemd user service ..." + install_systemd_service +else + echo "[!] autossh or systemd not found. Manual tunnel command:" + echo "" + echo " autossh -M 0 -o ServerAliveInterval=30 -N \\" + echo " -i ${KEY_FILE} \\" + echo " -R 172.18.0.1:${REMOTE_PORT}:localhost:${LOCAL_PORT} \\" + echo " ${TUNNEL_USER}@${SERVER}" +fi + +echo "" +echo "Done. The server will see Ollama at http://172.18.0.1:${REMOTE_PORT}" diff --git a/docs/superpowers/plans/2026-05-13-01-foundation.md b/docs/superpowers/plans/2026-05-13-01-foundation.md new file mode 100644 index 0000000..6db60d2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-01-foundation.md @@ -0,0 +1,3094 @@ +# SuperSeller3000 — Plan 1: Projektfundament + +> **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:** Lauffähige Docker-Umgebung mit Symfony 7, hexagonaler Verzeichnisstruktur, allen Core-Domain-Entities, Doctrine-Migrationen, Messenger-Konfiguration und funktionierender CI-Pipeline in Gitea. + +**Architecture:** Hexagonale Architektur (Domain / Application / Infrastructure). Domain-Entities sind reines PHP. Doctrine-Mapping via PHP-Attribute direkt auf den Entities (pragmatischer Kompromiss, kein Framework-Verhalten im Konstruktor). Repository-Interfaces im Domain-Layer, Implementierungen in Infrastructure/Persistence. Symfony UID (Uuid::v7()) für alle IDs — time-sortable, kein Extra-Dependency. + +**Tech Stack:** PHP 8.4, Symfony 7, Doctrine ORM, PostgreSQL 17 (Schemas: app/logs/logs_archive), Redis, Docker Compose, Caddy, PHP-FPM, PHPUnit 11, Pest 3, PHPStan Level 9, PHP CS Fixer 3, Gitea Actions + +--- + +## Dateistruktur (gesamter Plan) + +``` +/ +├── docker-compose.yml +├── docker-compose.override.yml # Dev-Overrides (Port-Bindings, Xdebug) +├── .env # Defaults ohne Credentials +├── .env.test +├── .gitignore +├── .gitea/ +│ └── workflows/ +│ └── ci.yml +├── docker/ +│ ├── app/ +│ │ ├── Dockerfile +│ │ └── php.ini +│ └── caddy/ +│ └── Caddyfile +├── src/ +│ ├── Domain/ +│ │ ├── Article/ +│ │ │ ├── Article.php +│ │ │ ├── ArticleCondition.php # enum +│ │ │ ├── ArticlePhoto.php +│ │ │ ├── ArticleStatus.php # enum mit Transition-Logik +│ │ │ ├── ArticleType.php +│ │ │ ├── AttributeDefinition.php +│ │ │ ├── AttributeType.php # enum +│ │ │ ├── AttributeValue.php +│ │ │ └── Repository/ +│ │ │ ├── ArticleRepositoryInterface.php +│ │ │ └── ArticleTypeRepositoryInterface.php +│ │ ├── Channel/ +│ │ │ ├── ArticleTypePlatformConfig.php +│ │ │ ├── AttributeMapping.php +│ │ │ ├── ChannelField.php +│ │ │ ├── Platform.php +│ │ │ └── Repository/ +│ │ │ └── PlatformRepositoryInterface.php +│ │ ├── Order/ +│ │ │ ├── Customer.php +│ │ │ ├── Invoice.php +│ │ │ ├── Order.php +│ │ │ ├── OrderStatus.php # enum +│ │ │ └── Repository/ +│ │ │ ├── CustomerRepositoryInterface.php +│ │ │ └── OrderRepositoryInterface.php +│ │ ├── Pipeline/ +│ │ │ ├── AIPipelineJob.php +│ │ │ ├── AIPipelineJobStatus.php # enum +│ │ │ └── AIPipelineJobType.php # enum +│ │ ├── Storage/ +│ │ │ └── StoragePath.php +│ │ └── Auth/ +│ │ ├── ApiKey.php +│ │ └── User.php +│ ├── Application/ # leer in Plan 1, UseCases folgen in Plan 2+ +│ └── Infrastructure/ +│ └── Persistence/ +│ └── Repository/ +│ ├── DoctrineArticleRepository.php +│ ├── DoctrineArticleTypeRepository.php +│ ├── DoctrineCustomerRepository.php +│ ├── DoctrineOrderRepository.php +│ └── DoctrinePlatformRepository.php +├── config/ +│ ├── packages/ +│ │ ├── doctrine.yaml +│ │ └── messenger.yaml +│ └── services.yaml +├── migrations/ +│ └── Version20260513000001.php # Schemas + alle Tabellen +├── tests/ +│ ├── Unit/ +│ │ └── Domain/ +│ │ ├── Article/ +│ │ │ ├── ArticleStatusTest.php +│ │ │ └── ArticleTest.php +│ │ └── Order/ +│ │ └── CustomerTest.php +│ └── Integration/ # leer in Plan 1 +├── phpunit.xml.dist +├── phpstan.neon +└── .php-cs-fixer.php +``` + +--- + +## Task 1: Docker-Umgebung + +**Files:** +- Create: `docker/app/Dockerfile` +- Create: `docker/app/php.ini` +- Create: `docker/caddy/Caddyfile` +- Create: `docker-compose.yml` +- Create: `docker-compose.override.yml` +- Create: `.env` + +- [ ] **Step 1: Dockerfile schreiben** + +```dockerfile +# docker/app/Dockerfile +FROM php:8.4-fpm-alpine + +RUN apk add --no-cache \ + postgresql-dev \ + icu-dev \ + libzip-dev \ + unzip \ + git \ + && docker-php-ext-install \ + pdo_pgsql \ + intl \ + zip \ + opcache + +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 +``` + +- [ ] **Step 2: php.ini schreiben** + +```ini +; docker/app/php.ini +opcache.enable=1 +opcache.memory_consumption=256 +opcache.max_accelerated_files=20000 +opcache.validate_timestamps=0 +memory_limit=256M +upload_max_filesize=20M +post_max_size=20M +``` + +- [ ] **Step 3: Caddyfile schreiben** + +``` +# docker/caddy/Caddyfile +{ + admin off +} + +:80 { + 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" + } +} +``` + +- [ ] **Step 4: docker-compose.yml schreiben** + +```yaml +# docker-compose.yml +services: + app: + build: + context: . + dockerfile: docker/app/Dockerfile + volumes: + - .:/var/www + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + env_file: .env + + caddy: + image: caddy:2-alpine + volumes: + - ./docker/caddy/Caddyfile:/etc/caddy/Caddyfile + - .:/var/www + - 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 + command: php bin/console messenger:consume ai_pipeline --time-limit=3600 --memory-limit=256M + volumes: + - .:/var/www + depends_on: + - postgres + - redis + env_file: .env + restart: unless-stopped + + worker-orders: + build: + context: . + dockerfile: docker/app/Dockerfile + command: php bin/console messenger:consume orders --time-limit=3600 --memory-limit=256M + volumes: + - .:/var/www + depends_on: + - postgres + - redis + env_file: .env + restart: unless-stopped + + worker-channel: + build: + context: . + dockerfile: docker/app/Dockerfile + command: php bin/console messenger:consume channel_sync --time-limit=3600 --memory-limit=256M + volumes: + - .:/var/www + depends_on: + - postgres + - redis + env_file: .env + restart: unless-stopped + + cron: + build: + context: . + dockerfile: docker/app/Dockerfile + command: > + sh -c "while true; do + php bin/console app:logs:rotate; + sleep 86400; + done" + volumes: + - .:/var/www + depends_on: + - postgres + env_file: .env + restart: unless-stopped + +volumes: + postgres_data: + redis_data: + caddy_data: +``` + +- [ ] **Step 5: docker-compose.override.yml schreiben (Dev-Ports)** + +```yaml +# docker-compose.override.yml +services: + caddy: + ports: + - "80:80" + - "443:443" + + postgres: + ports: + - "5432:5432" + + redis: + ports: + - "6379:6379" +``` + +- [ ] **Step 6: .env schreiben (keine Credentials)** + +```dotenv +# .env +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 +OLLAMA_URL=http://localhost:11434 +``` + +- [ ] **Step 7: .gitignore schreiben** + +```gitignore +/.env.local +/.env.*.local +/vendor/ +/var/ +/public/bundles/ +/.php-cs-fixer.cache +/.phpunit.result.cache +``` + +- [ ] **Step 8: Commit** + +```bash +git init +git add docker/ docker-compose.yml docker-compose.override.yml .env .gitignore +git commit -m "feat: add Docker Compose environment with Caddy, PostgreSQL 17, Redis, PHP-FPM workers" +``` + +--- + +## Task 2: Symfony 7 Skeleton + +**Files:** +- Create: `composer.json` (via Composer) +- Create: `config/packages/doctrine.yaml` +- Create: `config/packages/messenger.yaml` + +- [ ] **Step 1: Symfony installieren** + +```bash +docker compose run --rm app composer create-project symfony/skeleton . --no-interaction +docker compose run --rm app composer require \ + doctrine/orm \ + doctrine/doctrine-bundle \ + doctrine/doctrine-migrations-bundle \ + symfony/messenger \ + symfony/uid \ + symfony/mailer \ + symfony/security-bundle \ + symfony/validator \ + symfony/serializer \ + symfony/http-client +``` + +- [ ] **Step 2: Doctrine konfigurieren** + +```yaml +# config/packages/doctrine.yaml +doctrine: + dbal: + url: '%env(resolve:DATABASE_URL)%' + schema_filter: ~^(?!logs\.|logs_archive\.)~ # Standard-Migrations nur auf app-Schema + orm: + auto_generate_proxy_classes: true + enable_lazy_ghost_objects: true + naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware + auto_mapping: true + mappings: + App: + is_bundle: false + dir: '%kernel.project_dir%/src' + prefix: 'App' + alias: App + dql: + string_functions: + JSONB_AGG: App\Infrastructure\Persistence\Doctrine\Function\JsonbAgg +``` + +- [ ] **Step 3: Messenger konfigurieren** + +```yaml +# config/packages/messenger.yaml +framework: + messenger: + failure_transport: failed + + transports: + ai_pipeline: + dsn: '%env(MESSENGER_TRANSPORT_DSN)%' + options: + stream: ai_pipeline + retry_strategy: + max_retries: 3 + delay: 2000 + multiplier: 2 + + orders: + dsn: '%env(MESSENGER_TRANSPORT_DSN)%' + options: + stream: orders + retry_strategy: + max_retries: 5 + delay: 1000 + multiplier: 2 + + channel_sync: + dsn: '%env(MESSENGER_TRANSPORT_DSN)%' + options: + stream: channel_sync + retry_strategy: + max_retries: 5 + delay: 2000 + multiplier: 2 + max_delay: 60000 + + failed: + dsn: '%env(MESSENGER_TRANSPORT_DSN)%' + options: + stream: failed + + routing: + # Routing wird in späteren Plänen pro Message-Klasse gesetzt +``` + +- [ ] **Step 4: Commit** + +```bash +git add composer.json composer.lock config/ symfony.lock +git commit -m "feat: install Symfony 7 skeleton with Doctrine, Messenger, UID, Security" +``` + +--- + +## Task 3: Tooling (PHPStan, PHP CS Fixer, Pest) + +**Files:** +- Create: `phpstan.neon` +- Create: `.php-cs-fixer.php` +- Create: `phpunit.xml.dist` + +- [ ] **Step 1: Dev-Dependencies installieren** + +```bash +docker compose run --rm app composer require --dev \ + phpunit/phpunit \ + pestphp/pest \ + pestphp/pest-plugin-symfony \ + phpstan/phpstan \ + phpstan/extension-installer \ + phpstan/phpstan-symfony \ + phpstan/phpstan-doctrine \ + friendsofphp/php-cs-fixer +``` + +- [ ] **Step 2: PHPStan konfigurieren** + +```neon +# phpstan.neon +includes: + - vendor/phpstan/phpstan-symfony/extension.neon + - vendor/phpstan/phpstan-doctrine/extension.neon + +parameters: + level: 9 + paths: + - src + - tests + symfony: + containerXmlPath: var/cache/dev/App_KernelDevDebugContainer.xml + doctrine: + objectManagerLoader: tests/bootstrap.php +``` + +- [ ] **Step 3: PHP CS Fixer konfigurieren** + +```php +in(__DIR__.'/src') + ->in(__DIR__.'/tests'); + +return (new PhpCsFixer\Config()) + ->setRules([ + '@Symfony' => true, + '@Symfony:risky' => true, + 'declare_strict_types' => true, + 'native_function_invocation' => ['include' => ['@compiler_optimized'], 'scope' => 'namespaced'], + 'ordered_imports' => true, + 'no_unused_imports' => true, + 'array_syntax' => ['syntax' => 'short'], + 'phpdoc_align' => false, + ]) + ->setRiskyAllowed(true) + ->setFinder($finder); +``` + +- [ ] **Step 4: PHPUnit konfigurieren** + +```xml + + + + + + tests/Unit + + + tests/Integration + + + + + + src + + + + + + + + + +``` + +- [ ] **Step 5: Pest initialisieren** + +```bash +docker compose run --rm app ./vendor/bin/pest --init +``` + +- [ ] **Step 6: Verify tools laufen** + +```bash +docker compose run --rm app ./vendor/bin/phpstan analyse --no-progress +# Expected: "No errors" + +docker compose run --rm app ./vendor/bin/php-cs-fixer fix --dry-run --diff +# Expected: keine Änderungen + +docker compose run --rm app ./vendor/bin/pest +# Expected: "No tests found" +``` + +- [ ] **Step 7: Commit** + +```bash +git add phpstan.neon .php-cs-fixer.php phpunit.xml.dist composer.json composer.lock +git commit -m "feat: add PHPStan level 9, PHP CS Fixer, Pest" +``` + +--- + +## Task 4: Gitea Actions CI + +**Files:** +- Create: `.gitea/workflows/ci.yml` + +- [ ] **Step 1: CI-Workflow schreiben** + +```yaml +# .gitea/workflows/ci.yml +name: CI + +on: + push: + branches: ['*'] + pull_request: + branches: ['main'] + +jobs: + test: + runs-on: ubuntu-latest + container: + image: php:8.4-cli-alpine + + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_DB: superseller_test + POSTGRES_USER: superseller + POSTGRES_PASSWORD: test + options: >- + --health-cmd pg_isready + --health-interval 5s + --health-timeout 5s + --health-retries 5 + + redis: + image: redis:7-alpine + + steps: + - uses: actions/checkout@v4 + + - name: Install system deps + run: | + apk add --no-cache postgresql-dev icu-dev libzip-dev unzip git + docker-php-ext-install pdo_pgsql intl zip + + - name: Install Composer + run: | + curl -sS https://getcomposer.org/installer | php + mv composer.phar /usr/local/bin/composer + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + + - name: PHP CS Fixer + run: ./vendor/bin/php-cs-fixer fix --dry-run --diff + + - name: PHPStan + run: ./vendor/bin/phpstan analyse --no-progress + + - name: Run migrations + env: + DATABASE_URL: postgresql://superseller:test@postgres:5432/superseller_test?serverVersion=17 + APP_ENV: test + run: php bin/console doctrine:migrations:migrate --no-interaction + + - name: Pest + env: + DATABASE_URL: postgresql://superseller:test@postgres:5432/superseller_test?serverVersion=17 + APP_ENV: test + REDIS_URL: redis://redis:6379 + run: ./vendor/bin/pest +``` + +- [ ] **Step 2: Commit** + +```bash +git add .gitea/ +git commit -m "feat: add Gitea Actions CI (CS Fixer, PHPStan, Pest + migrations)" +``` + +--- + +## Task 5: Domain-Enums + +**Files:** +- Create: `src/Domain/Article/ArticleStatus.php` +- Create: `src/Domain/Article/ArticleCondition.php` +- Create: `src/Domain/Article/AttributeType.php` +- Create: `src/Domain/Order/OrderStatus.php` +- Create: `src/Domain/Pipeline/AIPipelineJobType.php` +- Create: `src/Domain/Pipeline/AIPipelineJobStatus.php` +- Test: `tests/Unit/Domain/Article/ArticleStatusTest.php` + +- [ ] **Step 1: Failing test für ArticleStatus schreiben** + +```php +assertTrue(ArticleStatus::Ingesting->canTransitionTo(ArticleStatus::Draft)); + $this->assertTrue(ArticleStatus::Draft->canTransitionTo(ArticleStatus::Active)); + $this->assertTrue(ArticleStatus::Draft->canTransitionTo(ArticleStatus::NeedsReview)); + $this->assertTrue(ArticleStatus::NeedsReview->canTransitionTo(ArticleStatus::Draft)); + $this->assertTrue(ArticleStatus::Active->canTransitionTo(ArticleStatus::Listed)); + $this->assertTrue(ArticleStatus::Listed->canTransitionTo(ArticleStatus::Sold)); + } + + public function test_invalid_transitions(): void + { + $this->assertFalse(ArticleStatus::Sold->canTransitionTo(ArticleStatus::Draft)); + $this->assertFalse(ArticleStatus::Ingesting->canTransitionTo(ArticleStatus::Sold)); + $this->assertFalse(ArticleStatus::Listed->canTransitionTo(ArticleStatus::Ingesting)); + } +} +``` + +- [ ] **Step 2: Test ausführen — muss fehlschlagen** + +```bash +docker compose run --rm app ./vendor/bin/pest tests/Unit/Domain/Article/ArticleStatusTest.php +# Expected: FAIL — class ArticleStatus not found +``` + +- [ ] **Step 3: ArticleStatus implementieren** + +```php + */ + public function allowedTransitions(): array + { + return match ($this) { + self::Ingesting => [self::Draft, self::NeedsReview], + self::Draft => [self::Active, self::NeedsReview], + self::NeedsReview => [self::Draft], + self::Active => [self::Listed, self::Draft], + self::Listed => [self::Sold, self::Active], + self::Sold => [], + }; + } + + public function canTransitionTo(self $next): bool + { + return \in_array($next, $this->allowedTransitions(), strict: true); + } +} +``` + +- [ ] **Step 4: Restliche Enums schreiben** + +```php +type = new ArticleType('Notebook'); + } + + public function test_new_article_has_ingesting_status(): void + { + $article = new Article($this->type, 'NB-001', 'INV-001', 1, ArticleCondition::Good); + + $this->assertSame(ArticleStatus::Ingesting, $article->getStatus()); + $this->assertSame(1, $article->getStock()); + } + + public function test_valid_status_transition(): void + { + $article = new Article($this->type, 'NB-001', 'INV-001', 1, ArticleCondition::Good); + $article->transitionTo(ArticleStatus::Draft); + + $this->assertSame(ArticleStatus::Draft, $article->getStatus()); + } + + public function test_invalid_status_transition_throws(): void + { + $article = new Article($this->type, 'NB-001', 'INV-001', 1, ArticleCondition::Good); + + $this->expectException(\DomainException::class); + $article->transitionTo(ArticleStatus::Sold); + } + + public function test_decrement_stock(): void + { + $article = new Article($this->type, 'NB-001', 'INV-001', 3, ArticleCondition::Good); + $article->decrementStock(); + + $this->assertSame(2, $article->getStock()); + $this->assertFalse($article->isOutOfStock()); + } + + public function test_decrement_to_zero_marks_out_of_stock(): void + { + $article = new Article($this->type, 'NB-001', 'INV-001', 1, ArticleCondition::Good); + $article->decrementStock(); + + $this->assertTrue($article->isOutOfStock()); + } + + public function test_decrement_below_zero_throws(): void + { + $article = new Article($this->type, 'NB-001', 'INV-001', 0, ArticleCondition::Good); + + $this->expectException(\DomainException::class); + $article->decrementStock(); + } +} +``` + +- [ ] **Step 2: Test ausführen — muss fehlschlagen** + +```bash +docker compose run --rm app ./vendor/bin/pest tests/Unit/Domain/Article/ArticleTest.php +# Expected: FAIL — classes not found +``` + +- [ ] **Step 3: ArticleType implementieren** + +```php + */ + #[ORM\ManyToMany(targetEntity: AttributeDefinition::class)] + #[ORM\JoinTable(name: 'article_type_attributes', schema: 'app')] + private Collection $attributeDefinitions; + + public function __construct(string $name) + { + $this->id = Uuid::v7(); + $this->name = $name; + $this->attributeDefinitions = new ArrayCollection(); + } + + public function getId(): Uuid { return $this->id; } + public function getName(): string { return $this->name; } + public function setName(string $name): void { $this->name = $name; } + + /** @return Collection */ + public function getAttributeDefinitions(): Collection { return $this->attributeDefinitions; } + + public function addAttributeDefinition(AttributeDefinition $def): void + { + if (!$this->attributeDefinitions->contains($def)) { + $this->attributeDefinitions->add($def); + } + } + + public function removeAttributeDefinition(AttributeDefinition $def): void + { + $this->attributeDefinitions->removeElement($def); + } +} +``` + +- [ ] **Step 4: AttributeDefinition implementieren** + +```php +|null */ + #[ORM\Column(type: 'json', nullable: true)] + private ?array $options = null; + + public function __construct(string $name, AttributeType $type) + { + $this->id = Uuid::v7(); + $this->name = $name; + $this->type = $type; + } + + public function getId(): Uuid { return $this->id; } + public function getName(): string { return $this->name; } + public function setName(string $name): void { $this->name = $name; } + public function getType(): AttributeType { return $this->type; } + public function getUnit(): ?string { return $this->unit; } + public function setUnit(?string $unit): void { $this->unit = $unit; } + + /** @return list|null */ + public function getOptions(): ?array { return $this->options; } + + /** @param list|null $options */ + public function setOptions(?array $options): void { $this->options = $options; } +} +``` + +- [ ] **Step 5: StoragePath implementieren** + +```php +id = Uuid::v7(); + $this->label = $label; + $this->basePath = $basePath; + $this->quotaBytes = $quotaBytes; + $this->priority = $priority; + $this->isActive = $isActive; + } + + public function getId(): Uuid { return $this->id; } + public function getLabel(): string { return $this->label; } + public function getBasePath(): string { return $this->basePath; } + public function setBasePath(string $basePath): void { $this->basePath = $basePath; } + public function getQuotaBytes(): int { return $this->quotaBytes; } + public function getPriority(): int { return $this->priority; } + public function isActive(): bool { return $this->isActive; } + public function setIsActive(bool $isActive): void { $this->isActive = $isActive; } + + public function resolveFilePath(string $filename): string + { + return \rtrim($this->basePath, '/').'/'.$filename; + } +} +``` + +- [ ] **Step 6: ArticlePhoto implementieren** + +```php +id = Uuid::v7(); + $this->article = $article; + $this->storagePath = $storagePath; + $this->filename = $filename; + $this->isMain = $isMain; + $this->sortOrder = $sortOrder; + } + + public function getId(): Uuid { return $this->id; } + public function getStoragePath(): StoragePath { return $this->storagePath; } + public function getFilename(): string { return $this->filename; } + public function isMain(): bool { return $this->isMain; } + public function getSortOrder(): int { return $this->sortOrder; } + public function setSortOrder(int $sortOrder): void { $this->sortOrder = $sortOrder; } + + public function getFullPath(): string + { + return $this->storagePath->resolveFilePath($this->filename); + } +} +``` + +- [ ] **Step 7: AttributeValue implementieren** + +```php +id = Uuid::v7(); + $this->article = $article; + $this->attributeDefinition = $attributeDefinition; + $this->value = $value; + } + + public function getId(): Uuid { return $this->id; } + public function getAttributeDefinition(): AttributeDefinition { return $this->attributeDefinition; } + public function getValue(): string { return $this->value; } + public function setValue(string $value): void { $this->value = $value; } + + public function getCastValue(): mixed + { + return match ($this->attributeDefinition->getType()) { + AttributeType::Int => (int) $this->value, + AttributeType::Float => (float) $this->value, + AttributeType::Bool => filter_var($this->value, \FILTER_VALIDATE_BOOLEAN), + AttributeType::MultiSelect => \json_decode($this->value, true), + default => $this->value, + }; + } +} +``` + +- [ ] **Step 8: Article implementieren** + +```php + */ + #[ORM\OneToMany(mappedBy: 'article', targetEntity: AttributeValue::class, cascade: ['persist', 'remove'])] + private Collection $attributeValues; + + /** @var Collection */ + #[ORM\OneToMany(mappedBy: 'article', targetEntity: ArticlePhoto::class, cascade: ['persist', 'remove'], orderBy: ['sortOrder' => 'ASC'])] + private Collection $photos; + + public function __construct( + ArticleType $articleType, + string $sku, + string $inventoryNumber, + int $stock, + ArticleCondition $condition, + ) { + $this->id = Uuid::v7(); + $this->articleType = $articleType; + $this->sku = $sku; + $this->inventoryNumber = $inventoryNumber; + $this->status = ArticleStatus::Ingesting; + $this->stock = $stock; + $this->condition = $condition; + $this->attributeValues = new ArrayCollection(); + $this->photos = new ArrayCollection(); + } + + public function transitionTo(ArticleStatus $newStatus): void + { + if (!$this->status->canTransitionTo($newStatus)) { + throw new \DomainException(\sprintf( + 'Cannot transition from %s to %s', + $this->status->value, + $newStatus->value, + )); + } + $this->status = $newStatus; + } + + public function decrementStock(): void + { + if ($this->stock <= 0) { + throw new \DomainException('Stock cannot go below zero'); + } + --$this->stock; + } + + public function isOutOfStock(): bool { return 0 === $this->stock; } + + public function getMainPhoto(): ?ArticlePhoto + { + foreach ($this->photos as $photo) { + if ($photo->isMain()) { + return $photo; + } + } + + return null; + } + + public function getId(): Uuid { return $this->id; } + public function getArticleType(): ArticleType { return $this->articleType; } + public function getSku(): string { return $this->sku; } + public function getInventoryNumber(): string { return $this->inventoryNumber; } + public function getStatus(): ArticleStatus { return $this->status; } + public function getStock(): int { return $this->stock; } + public function getCondition(): ArticleCondition { return $this->condition; } + public function getConditionNotes(): ?string { return $this->conditionNotes; } + public function getListingPrice(): ?string { return $this->listingPrice; } + public function getSerialNumber(): ?string { return $this->serialNumber; } + public function getEbayListingId(): ?string { return $this->ebayListingId; } + public function getEbayTitle(): ?string { return $this->ebayTitle; } + public function getEbayDescription(): ?string { return $this->ebayDescription; } + + /** @return Collection */ + public function getAttributeValues(): Collection { return $this->attributeValues; } + + /** @return Collection */ + public function getPhotos(): Collection { return $this->photos; } + + public function setConditionNotes(?string $notes): void { $this->conditionNotes = $notes; } + public function setListingPrice(?string $price): void { $this->listingPrice = $price; } + public function setSerialNumber(?string $sn): void { $this->serialNumber = $sn; } + public function setEbayListingId(?string $id): void { $this->ebayListingId = $id; } + public function setEbayTitle(?string $title): void { $this->ebayTitle = $title; } + public function setEbayDescription(?string $desc): void { $this->ebayDescription = $desc; } +} +``` + +- [ ] **Step 9: Tests ausführen — müssen bestehen** + +```bash +docker compose run --rm app ./vendor/bin/pest tests/Unit/Domain/Article/ArticleTest.php +# Expected: PASS (6 tests) +``` + +- [ ] **Step 10: PHPStan prüfen** + +```bash +docker compose run --rm app ./vendor/bin/phpstan analyse src/Domain/Article src/Domain/Storage --no-progress +# Expected: No errors +``` + +- [ ] **Step 11: Commit** + +```bash +git add src/Domain/Article/ src/Domain/Storage/ tests/Unit/Domain/Article/ArticleTest.php +git commit -m "feat: add Article domain cluster (ArticleType, AttributeDefinition, Article, AttributeValue, ArticlePhoto, StoragePath)" +``` + +--- + +## Task 7: Domain-Entities — Channel-Cluster + +**Files:** +- Create: `src/Domain/Channel/Platform.php` +- Create: `src/Domain/Channel/ChannelField.php` +- Create: `src/Domain/Channel/ArticleTypePlatformConfig.php` +- Create: `src/Domain/Channel/AttributeMapping.php` + +- [ ] **Step 1: Platform implementieren** + +```php + */ + #[ORM\Column(type: 'json')] + private array $config = []; + + public function __construct(string $type, string $label) + { + $this->id = Uuid::v7(); + $this->type = $type; + $this->label = $label; + } + + public function getId(): Uuid { return $this->id; } + public function getType(): string { return $this->type; } + public function getLabel(): string { return $this->label; } + public function setLabel(string $label): void { $this->label = $label; } + + /** @return array */ + public function getConfig(): array { return $this->config; } + + /** @param array $config */ + public function setConfig(array $config): void { $this->config = $config; } +} +``` + +- [ ] **Step 2: ChannelField implementieren** + +```php +id = Uuid::v7(); + $this->platform = $platform; + $this->label = $label; + $this->path = $path; + } + + public function getId(): Uuid { return $this->id; } + public function getPlatform(): Platform { return $this->platform; } + public function getLabel(): string { return $this->label; } + public function setLabel(string $label): void { $this->label = $label; } + public function getPath(): string { return $this->path; } + public function setPath(string $path): void { $this->path = $path; } +} +``` + +- [ ] **Step 3: AttributeMapping implementieren** + +```php +id = Uuid::v7(); + $this->platformConfig = $platformConfig; + $this->attributeDefinition = $attributeDefinition; + $this->channelField = $channelField; + } + + public function getId(): Uuid { return $this->id; } + public function getPlatformConfig(): ArticleTypePlatformConfig { return $this->platformConfig; } + public function getAttributeDefinition(): AttributeDefinition { return $this->attributeDefinition; } + public function getChannelField(): ChannelField { return $this->channelField; } + public function getTransformer(): ?string { return $this->transformer; } + public function setTransformer(?string $transformer): void { $this->transformer = $transformer; } +} +``` + +- [ ] **Step 4: ArticleTypePlatformConfig implementieren** + +```php + */ + #[ORM\OneToMany(mappedBy: 'platformConfig', targetEntity: AttributeMapping::class, cascade: ['persist', 'remove'])] + private Collection $attributeMappings; + + public function __construct(ArticleType $articleType, Platform $platform, string $categoryId) + { + $this->id = Uuid::v7(); + $this->articleType = $articleType; + $this->platform = $platform; + $this->categoryId = $categoryId; + $this->attributeMappings = new ArrayCollection(); + } + + public function getId(): Uuid { return $this->id; } + public function getArticleType(): ArticleType { return $this->articleType; } + public function getPlatform(): Platform { return $this->platform; } + public function getCategoryId(): string { return $this->categoryId; } + public function setCategoryId(string $categoryId): void { $this->categoryId = $categoryId; } + + /** @return Collection */ + public function getAttributeMappings(): Collection { return $this->attributeMappings; } +} +``` + +- [ ] **Step 5: PHPStan + CS Fixer** + +```bash +docker compose run --rm app ./vendor/bin/phpstan analyse src/Domain/Channel --no-progress +# Expected: No errors + +docker compose run --rm app ./vendor/bin/php-cs-fixer fix src/Domain/Channel --dry-run --diff +# Expected: keine Änderungen +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/Domain/Channel/ +git commit -m "feat: add Channel domain cluster (Platform, ChannelField, ArticleTypePlatformConfig, AttributeMapping)" +``` + +--- + +## Task 8: Domain-Entities — Order, Pipeline, Auth + +**Files:** +- Create: `src/Domain/Order/Customer.php` +- Create: `src/Domain/Order/Invoice.php` +- Create: `src/Domain/Order/Order.php` +- Create: `src/Domain/Pipeline/AIPipelineJob.php` +- Create: `src/Domain/Auth/User.php` +- Create: `src/Domain/Auth/ApiKey.php` +- Test: `tests/Unit/Domain/Order/CustomerTest.php` + +- [ ] **Step 1: Failing test für Customer-Matching schreiben** + +```php +assertSame([], $customer->getPlatformIds()); + $this->assertNull($customer->getPlatformId('ebay')); + } + + public function test_add_platform_id(): void + { + $customer = new Customer('Max Mustermann', 'max@example.com', []); + $customer->addPlatformId('ebay', 'ebay-user-123'); + + $this->assertSame('ebay-user-123', $customer->getPlatformId('ebay')); + } + + public function test_matching_key_is_lowercase_normalized(): void + { + $customer = new Customer('Max Mustermann', 'max@example.com', [ + 'street' => 'Musterstraße 1', + 'city' => 'Berlin', + 'zip' => '10115', + ]); + + $expected = 'max mustermann|musterstraße 1|berlin|10115'; + $this->assertSame($expected, $customer->getMatchingKey()); + } +} +``` + +- [ ] **Step 2: Test ausführen — muss fehlschlagen** + +```bash +docker compose run --rm app ./vendor/bin/pest tests/Unit/Domain/Order/CustomerTest.php +# Expected: FAIL — class Customer not found +``` + +- [ ] **Step 3: Customer implementieren** + +```php + */ + #[ORM\Column(type: 'json')] + private array $address; + + #[ORM\Column(type: 'string', length: 255, nullable: true)] + private ?string $frappeCustomerId = null; + + /** @var array platform => id, e.g. {"ebay": "user123"} */ + #[ORM\Column(type: 'json')] + private array $platformIds = []; + + /** + * @param array $address Keys: street, city, zip, country + */ + public function __construct(string $name, string $email, array $address) + { + $this->id = Uuid::v7(); + $this->name = $name; + $this->email = $email; + $this->address = $address; + } + + public function getId(): Uuid { return $this->id; } + public function getName(): string { return $this->name; } + public function getEmail(): string { return $this->email; } + + /** @return array */ + public function getAddress(): array { return $this->address; } + + public function getFrappeCustomerId(): ?string { return $this->frappeCustomerId; } + public function setFrappeCustomerId(?string $id): void { $this->frappeCustomerId = $id; } + + /** @return array */ + public function getPlatformIds(): array { return $this->platformIds; } + + public function getPlatformId(string $platform): ?string + { + return $this->platformIds[$platform] ?? null; + } + + public function addPlatformId(string $platform, string $id): void + { + $this->platformIds[$platform] = $id; + } + + /** + * Exact lowercase match key for cross-platform deduplication. + * Only name + street + city + zip — no fuzzy logic. + */ + public function getMatchingKey(): string + { + return \mb_strtolower(\implode('|', [ + $this->name, + $this->address['street'] ?? '', + $this->address['city'] ?? '', + $this->address['zip'] ?? '', + ])); + } +} +``` + +- [ ] **Step 4: Invoice implementieren** + +```php +id = Uuid::v7(); + $this->order = $order; + $this->frappeInvoiceId = $frappeInvoiceId; + $this->storagePath = $storagePath; + $this->filename = $filename; + $this->createdAt = new \DateTimeImmutable(); + } + + public function getId(): Uuid { return $this->id; } + public function getOrder(): Order { return $this->order; } + public function getFrappeInvoiceId(): string { return $this->frappeInvoiceId; } + public function getStoragePath(): StoragePath { return $this->storagePath; } + public function getFilename(): string { return $this->filename; } + public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; } + public function getEmailedAt(): ?\DateTimeImmutable { return $this->emailedAt; } + + public function markAsEmailed(): void + { + $this->emailedAt = new \DateTimeImmutable(); + } + + public function getFullPath(): string + { + return $this->storagePath->resolveFilePath($this->filename); + } +} +``` + +- [ ] **Step 5: Order implementieren** + +```php +id = Uuid::v7(); + $this->article = $article; + $this->customer = $customer; + $this->platform = $platform; + $this->platformOrderId = $platformOrderId; + $this->status = OrderStatus::Pending; + $this->salePrice = $salePrice; + $this->saleDate = $saleDate; + } + + public function setTracking(string $trackingNumber, string $carrier): void + { + $this->trackingNumber = $trackingNumber; + $this->carrier = $carrier; + $this->shippedAt = new \DateTimeImmutable(); + $this->status = OrderStatus::Shipped; + } + + public function markTrackingPushedToEbay(): void + { + $this->trackingPushedToEbayAt = new \DateTimeImmutable(); + } + + public function getId(): Uuid { return $this->id; } + public function getArticle(): Article { return $this->article; } + public function getCustomer(): Customer { return $this->customer; } + public function getPlatform(): Platform { return $this->platform; } + public function getPlatformOrderId(): string { return $this->platformOrderId; } + public function getStatus(): OrderStatus { return $this->status; } + public function setStatus(OrderStatus $status): void { $this->status = $status; } + public function getSalePrice(): string { return $this->salePrice; } + public function getSaleDate(): \DateTimeImmutable { return $this->saleDate; } + public function getTrackingNumber(): ?string { return $this->trackingNumber; } + public function getCarrier(): ?string { return $this->carrier; } + public function getShippedAt(): ?\DateTimeImmutable { return $this->shippedAt; } + public function getTrackingPushedToEbayAt(): ?\DateTimeImmutable { return $this->trackingPushedToEbayAt; } + public function getInvoice(): ?Invoice { return $this->invoice; } + public function setInvoice(Invoice $invoice): void { $this->invoice = $invoice; } +} +``` + +- [ ] **Step 6: AIPipelineJob implementieren** + +```php + */ + #[ORM\Column(type: 'json')] + private array $inputData; + + /** @var array */ + #[ORM\Column(type: 'json')] + private array $outputData = []; + + /** @var list */ + #[ORM\Column(type: 'simple_array', nullable: true)] + private array $missingFields = []; + + #[ORM\Column(type: 'text', nullable: true)] + private ?string $errorMessage = null; + + #[ORM\Column(type: 'datetime_immutable')] + private \DateTimeImmutable $createdAt; + + #[ORM\Column(type: 'datetime_immutable', nullable: true)] + private ?\DateTimeImmutable $completedAt = null; + + /** + * @param array $inputData + */ + public function __construct(AIPipelineJobType $type, array $inputData) + { + $this->id = Uuid::v7(); + $this->type = $type; + $this->inputData = $inputData; + $this->status = AIPipelineJobStatus::Queued; + $this->createdAt = new \DateTimeImmutable(); + } + + public function incrementAttempt(): void { ++$this->attemptCount; } + + public function markCompleted(): void + { + $this->status = AIPipelineJobStatus::Completed; + $this->completedAt = new \DateTimeImmutable(); + } + + public function markFailed(string $errorMessage): void + { + $this->status = AIPipelineJobStatus::Failed; + $this->errorMessage = $errorMessage; + } + + public function markNeedsReview(string $reason): void + { + $this->status = AIPipelineJobStatus::NeedsReview; + $this->errorMessage = $reason; + } + + public function getId(): Uuid { return $this->id; } + public function getType(): AIPipelineJobType { return $this->type; } + public function getArticle(): ?Article { return $this->article; } + public function setArticle(Article $article): void { $this->article = $article; } + public function getStatus(): AIPipelineJobStatus { return $this->status; } + public function setStatus(AIPipelineJobStatus $status): void { $this->status = $status; } + public function getAttemptCount(): int { return $this->attemptCount; } + + /** @return array */ + public function getInputData(): array { return $this->inputData; } + + /** @return array */ + public function getOutputData(): array { return $this->outputData; } + + /** @param array $outputData */ + public function setOutputData(array $outputData): void { $this->outputData = $outputData; } + + /** @return list */ + public function getMissingFields(): array { return $this->missingFields; } + + /** @param list $fields */ + public function setMissingFields(array $fields): void { $this->missingFields = $fields; } + + public function getErrorMessage(): ?string { return $this->errorMessage; } + public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; } + public function getCompletedAt(): ?\DateTimeImmutable { return $this->completedAt; } +} +``` + +- [ ] **Step 7: User und ApiKey implementieren** + +```php + */ + #[ORM\Column(type: 'json')] + private array $permissions = []; + + #[ORM\Column(type: 'boolean')] + private bool $isActive = true; + + public function __construct(string $email, string $passwordHash) + { + $this->id = Uuid::v7(); + $this->email = $email; + $this->passwordHash = $passwordHash; + } + + public function getId(): Uuid { return $this->id; } + public function getEmail(): string { return $this->email; } + public function getPassword(): string { return $this->passwordHash; } + public function getUserIdentifier(): string { return $this->email; } + public function getRoles(): array { return ['ROLE_USER']; } + public function eraseCredentials(): void {} + public function getTotpSecret(): ?string { return $this->totpSecret; } + public function setTotpSecret(?string $secret): void { $this->totpSecret = $secret; } + public function isActive(): bool { return $this->isActive; } + public function setIsActive(bool $active): void { $this->isActive = $active; } + + /** @return array */ + public function getPermissions(): array { return $this->permissions; } + + public function hasPermission(string $permission): bool + { + return $this->permissions[$permission] ?? false; + } + + public function grantPermission(string $permission): void + { + $this->permissions[$permission] = true; + } + + public function revokePermission(string $permission): void + { + unset($this->permissions[$permission]); + } +} +``` + +```php + */ + #[ORM\Column(type: 'json')] + private array $permissions = []; + + #[ORM\Column(type: 'boolean')] + private bool $isActive = true; + + #[ORM\Column(type: 'datetime_immutable', nullable: true)] + private ?\DateTimeImmutable $lastUsedAt = null; + + #[ORM\Column(type: 'datetime_immutable', nullable: true)] + private ?\DateTimeImmutable $expiresAt = null; + + public function __construct(User $user, string $label, string $keyHash) + { + $this->id = Uuid::v7(); + $this->user = $user; + $this->label = $label; + $this->keyHash = $keyHash; + } + + public function getId(): Uuid { return $this->id; } + public function getUser(): User { return $this->user; } + public function getLabel(): string { return $this->label; } + public function getKeyHash(): string { return $this->keyHash; } + public function isActive(): bool { return $this->isActive; } + public function setIsActive(bool $active): void { $this->isActive = $active; } + public function getLastUsedAt(): ?\DateTimeImmutable { return $this->lastUsedAt; } + public function getExpiresAt(): ?\DateTimeImmutable { return $this->expiresAt; } + public function setExpiresAt(?\DateTimeImmutable $expiresAt): void { $this->expiresAt = $expiresAt; } + + /** @return array */ + public function getPermissions(): array { return $this->permissions; } + + public function hasPermission(string $permission): bool + { + return $this->permissions[$permission] ?? false; + } + + public function grantPermission(string $permission): void + { + $this->permissions[$permission] = true; + } + + public function markUsed(): void + { + $this->lastUsedAt = new \DateTimeImmutable(); + } + + public function isExpired(): bool + { + return null !== $this->expiresAt && $this->expiresAt < new \DateTimeImmutable(); + } +} +``` + +- [ ] **Step 8: Tests ausführen** + +```bash +docker compose run --rm app ./vendor/bin/pest tests/Unit/Domain/Order/CustomerTest.php +# Expected: PASS (3 tests) +``` + +- [ ] **Step 9: Commit** + +```bash +git add src/Domain/Order/ src/Domain/Pipeline/ src/Domain/Auth/ tests/Unit/Domain/Order/ +git commit -m "feat: add Order, Pipeline, Auth domain entities (Customer with matching-key, Order, Invoice, AIPipelineJob, User, ApiKey)" +``` + +--- + +## Task 9: Repository-Interfaces (Ports) + +**Files:** +- Create: `src/Domain/Article/Repository/ArticleRepositoryInterface.php` +- Create: `src/Domain/Article/Repository/ArticleTypeRepositoryInterface.php` +- Create: `src/Domain/Channel/Repository/PlatformRepositoryInterface.php` +- Create: `src/Domain/Order/Repository/CustomerRepositoryInterface.php` +- Create: `src/Domain/Order/Repository/OrderRepositoryInterface.php` + +- [ ] **Step 1: ArticleRepositoryInterface schreiben** + +```php + */ + public function findByStatus(ArticleStatus $status): array; + + /** + * Atomically decrements stock WHERE stock > 0. + * Returns true if decrement succeeded (stock was > 0), false if already 0. + */ + public function decrementStockAtomic(Uuid $articleId): bool; + + public function save(Article $article): void; + + public function remove(Article $article): void; +} +``` + +- [ ] **Step 2: ArticleTypeRepositoryInterface schreiben** + +```php + */ + public function findAll(): array; + + public function save(ArticleType $articleType): void; +} +``` + +- [ ] **Step 3: PlatformRepositoryInterface schreiben** + +```php + */ + public function findAll(): array; + + public function save(Platform $platform): void; +} +``` + +- [ ] **Step 4: CustomerRepositoryInterface schreiben** + +```php +em->find(Article::class, $id); + } + + public function findBySku(string $sku): ?Article + { + return $this->em->getRepository(Article::class)->findOneBy(['sku' => $sku]); + } + + public function findByInventoryNumber(string $inventoryNumber): ?Article + { + return $this->em->getRepository(Article::class)->findOneBy(['inventoryNumber' => $inventoryNumber]); + } + + /** @return list
*/ + public function findByStatus(ArticleStatus $status): array + { + /** @var list
*/ + return $this->em->getRepository(Article::class)->findBy(['status' => $status]); + } + + public function decrementStockAtomic(Uuid $articleId): bool + { + $affected = $this->em->getConnection()->executeStatement( + 'UPDATE app.articles SET stock = stock - 1 WHERE id = :id AND stock > 0', + ['id' => $articleId->toRfc4122()], + ); + + if ($affected > 0) { + // Evict from identity map so next findById returns fresh stock value + $article = $this->em->find(Article::class, $articleId); + if (null !== $article) { + $this->em->refresh($article); + } + } + + return $affected > 0; + } + + public function save(Article $article): void + { + $this->em->persist($article); + $this->em->flush(); + } + + public function remove(Article $article): void + { + $this->em->remove($article); + $this->em->flush(); + } +} +``` + +- [ ] **Step 2: DoctrineArticleTypeRepository schreiben** + +```php +em->find(ArticleType::class, $id); + } + + public function findByName(string $name): ?ArticleType + { + return $this->em->getRepository(ArticleType::class)->findOneBy(['name' => $name]); + } + + /** @return list */ + public function findAll(): array + { + /** @var list */ + return $this->em->getRepository(ArticleType::class)->findAll(); + } + + public function save(ArticleType $articleType): void + { + $this->em->persist($articleType); + $this->em->flush(); + } +} +``` + +- [ ] **Step 3: DoctrineCustomerRepository schreiben** + +```php +em->find(Customer::class, $id); + } + + public function findByPlatformId(string $platform, string $platformUserId): ?Customer + { + return $this->em->getRepository(Customer::class) + ->createQueryBuilder('c') + ->where("JSON_VALUE(c.platformIds, '$.\"{$platform}\"') = :id") + ->setParameter('id', $platformUserId) + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + } + + public function findByMatchingKey(string $matchingKey): ?Customer + { + // Matching key: lowercase(name|street|city|zip) + // We compute it in PHP after fetching candidates — table is small enough + // For scale: add a generated column to the DB + $customers = $this->em->getRepository(Customer::class)->findAll(); + foreach ($customers as $customer) { + if ($customer->getMatchingKey() === $matchingKey) { + return $customer; + } + } + + return null; + } + + public function save(Customer $customer): void + { + $this->em->persist($customer); + $this->em->flush(); + } +} +``` + +- [ ] **Step 4: DoctrineOrderRepository und DoctrinePlatformRepository schreiben** + +```php +em->find(Order::class, $id); + } + + public function findByPlatformOrderId(string $platformOrderId): ?Order + { + return $this->em->getRepository(Order::class)->findOneBy(['platformOrderId' => $platformOrderId]); + } + + public function save(Order $order): void + { + $this->em->persist($order); + $this->em->flush(); + } +} +``` + +```php +em->find(Platform::class, $id); + } + + public function findByType(string $type): ?Platform + { + return $this->em->getRepository(Platform::class)->findOneBy(['type' => $type]); + } + + /** @return list */ + public function findAll(): array + { + /** @var list */ + return $this->em->getRepository(Platform::class)->findAll(); + } + + public function save(Platform $platform): void + { + $this->em->persist($platform); + $this->em->flush(); + } +} +``` + +- [ ] **Step 5: Interfaces an Implementations binden** + +```yaml +# config/services.yaml (relevante Einträge ergänzen) +services: + _defaults: + autowire: true + autoconfigure: true + + App\: + resource: '../src/' + exclude: + - '../src/Domain/' + - '../src/Kernel.php' + + App\Domain\Article\Repository\ArticleRepositoryInterface: + alias: App\Infrastructure\Persistence\Repository\DoctrineArticleRepository + + App\Domain\Article\Repository\ArticleTypeRepositoryInterface: + alias: App\Infrastructure\Persistence\Repository\DoctrineArticleTypeRepository + + App\Domain\Channel\Repository\PlatformRepositoryInterface: + alias: App\Infrastructure\Persistence\Repository\DoctrinePlatformRepository + + App\Domain\Order\Repository\CustomerRepositoryInterface: + alias: App\Infrastructure\Persistence\Repository\DoctrineCustomerRepository + + App\Domain\Order\Repository\OrderRepositoryInterface: + alias: App\Infrastructure\Persistence\Repository\DoctrineOrderRepository +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/Infrastructure/ config/services.yaml +git commit -m "feat: add Doctrine repository implementations and wire interfaces in services.yaml" +``` + +--- + +## Task 11: Datenbank-Migration + +**Files:** +- Create: `migrations/Version20260513000001.php` + +- [ ] **Step 1: Schemas erstellen lassen** + +```bash +docker compose up -d postgres +docker compose run --rm app php bin/console doctrine:migrations:generate +# Öffne die generierte Datei und ersetze den Inhalt +``` + +- [ ] **Step 2: Migration schreiben** + +```php +addSql('CREATE SCHEMA IF NOT EXISTS app'); + $this->addSql('CREATE SCHEMA IF NOT EXISTS logs'); + $this->addSql('CREATE SCHEMA IF NOT EXISTS logs_archive'); + + // StoragePath + $this->addSql('CREATE TABLE app.storage_paths ( + id UUID NOT NULL, + label VARCHAR(255) NOT NULL, + base_path VARCHAR(500) NOT NULL, + quota_bytes BIGINT NOT NULL, + priority INT NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + PRIMARY KEY(id) + )'); + + // ArticleType + $this->addSql('CREATE TABLE app.article_types ( + id UUID NOT NULL, + name VARCHAR(255) NOT NULL, + PRIMARY KEY(id), + CONSTRAINT uq_article_type_name UNIQUE (name) + )'); + + // AttributeDefinition + $this->addSql('CREATE TABLE app.attribute_definitions ( + id UUID NOT NULL, + name VARCHAR(255) NOT NULL, + type VARCHAR(50) NOT NULL, + unit VARCHAR(50) DEFAULT NULL, + options JSON DEFAULT NULL, + PRIMARY KEY(id) + )'); + + // ArticleType <-> AttributeDefinition (M:N) + $this->addSql('CREATE TABLE app.article_type_attributes ( + article_type_id UUID NOT NULL, + attribute_definition_id UUID NOT NULL, + PRIMARY KEY(article_type_id, attribute_definition_id), + FOREIGN KEY(article_type_id) REFERENCES app.article_types(id) ON DELETE CASCADE, + FOREIGN KEY(attribute_definition_id) REFERENCES app.attribute_definitions(id) ON DELETE CASCADE + )'); + + // Platform + $this->addSql('CREATE TABLE app.platforms ( + id UUID NOT NULL, + type VARCHAR(100) NOT NULL, + label VARCHAR(255) NOT NULL, + config JSON NOT NULL DEFAULT \'[]\', + PRIMARY KEY(id), + CONSTRAINT uq_platform_type UNIQUE (type) + )'); + + // ChannelField + $this->addSql('CREATE TABLE app.channel_fields ( + id UUID NOT NULL, + platform_id UUID NOT NULL, + label VARCHAR(255) NOT NULL, + path VARCHAR(500) NOT NULL, + PRIMARY KEY(id), + FOREIGN KEY(platform_id) REFERENCES app.platforms(id) ON DELETE CASCADE + )'); + + // ArticleTypePlatformConfig + $this->addSql('CREATE TABLE app.article_type_platform_configs ( + id UUID NOT NULL, + article_type_id UUID NOT NULL, + platform_id UUID NOT NULL, + category_id VARCHAR(255) NOT NULL, + PRIMARY KEY(id), + CONSTRAINT uq_type_platform UNIQUE (article_type_id, platform_id), + FOREIGN KEY(article_type_id) REFERENCES app.article_types(id) ON DELETE CASCADE, + FOREIGN KEY(platform_id) REFERENCES app.platforms(id) ON DELETE CASCADE + )'); + + // AttributeMapping + $this->addSql('CREATE TABLE app.attribute_mappings ( + id UUID NOT NULL, + platform_config_id UUID NOT NULL, + attribute_definition_id UUID NOT NULL, + channel_field_id UUID NOT NULL, + transformer VARCHAR(100) DEFAULT NULL, + PRIMARY KEY(id), + CONSTRAINT uq_mapping UNIQUE (platform_config_id, attribute_definition_id), + FOREIGN KEY(platform_config_id) REFERENCES app.article_type_platform_configs(id) ON DELETE CASCADE, + FOREIGN KEY(attribute_definition_id) REFERENCES app.attribute_definitions(id), + FOREIGN KEY(channel_field_id) REFERENCES app.channel_fields(id) + )'); + + // Article + $this->addSql('CREATE TABLE app.articles ( + id UUID NOT NULL, + article_type_id UUID NOT NULL, + sku VARCHAR(255) NOT NULL, + inventory_number VARCHAR(100) NOT NULL, + status VARCHAR(50) NOT NULL, + stock INT NOT NULL DEFAULT 0, + condition VARCHAR(50) NOT NULL, + condition_notes TEXT DEFAULT NULL, + listing_price DECIMAL(10,2) DEFAULT NULL, + serial_number VARCHAR(255) DEFAULT NULL, + ebay_listing_id VARCHAR(255) DEFAULT NULL, + ebay_title TEXT DEFAULT NULL, + ebay_description TEXT DEFAULT NULL, + PRIMARY KEY(id), + CONSTRAINT uq_article_sku UNIQUE (sku), + CONSTRAINT uq_article_inv UNIQUE (inventory_number), + FOREIGN KEY(article_type_id) REFERENCES app.article_types(id) + )'); + + // ArticlePhoto + $this->addSql('CREATE TABLE app.article_photos ( + id UUID NOT NULL, + article_id UUID NOT NULL, + storage_path_id UUID NOT NULL, + filename VARCHAR(500) NOT NULL, + is_main BOOLEAN NOT NULL DEFAULT FALSE, + sort_order INT NOT NULL DEFAULT 0, + PRIMARY KEY(id), + FOREIGN KEY(article_id) REFERENCES app.articles(id) ON DELETE CASCADE, + FOREIGN KEY(storage_path_id) REFERENCES app.storage_paths(id) + )'); + + // AttributeValue + $this->addSql('CREATE TABLE app.attribute_values ( + id UUID NOT NULL, + article_id UUID NOT NULL, + attribute_definition_id UUID NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY(id), + CONSTRAINT uq_article_attr UNIQUE (article_id, attribute_definition_id), + FOREIGN KEY(article_id) REFERENCES app.articles(id) ON DELETE CASCADE, + FOREIGN KEY(attribute_definition_id) REFERENCES app.attribute_definitions(id) + )'); + + // Users + $this->addSql('CREATE TABLE app.users ( + id UUID NOT NULL, + email VARCHAR(255) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + totp_secret VARCHAR(255) DEFAULT NULL, + permissions JSON NOT NULL DEFAULT \'[]\', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + PRIMARY KEY(id), + CONSTRAINT uq_user_email UNIQUE (email) + )'); + + // ApiKeys + $this->addSql('CREATE TABLE app.api_keys ( + id UUID NOT NULL, + user_id UUID NOT NULL, + label VARCHAR(255) NOT NULL, + key_hash VARCHAR(255) NOT NULL, + permissions JSON NOT NULL DEFAULT \'[]\', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + last_used_at TIMESTAMP DEFAULT NULL, + expires_at TIMESTAMP DEFAULT NULL, + PRIMARY KEY(id), + CONSTRAINT uq_api_key_hash UNIQUE (key_hash), + FOREIGN KEY(user_id) REFERENCES app.users(id) ON DELETE CASCADE + )'); + + // Customers + $this->addSql('CREATE TABLE app.customers ( + id UUID NOT NULL, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + address JSON NOT NULL DEFAULT \'[]\', + frappe_customer_id VARCHAR(255) DEFAULT NULL, + platform_ids JSON NOT NULL DEFAULT \'[]\', + PRIMARY KEY(id) + )'); + + // Orders + $this->addSql('CREATE TABLE app.orders ( + id UUID NOT NULL, + article_id UUID NOT NULL, + customer_id UUID NOT NULL, + platform_id UUID NOT NULL, + platform_order_id VARCHAR(255) NOT NULL, + status VARCHAR(50) NOT NULL, + sale_price DECIMAL(10,2) NOT NULL, + sale_date TIMESTAMP NOT NULL, + tracking_number VARCHAR(255) DEFAULT NULL, + carrier VARCHAR(100) DEFAULT NULL, + shipped_at TIMESTAMP DEFAULT NULL, + tracking_pushed_to_ebay_at TIMESTAMP DEFAULT NULL, + PRIMARY KEY(id), + CONSTRAINT uq_platform_order UNIQUE (platform_order_id), + FOREIGN KEY(article_id) REFERENCES app.articles(id), + FOREIGN KEY(customer_id) REFERENCES app.customers(id), + FOREIGN KEY(platform_id) REFERENCES app.platforms(id) + )'); + + // Invoices + $this->addSql('CREATE TABLE app.invoices ( + id UUID NOT NULL, + order_id UUID NOT NULL, + frappe_invoice_id VARCHAR(255) NOT NULL, + storage_path_id UUID NOT NULL, + filename VARCHAR(500) NOT NULL, + created_at TIMESTAMP NOT NULL, + emailed_at TIMESTAMP DEFAULT NULL, + PRIMARY KEY(id), + CONSTRAINT uq_invoice_order UNIQUE (order_id), + FOREIGN KEY(order_id) REFERENCES app.orders(id), + FOREIGN KEY(storage_path_id) REFERENCES app.storage_paths(id) + )'); + + // AIPipelineJobs + $this->addSql('CREATE TABLE app.ai_pipeline_jobs ( + id UUID NOT NULL, + type VARCHAR(50) NOT NULL, + article_id UUID DEFAULT NULL, + status VARCHAR(50) NOT NULL, + attempt_count INT NOT NULL DEFAULT 0, + input_data JSON NOT NULL, + output_data JSON NOT NULL DEFAULT \'[]\', + missing_fields TEXT DEFAULT NULL, + error_message TEXT DEFAULT NULL, + created_at TIMESTAMP NOT NULL, + completed_at TIMESTAMP DEFAULT NULL, + PRIMARY KEY(id), + FOREIGN KEY(article_id) REFERENCES app.articles(id) ON DELETE SET NULL + )'); + + // Logs + $this->addSql('CREATE TABLE logs.log_entry ( + id BIGSERIAL NOT NULL, + level VARCHAR(20) NOT NULL, + channel VARCHAR(100) NOT NULL, + message TEXT NOT NULL, + context JSON NOT NULL DEFAULT \'[]\', + message_search TSVECTOR GENERATED ALWAYS AS (to_tsvector(\'german\', message)) STORED, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + PRIMARY KEY(id) + )'); + $this->addSql('CREATE INDEX idx_log_level ON logs.log_entry (level)'); + $this->addSql('CREATE INDEX idx_log_created ON logs.log_entry (created_at)'); + $this->addSql('CREATE INDEX idx_log_fts ON logs.log_entry USING GIN (message_search)'); + + // Logs Archive (same structure) + $this->addSql('CREATE TABLE logs_archive.log_entry ( + id BIGSERIAL NOT NULL, + level VARCHAR(20) NOT NULL, + channel VARCHAR(100) NOT NULL, + message TEXT NOT NULL, + context JSON NOT NULL DEFAULT \'[]\', + message_search TSVECTOR GENERATED ALWAYS AS (to_tsvector(\'german\', message)) STORED, + created_at TIMESTAMP NOT NULL, + PRIMARY KEY(id) + )'); + $this->addSql('CREATE INDEX idx_log_archive_created ON logs_archive.log_entry (created_at)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP SCHEMA IF EXISTS logs_archive CASCADE'); + $this->addSql('DROP SCHEMA IF EXISTS logs CASCADE'); + $this->addSql('DROP SCHEMA IF EXISTS app CASCADE'); + } +} +``` + +- [ ] **Step 3: Migration ausführen** + +```bash +docker compose up -d postgres +docker compose run --rm app php bin/console doctrine:migrations:migrate --no-interaction +# Expected: 1 migration executed successfully +``` + +- [ ] **Step 4: Schema validieren** + +```bash +docker compose run --rm app php bin/console doctrine:schema:validate +# Expected: "[OK] The mapping files are correct" und "[OK] The database schema is in sync" +``` + +- [ ] **Step 5: Alle Tests + PHPStan** + +```bash +docker compose run --rm app ./vendor/bin/pest +# Expected: alle Unit-Tests PASS + +docker compose run --rm app ./vendor/bin/phpstan analyse --no-progress +# Expected: No errors +``` + +- [ ] **Step 6: Commit** + +```bash +git add migrations/ +git commit -m "feat: add initial migration — app/logs/logs_archive schemas with all base tables and GIN fulltext index" +``` + +--- + +## Self-Review + +**Spec-Coverage:** +- ✅ Docker Compose (app, caddy, postgres, redis, 3 workers, cron, gitea/act-runner in CI) +- ✅ Hexagonale Struktur (Domain / Application / Infrastructure) +- ✅ PHP 8.4, Symfony 7, PostgreSQL 17, Redis +- ✅ Drei isolierte Messenger-Transports +- ✅ Alle Domain-Entities (Article, ArticleType, AttributeDefinition, AttributeValue, ArticlePhoto, StoragePath, Platform, ChannelField, ArticleTypePlatformConfig, AttributeMapping, Customer, Order, Invoice, AIPipelineJob, User, ApiKey) +- ✅ ArticleStatus mit Transition-Logik +- ✅ `decrementStockAtomic` (SQL-Level, kein Überverkauf) +- ✅ `Customer.getMatchingKey()` (lowercase, exakt) +- ✅ `Customer.platform_ids` als JSON +- ✅ `StoragePath.resolveFilePath()` (Migration: nur base_path ändern) +- ✅ Log-Schemas mit GIN-Fulltext-Index +- ✅ TDD: alle Entities mit Tests +- ✅ PHPStan Level 9, PHP CS Fixer, CI + +**Gitea + act-runner:** Gitea läuft als Container in docker-compose.yml, aber für Plan 1 wird Gitea manuell eingerichtet. Die CI-Pipeline liegt in `.gitea/workflows/ci.yml` und funktioniert sobald der act-runner registriert ist. + +**Nicht in Plan 1 (folgen in Plänen 2–6):** +- StorageManager Service (Plan 2) +- ChannelAdapterInterface (Plan 5) +- Auth/ACL Voter (Plan 3) +- EasyAdmin (Plan 3) +- AI-Agenten (Plan 4) +- eBay-Webhook (Plan 5) +- Order-UseCase (Plan 6) diff --git a/docs/superpowers/plans/2026-05-13-02-article-api.md b/docs/superpowers/plans/2026-05-13-02-article-api.md new file mode 100644 index 0000000..f05a36b --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-02-article-api.md @@ -0,0 +1,2461 @@ +# SuperSeller3000 — Plan 2: Artikel-Management API + +> **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:** REST-API für vollständiges Artikel-Management: ArticleType/AttributeDefinition-CRUD, Platform/ChannelField/Mapping-Konfiguration, StorageManager mit Multi-Path-Quota-Support, Foto-Upload, Artikel-CRUD mit Pflichtfeld-Validierung. + +**Architecture:** Application-Layer plain Service-Klassen (keine Bus-Abhängigkeit für synchrone Operationen). Controller in Infrastructure/Http validieren JSON-Input, delegieren an Application-Services, antworten mit JsonResponse. StorageManager wählt aktiven StoragePath mit höchster Priorität, der noch Quota hat. + +**Tech Stack:** PHP 8.4, Symfony 7, Doctrine ORM, PHPUnit 11 + Pest 3, PHPStan Level 9 + +--- + +## Dateistruktur (gesamter Plan) + +``` +src/ + Domain/ + Article/ + Repository/ + ArticlePhotoRepositoryInterface.php # new + AttributeValueRepositoryInterface.php # new + Storage/ + Repository/ + StoragePathRepositoryInterface.php # new + Channel/ + Repository/ + ChannelFieldRepositoryInterface.php # new + ArticleTypePlatformConfigRepositoryInterface.php # new + AttributeMappingRepositoryInterface.php # new + Application/ + Article/ + ArticleTypeService.php + ArticleService.php + ArticleValidator.php + PhotoService.php + Channel/ + PlatformService.php + MappingService.php + Storage/ + StorageManagerInterface.php + StoredFile.php # value object + Infrastructure/ + Persistence/ + Repository/ + DoctrineStoragePathRepository.php + DoctrineArticlePhotoRepository.php + DoctrineAttributeValueRepository.php + DoctrineChannelFieldRepository.php + DoctrineArticleTypePlatformConfigRepository.php + DoctrineAttributeMappingRepository.php + Http/ + Controller/ + Api/ + ArticleTypeController.php + ArticleController.php + PhotoController.php + PlatformController.php + MappingController.php + Storage/ + LocalStorageManager.php +migrations/ + Version20260513000002.php # inventory_seq +config/ + routes/ + api.yaml # route prefix /api +tests/ + Unit/ + Application/ + Article/ + ArticleValidatorTest.php + ArticleTypeServiceTest.php + Storage/ + LocalStorageManagerTest.php +``` + +--- + +## Task 1: Fehlende Repository-Interfaces + Doctrine-Implementierungen + +**Files:** +- Create: `src/Domain/Storage/Repository/StoragePathRepositoryInterface.php` +- Create: `src/Domain/Article/Repository/ArticlePhotoRepositoryInterface.php` +- Create: `src/Domain/Article/Repository/AttributeValueRepositoryInterface.php` +- Create: `src/Domain/Channel/Repository/ChannelFieldRepositoryInterface.php` +- Create: `src/Domain/Channel/Repository/ArticleTypePlatformConfigRepositoryInterface.php` +- Create: `src/Domain/Channel/Repository/AttributeMappingRepositoryInterface.php` +- Create: `src/Infrastructure/Persistence/Repository/DoctrineStoragePathRepository.php` +- Create: `src/Infrastructure/Persistence/Repository/DoctrineArticlePhotoRepository.php` +- Create: `src/Infrastructure/Persistence/Repository/DoctrineAttributeValueRepository.php` +- Create: `src/Infrastructure/Persistence/Repository/DoctrineChannelFieldRepository.php` +- Create: `src/Infrastructure/Persistence/Repository/DoctrineArticleTypePlatformConfigRepository.php` +- Create: `src/Infrastructure/Persistence/Repository/DoctrineAttributeMappingRepository.php` +- Modify: `config/services.yaml` + +- [ ] **Step 1: Domain-Interfaces schreiben** + +```php + active paths ordered by priority DESC */ + public function findActiveSortedByPriority(): array; + + public function save(StoragePath $storagePath): void; +} +``` + +```php + */ + public function findByArticle(Uuid $articleId): array; + + public function save(ArticlePhoto $photo): void; + + public function remove(ArticlePhoto $photo): void; +} +``` + +```php + */ + public function findByArticle(Uuid $articleId): array; + + public function findByArticleAndDefinition(Uuid $articleId, Uuid $definitionId): ?AttributeValue; + + public function save(AttributeValue $value): void; + + public function remove(AttributeValue $value): void; +} +``` + +```php + */ + public function findByPlatform(Uuid $platformId): array; + + public function save(ChannelField $field): void; + + public function remove(ChannelField $field): void; +} +``` + +```php + */ + public function findByArticleType(Uuid $articleTypeId): array; + + public function save(ArticleTypePlatformConfig $config): void; + + public function remove(ArticleTypePlatformConfig $config): void; +} +``` + +```php + */ + public function findByPlatformConfig(Uuid $platformConfigId): array; + + public function save(AttributeMapping $mapping): void; + + public function remove(AttributeMapping $mapping): void; +} +``` + +- [ ] **Step 2: Doctrine-Repositories schreiben** + +```php +em->find(StoragePath::class, $id); + } + + /** @return list */ + public function findActiveSortedByPriority(): array + { + /** @var list */ + return $this->em->getRepository(StoragePath::class) + ->createQueryBuilder('s') + ->where('s.isActive = :active') + ->setParameter('active', true) + ->orderBy('s.priority', 'DESC') + ->getQuery() + ->getResult(); + } + + public function save(StoragePath $storagePath): void + { + $this->em->persist($storagePath); + $this->em->flush(); + } +} +``` + +```php +em->find(ArticlePhoto::class, $id); + } + + /** @return list */ + public function findByArticle(Uuid $articleId): array + { + /** @var list */ + return $this->em->getRepository(ArticlePhoto::class) + ->createQueryBuilder('p') + ->where('IDENTITY(p.article) = :articleId') + ->setParameter('articleId', $articleId->toRfc4122()) + ->orderBy('p.sortOrder', 'ASC') + ->getQuery() + ->getResult(); + } + + public function save(ArticlePhoto $photo): void + { + $this->em->persist($photo); + $this->em->flush(); + } + + public function remove(ArticlePhoto $photo): void + { + $this->em->remove($photo); + $this->em->flush(); + } +} +``` + +```php + */ + public function findByArticle(Uuid $articleId): array + { + /** @var list */ + return $this->em->getRepository(AttributeValue::class) + ->createQueryBuilder('v') + ->where('IDENTITY(v.article) = :articleId') + ->setParameter('articleId', $articleId->toRfc4122()) + ->getQuery() + ->getResult(); + } + + public function findByArticleAndDefinition(Uuid $articleId, Uuid $definitionId): ?AttributeValue + { + return $this->em->getRepository(AttributeValue::class) + ->createQueryBuilder('v') + ->where('IDENTITY(v.article) = :articleId') + ->andWhere('IDENTITY(v.attributeDefinition) = :defId') + ->setParameter('articleId', $articleId->toRfc4122()) + ->setParameter('defId', $definitionId->toRfc4122()) + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + } + + public function save(AttributeValue $value): void + { + $this->em->persist($value); + $this->em->flush(); + } + + public function remove(AttributeValue $value): void + { + $this->em->remove($value); + $this->em->flush(); + } +} +``` + +```php +em->find(ChannelField::class, $id); + } + + /** @return list */ + public function findByPlatform(Uuid $platformId): array + { + /** @var list */ + return $this->em->getRepository(ChannelField::class) + ->createQueryBuilder('f') + ->where('IDENTITY(f.platform) = :platformId') + ->setParameter('platformId', $platformId->toRfc4122()) + ->orderBy('f.label', 'ASC') + ->getQuery() + ->getResult(); + } + + public function save(ChannelField $field): void + { + $this->em->persist($field); + $this->em->flush(); + } + + public function remove(ChannelField $field): void + { + $this->em->remove($field); + $this->em->flush(); + } +} +``` + +```php +em->find(ArticleTypePlatformConfig::class, $id); + } + + public function findByArticleTypeAndPlatform(Uuid $articleTypeId, Uuid $platformId): ?ArticleTypePlatformConfig + { + return $this->em->getRepository(ArticleTypePlatformConfig::class) + ->createQueryBuilder('c') + ->where('IDENTITY(c.articleType) = :typeId') + ->andWhere('IDENTITY(c.platform) = :platformId') + ->setParameter('typeId', $articleTypeId->toRfc4122()) + ->setParameter('platformId', $platformId->toRfc4122()) + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + } + + /** @return list */ + public function findByArticleType(Uuid $articleTypeId): array + { + /** @var list */ + return $this->em->getRepository(ArticleTypePlatformConfig::class) + ->createQueryBuilder('c') + ->where('IDENTITY(c.articleType) = :typeId') + ->setParameter('typeId', $articleTypeId->toRfc4122()) + ->getQuery() + ->getResult(); + } + + public function save(ArticleTypePlatformConfig $config): void + { + $this->em->persist($config); + $this->em->flush(); + } + + public function remove(ArticleTypePlatformConfig $config): void + { + $this->em->remove($config); + $this->em->flush(); + } +} +``` + +```php +em->find(AttributeMapping::class, $id); + } + + /** @return list */ + public function findByPlatformConfig(Uuid $platformConfigId): array + { + /** @var list */ + return $this->em->getRepository(AttributeMapping::class) + ->createQueryBuilder('m') + ->where('IDENTITY(m.platformConfig) = :configId') + ->setParameter('configId', $platformConfigId->toRfc4122()) + ->getQuery() + ->getResult(); + } + + public function save(AttributeMapping $mapping): void + { + $this->em->persist($mapping); + $this->em->flush(); + } + + public function remove(AttributeMapping $mapping): void + { + $this->em->remove($mapping); + $this->em->flush(); + } +} +``` + +- [ ] **Step 3: services.yaml mit neuen Aliases erweitern** + +Ergänze `config/services.yaml`: + +```yaml + App\Domain\Storage\Repository\StoragePathRepositoryInterface: + alias: App\Infrastructure\Persistence\Repository\DoctrineStoragePathRepository + + App\Domain\Article\Repository\ArticlePhotoRepositoryInterface: + alias: App\Infrastructure\Persistence\Repository\DoctrineArticlePhotoRepository + + App\Domain\Article\Repository\AttributeValueRepositoryInterface: + alias: App\Infrastructure\Persistence\Repository\DoctrineAttributeValueRepository + + App\Domain\Channel\Repository\ChannelFieldRepositoryInterface: + alias: App\Infrastructure\Persistence\Repository\DoctrineChannelFieldRepository + + App\Domain\Channel\Repository\ArticleTypePlatformConfigRepositoryInterface: + alias: App\Infrastructure\Persistence\Repository\DoctrineArticleTypePlatformConfigRepository + + App\Domain\Channel\Repository\AttributeMappingRepositoryInterface: + alias: App\Infrastructure\Persistence\Repository\DoctrineAttributeMappingRepository +``` + +- [ ] **Step 4: PHPStan + CS Fixer** + +```bash +docker compose run --rm app ./vendor/bin/phpstan analyse src/ --no-progress +# Expected: No errors + +docker compose run --rm app ./vendor/bin/php-cs-fixer fix src/ --dry-run --diff +# Expected: keine Änderungen +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/Domain/ src/Infrastructure/Persistence/ config/services.yaml +git commit -m "feat: add missing repository interfaces and Doctrine implementations (StoragePath, ArticlePhoto, AttributeValue, ChannelField, ArticleTypePlatformConfig, AttributeMapping)" +``` + +--- + +## Task 2: StorageManager + +**Files:** +- Create: `src/Application/Storage/StorageManagerInterface.php` +- Create: `src/Application/Storage/StoredFile.php` +- Create: `src/Infrastructure/Storage/LocalStorageManager.php` +- Test: `tests/Unit/Application/Storage/LocalStorageManagerTest.php` + +- [ ] **Step 1: Failing-Test schreiben** + +```php +repo = $this->createMock(StoragePathRepositoryInterface::class); + $this->manager = new LocalStorageManager($this->repo); + $this->tmpFile = sys_get_temp_dir().'/test-upload-'.uniqid().'.jpg'; + file_put_contents($this->tmpFile, str_repeat('x', 100)); + } + + protected function tearDown(): void + { + if (file_exists($this->tmpFile)) { + unlink($this->tmpFile); + } + } + + public function test_store_picks_active_path_with_quota(): void + { + $path = new StoragePath('Main', sys_get_temp_dir().'/storage-test-'.uniqid(), 1_000_000, 10); + mkdir($path->getBasePath(), recursive: true); + + $this->repo->expects($this->once()) + ->method('findActiveSortedByPriority') + ->willReturn([$path]); + + $stored = $this->manager->store($this->tmpFile, 'photo.jpg'); + + $this->assertSame($path->getId()->toRfc4122(), $stored->storagePath->getId()->toRfc4122()); + $this->assertStringEndsWith('.jpg', $stored->filename); + $this->assertFileExists($path->getBasePath().'/'.$stored->filename); + + // cleanup + unlink($path->getBasePath().'/'.$stored->filename); + rmdir($path->getBasePath()); + } + + public function test_throws_when_no_active_path(): void + { + $this->repo->method('findActiveSortedByPriority')->willReturn([]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('No active storage path'); + + $this->manager->store($this->tmpFile, 'photo.jpg'); + } + + public function test_skips_full_path_and_uses_next(): void + { + $fullPath = new StoragePath('Full', sys_get_temp_dir().'/full-'.uniqid(), 50, 20); + $okPath = new StoragePath('OK', sys_get_temp_dir().'/ok-'.uniqid(), 1_000_000, 10); + mkdir($fullPath->getBasePath(), recursive: true); + mkdir($okPath->getBasePath(), recursive: true); + + // fullPath quota (50 bytes) is less than file size (100 bytes) — skip it + $this->repo->method('findActiveSortedByPriority')->willReturn([$fullPath, $okPath]); + + $stored = $this->manager->store($this->tmpFile, 'photo.jpg'); + + $this->assertSame($okPath->getId()->toRfc4122(), $stored->storagePath->getId()->toRfc4122()); + + unlink($okPath->getBasePath().'/'.$stored->filename); + rmdir($fullPath->getBasePath()); + rmdir($okPath->getBasePath()); + } + + public function test_get_full_path(): void + { + $path = new StoragePath('Main', '/srv/storage', 1_000_000, 10); + $this->assertSame('/srv/storage/photo.jpg', $this->manager->getFullPath($path, 'photo.jpg')); + } +} +``` + +- [ ] **Step 2: Test ausführen — muss fehlschlagen** + +```bash +docker compose run --rm app ./vendor/bin/pest tests/Unit/Application/Storage/LocalStorageManagerTest.php +# Expected: FAIL — classes not found +``` + +- [ ] **Step 3: Interfaces und Value Object implementieren** + +```php +storagePathRepository->findActiveSortedByPriority(); + + foreach ($paths as $storagePath) { + $used = $this->getUsedBytes($storagePath->getBasePath()); + $available = $storagePath->getQuotaBytes() - $used; + + if ($available < $fileSize) { + continue; + } + + $ext = \pathinfo($originalFilename, PATHINFO_EXTENSION); + $filename = Uuid::v7()->toRfc4122().($ext !== '' ? '.'.$ext : ''); + $destination = $storagePath->getBasePath().'/'.$filename; + + if (!\is_dir($storagePath->getBasePath())) { + \mkdir($storagePath->getBasePath(), 0755, true); + } + + \rename($tempPath, $destination); + + return new StoredFile($storagePath, $filename); + } + + throw new \RuntimeException('No active storage path with sufficient quota available'); + } + + public function getFullPath(StoragePath $storagePath, string $filename): string + { + return $storagePath->getBasePath().'/'.$filename; + } + + private function getUsedBytes(string $directory): int + { + if (!\is_dir($directory)) { + return 0; + } + + $total = 0; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), + ); + foreach ($iterator as $file) { + $total += $file->getSize(); + } + + return $total; + } +} +``` + +- [ ] **Step 5: services.yaml erweitern** + +```yaml + App\Application\Storage\StorageManagerInterface: + alias: App\Infrastructure\Storage\LocalStorageManager +``` + +- [ ] **Step 6: Test ausführen — muss bestehen** + +```bash +docker compose run --rm app ./vendor/bin/pest tests/Unit/Application/Storage/LocalStorageManagerTest.php +# Expected: PASS (4 tests) +``` + +- [ ] **Step 7: Commit** + +```bash +git add src/Application/Storage/ src/Infrastructure/Storage/ config/services.yaml tests/Unit/Application/Storage/ +git commit -m "feat: add StorageManager with multi-path quota-aware file storage" +``` + +--- + +## Task 3: ArticleType & AttributeDefinition API + +**Files:** +- Create: `src/Application/Article/ArticleTypeService.php` +- Create: `src/Infrastructure/Http/Controller/Api/ArticleTypeController.php` +- Test: `tests/Unit/Application/Article/ArticleTypeServiceTest.php` + +- [ ] **Step 1: Failing-Test schreiben** + +```php +repo = $this->createMock(ArticleTypeRepositoryInterface::class); + $this->service = new ArticleTypeService($this->repo); + } + + public function test_create_saves_article_type(): void + { + $this->repo->expects($this->once())->method('save'); + + $type = $this->service->create('Notebook'); + + $this->assertSame('Notebook', $type->getName()); + } + + public function test_rename_updates_name(): void + { + $type = new ArticleType('Notebook'); + $this->repo->method('findById')->willReturn($type); + $this->repo->expects($this->once())->method('save'); + + $this->service->rename($type->getId(), 'Laptop'); + + $this->assertSame('Laptop', $type->getName()); + } + + public function test_rename_throws_when_not_found(): void + { + $this->repo->method('findById')->willReturn(null); + + $this->expectException(\DomainException::class); + + $this->service->rename(\Symfony\Component\Uid\Uuid::v7(), 'X'); + } + + public function test_add_attribute_links_definition(): void + { + $type = new ArticleType('Notebook'); + $def = new AttributeDefinition('RAM', AttributeType::String); + $this->repo->method('findById')->willReturn($type); + $this->repo->expects($this->once())->method('save'); + + $this->service->addAttribute($type->getId(), $def); + + $this->assertCount(1, $type->getAttributeDefinitions()); + } +} +``` + +- [ ] **Step 2: Test ausführen — muss fehlschlagen** + +```bash +docker compose run --rm app ./vendor/bin/pest tests/Unit/Application/Article/ArticleTypeServiceTest.php +# Expected: FAIL — class ArticleTypeService not found +``` + +- [ ] **Step 3: ArticleTypeService implementieren** + +```php +articleTypeRepository->save($type); + + return $type; + } + + /** @return list */ + public function findAll(): array + { + return $this->articleTypeRepository->findAll(); + } + + public function findById(Uuid $id): ?ArticleType + { + return $this->articleTypeRepository->findById($id); + } + + public function rename(Uuid $id, string $newName): ArticleType + { + $type = $this->articleTypeRepository->findById($id) + ?? throw new \DomainException("ArticleType {$id->toRfc4122()} not found"); + + $type->setName($newName); + $this->articleTypeRepository->save($type); + + return $type; + } + + public function createAttribute(string $name, AttributeType $type, ?string $unit = null, ?array $options = null): AttributeDefinition + { + $def = new AttributeDefinition($name, $type); + $def->setUnit($unit); + $def->setOptions($options); + + return $def; + } + + public function addAttribute(Uuid $articleTypeId, AttributeDefinition $def): ArticleType + { + $type = $this->articleTypeRepository->findById($articleTypeId) + ?? throw new \DomainException("ArticleType {$articleTypeId->toRfc4122()} not found"); + + $type->addAttributeDefinition($def); + $this->articleTypeRepository->save($type); + + return $type; + } + + public function removeAttribute(Uuid $articleTypeId, Uuid $definitionId): void + { + $type = $this->articleTypeRepository->findById($articleTypeId) + ?? throw new \DomainException("ArticleType {$articleTypeId->toRfc4122()} not found"); + + foreach ($type->getAttributeDefinitions() as $def) { + if ($def->getId()->equals($definitionId)) { + $type->removeAttributeDefinition($def); + break; + } + } + + $this->articleTypeRepository->save($type); + } +} +``` + +- [ ] **Step 4: Test ausführen — muss bestehen** + +```bash +docker compose run --rm app ./vendor/bin/pest tests/Unit/Application/Article/ArticleTypeServiceTest.php +# Expected: PASS (4 tests) +``` + +- [ ] **Step 5: Route-Konfiguration anlegen** + +```yaml +# config/routes/api.yaml +api: + resource: '../src/Infrastructure/Http/Controller/Api/' + type: attribute + prefix: /api +``` + +- [ ] **Step 6: ArticleTypeController implementieren** + +```php +service->findAll(); + + return $this->json(\array_map(static fn ($t) => [ + 'id' => $t->getId()->toRfc4122(), + 'name' => $t->getName(), + 'attributes' => \array_map(static fn ($a) => [ + 'id' => $a->getId()->toRfc4122(), + 'name' => $a->getName(), + 'type' => $a->getType()->value, + 'unit' => $a->getUnit(), + 'options' => $a->getOptions(), + ], $t->getAttributeDefinitions()->toArray()), + ], $types)); + } + + #[Route('', name: 'create', methods: ['POST'])] + public function create(Request $request): JsonResponse + { + $data = $request->toArray(); + if (empty($data['name'])) { + return $this->json(['error' => 'name is required'], Response::HTTP_BAD_REQUEST); + } + + $type = $this->service->create($data['name']); + + return $this->json(['id' => $type->getId()->toRfc4122(), 'name' => $type->getName()], Response::HTTP_CREATED); + } + + #[Route('/{id}', name: 'get', methods: ['GET'])] + public function get(string $id): JsonResponse + { + $type = $this->service->findById(Uuid::fromString($id)); + if (null === $type) { + return $this->json(['error' => 'Not found'], Response::HTTP_NOT_FOUND); + } + + return $this->json([ + 'id' => $type->getId()->toRfc4122(), + 'name' => $type->getName(), + 'attributes' => \array_map(static fn ($a) => [ + 'id' => $a->getId()->toRfc4122(), + 'name' => $a->getName(), + 'type' => $a->getType()->value, + 'unit' => $a->getUnit(), + 'options' => $a->getOptions(), + ], $type->getAttributeDefinitions()->toArray()), + ]); + } + + #[Route('/{id}', name: 'rename', methods: ['PATCH'])] + public function rename(string $id, Request $request): JsonResponse + { + $data = $request->toArray(); + if (empty($data['name'])) { + return $this->json(['error' => 'name is required'], Response::HTTP_BAD_REQUEST); + } + + try { + $type = $this->service->rename(Uuid::fromString($id), $data['name']); + } catch (\DomainException $e) { + return $this->json(['error' => $e->getMessage()], Response::HTTP_NOT_FOUND); + } + + return $this->json(['id' => $type->getId()->toRfc4122(), 'name' => $type->getName()]); + } + + #[Route('/{id}/attributes', name: 'add_attribute', methods: ['POST'])] + public function addAttribute(string $id, Request $request): JsonResponse + { + $data = $request->toArray(); + if (empty($data['name']) || empty($data['type'])) { + return $this->json(['error' => 'name and type are required'], Response::HTTP_BAD_REQUEST); + } + + $attrType = AttributeType::tryFrom($data['type']); + if (null === $attrType) { + $valid = \implode(', ', \array_column(AttributeType::cases(), 'value')); + + return $this->json(['error' => "type must be one of: {$valid}"], Response::HTTP_BAD_REQUEST); + } + + try { + $def = $this->service->createAttribute($data['name'], $attrType, $data['unit'] ?? null, $data['options'] ?? null); + $type = $this->service->addAttribute(Uuid::fromString($id), $def); + } catch (\DomainException $e) { + return $this->json(['error' => $e->getMessage()], Response::HTTP_NOT_FOUND); + } + + return $this->json([ + 'id' => $type->getId()->toRfc4122(), + 'attributes' => \array_map(static fn ($a) => [ + 'id' => $a->getId()->toRfc4122(), + 'name' => $a->getName(), + 'type' => $a->getType()->value, + ], $type->getAttributeDefinitions()->toArray()), + ], Response::HTTP_CREATED); + } + + #[Route('/{id}/attributes/{attrId}', name: 'remove_attribute', methods: ['DELETE'])] + public function removeAttribute(string $id, string $attrId): JsonResponse + { + try { + $this->service->removeAttribute(Uuid::fromString($id), Uuid::fromString($attrId)); + } catch (\DomainException $e) { + return $this->json(['error' => $e->getMessage()], Response::HTTP_NOT_FOUND); + } + + return $this->json(null, Response::HTTP_NO_CONTENT); + } +} +``` + +- [ ] **Step 7: PHPStan + CS Fixer** + +```bash +docker compose run --rm app ./vendor/bin/phpstan analyse src/Application/Article/ src/Infrastructure/Http/ --no-progress +docker compose run --rm app ./vendor/bin/php-cs-fixer fix src/Application/Article/ src/Infrastructure/Http/ --dry-run --diff +``` + +- [ ] **Step 8: Commit** + +```bash +git add src/Application/Article/ArticleTypeService.php src/Infrastructure/Http/Controller/Api/ArticleTypeController.php config/routes/ tests/Unit/Application/Article/ArticleTypeServiceTest.php +git commit -m "feat: add ArticleType + AttributeDefinition REST API" +``` + +--- + +## Task 4: Platform & ChannelField API + +**Files:** +- Create: `src/Application/Channel/PlatformService.php` +- Create: `src/Infrastructure/Http/Controller/Api/PlatformController.php` + +- [ ] **Step 1: PlatformService implementieren** + +```php +platformRepository->save($platform); + + return $platform; + } + + /** @return list */ + public function findAll(): array + { + return $this->platformRepository->findAll(); + } + + public function findById(Uuid $id): ?Platform + { + return $this->platformRepository->findById($id); + } + + public function addChannelField(Uuid $platformId, string $label, string $path): ChannelField + { + $platform = $this->platformRepository->findById($platformId) + ?? throw new \DomainException("Platform {$platformId->toRfc4122()} not found"); + + $field = new ChannelField($platform, $label, $path); + $this->channelFieldRepository->save($field); + + return $field; + } + + /** @return list */ + public function findChannelFields(Uuid $platformId): array + { + return $this->channelFieldRepository->findByPlatform($platformId); + } + + public function removeChannelField(Uuid $fieldId): void + { + $field = $this->channelFieldRepository->findById($fieldId) + ?? throw new \DomainException("ChannelField {$fieldId->toRfc4122()} not found"); + + $this->channelFieldRepository->remove($field); + } +} +``` + +- [ ] **Step 2: PlatformController implementieren** + +```php +json(\array_map(static fn ($p) => [ + 'id' => $p->getId()->toRfc4122(), + 'type' => $p->getType(), + 'label' => $p->getLabel(), + ], $this->service->findAll())); + } + + #[Route('', name: 'create', methods: ['POST'])] + public function create(Request $request): JsonResponse + { + $data = $request->toArray(); + if (empty($data['type']) || empty($data['label'])) { + return $this->json(['error' => 'type and label are required'], Response::HTTP_BAD_REQUEST); + } + + $platform = $this->service->create($data['type'], $data['label'], $data['config'] ?? []); + + return $this->json(['id' => $platform->getId()->toRfc4122(), 'type' => $platform->getType()], Response::HTTP_CREATED); + } + + #[Route('/{id}/channel-fields', name: 'list_fields', methods: ['GET'])] + public function listFields(string $id): JsonResponse + { + $fields = $this->service->findChannelFields(Uuid::fromString($id)); + + return $this->json(\array_map(static fn ($f) => [ + 'id' => $f->getId()->toRfc4122(), + 'label' => $f->getLabel(), + 'path' => $f->getPath(), + ], $fields)); + } + + #[Route('/{id}/channel-fields', name: 'add_field', methods: ['POST'])] + public function addField(string $id, Request $request): JsonResponse + { + $data = $request->toArray(); + if (empty($data['label']) || empty($data['path'])) { + return $this->json(['error' => 'label and path are required'], Response::HTTP_BAD_REQUEST); + } + + try { + $field = $this->service->addChannelField(Uuid::fromString($id), $data['label'], $data['path']); + } catch (\DomainException $e) { + return $this->json(['error' => $e->getMessage()], Response::HTTP_NOT_FOUND); + } + + return $this->json(['id' => $field->getId()->toRfc4122(), 'label' => $field->getLabel()], Response::HTTP_CREATED); + } + + #[Route('/channel-fields/{fieldId}', name: 'remove_field', methods: ['DELETE'])] + public function removeField(string $fieldId): JsonResponse + { + try { + $this->service->removeChannelField(Uuid::fromString($fieldId)); + } catch (\DomainException $e) { + return $this->json(['error' => $e->getMessage()], Response::HTTP_NOT_FOUND); + } + + return $this->json(null, Response::HTTP_NO_CONTENT); + } +} +``` + +- [ ] **Step 3: PHPStan + CS Fixer** + +```bash +docker compose run --rm app ./vendor/bin/phpstan analyse src/Application/Channel/ src/Infrastructure/Http/Controller/Api/PlatformController.php --no-progress +docker compose run --rm app ./vendor/bin/php-cs-fixer fix src/Application/Channel/ src/Infrastructure/Http/Controller/Api/PlatformController.php --dry-run --diff +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/Application/Channel/PlatformService.php src/Infrastructure/Http/Controller/Api/PlatformController.php +git commit -m "feat: add Platform + ChannelField REST API" +``` + +--- + +## Task 5: Mapping API (ArticleTypePlatformConfig + AttributeMapping) + +**Files:** +- Create: `src/Application/Channel/MappingService.php` +- Create: `src/Infrastructure/Http/Controller/Api/MappingController.php` + +- [ ] **Step 1: MappingService implementieren** + +```php +articleTypeRepository->findById($articleTypeId) + ?? throw new \DomainException("ArticleType {$articleTypeId->toRfc4122()} not found"); + + $platform = $this->platformRepository->findById($platformId) + ?? throw new \DomainException("Platform {$platformId->toRfc4122()} not found"); + + $existing = $this->configRepository->findByArticleTypeAndPlatform($articleTypeId, $platformId); + if (null !== $existing) { + throw new \DomainException('Config for this ArticleType+Platform combination already exists'); + } + + $config = new ArticleTypePlatformConfig($articleType, $platform, $categoryId); + $this->configRepository->save($config); + + return $config; + } + + /** @return list */ + public function findConfigsByArticleType(Uuid $articleTypeId): array + { + return $this->configRepository->findByArticleType($articleTypeId); + } + + public function addMapping(Uuid $configId, Uuid $attributeDefinitionId, Uuid $channelFieldId, ?string $transformer = null): AttributeMapping + { + $config = $this->configRepository->findById($configId) + ?? throw new \DomainException("Config {$configId->toRfc4122()} not found"); + + $articleType = $config->getArticleType(); + $defFound = false; + foreach ($articleType->getAttributeDefinitions() as $def) { + if ($def->getId()->equals($attributeDefinitionId)) { + $defFound = true; + $attributeDefinition = $def; + break; + } + } + if (!$defFound) { + throw new \DomainException("AttributeDefinition {$attributeDefinitionId->toRfc4122()} not linked to this ArticleType"); + } + + $channelField = $this->channelFieldRepository->findById($channelFieldId) + ?? throw new \DomainException("ChannelField {$channelFieldId->toRfc4122()} not found"); + + $mapping = new AttributeMapping($config, $attributeDefinition, $channelField); + $mapping->setTransformer($transformer); + $this->mappingRepository->save($mapping); + + return $mapping; + } + + public function removeMapping(Uuid $mappingId): void + { + $mapping = $this->mappingRepository->findById($mappingId) + ?? throw new \DomainException("Mapping {$mappingId->toRfc4122()} not found"); + + $this->mappingRepository->remove($mapping); + } +} +``` + +- [ ] **Step 2: MappingController implementieren** + +```php +service->findConfigsByArticleType(Uuid::fromString($typeId)); + + return $this->json(\array_map(static fn ($c) => [ + 'id' => $c->getId()->toRfc4122(), + 'platform' => ['id' => $c->getPlatform()->getId()->toRfc4122(), 'label' => $c->getPlatform()->getLabel()], + 'categoryId' => $c->getCategoryId(), + 'mappings' => \array_map(static fn ($m) => [ + 'id' => $m->getId()->toRfc4122(), + 'attribute' => ['id' => $m->getAttributeDefinition()->getId()->toRfc4122(), 'name' => $m->getAttributeDefinition()->getName()], + 'channelField' => ['id' => $m->getChannelField()->getId()->toRfc4122(), 'path' => $m->getChannelField()->getPath()], + 'transformer' => $m->getTransformer(), + ], $c->getAttributeMappings()->toArray()), + ], $configs)); + } + + #[Route('', name: 'create_config', methods: ['POST'])] + public function createConfig(string $typeId, Request $request): JsonResponse + { + $data = $request->toArray(); + if (empty($data['platformId']) || empty($data['categoryId'])) { + return $this->json(['error' => 'platformId and categoryId are required'], Response::HTTP_BAD_REQUEST); + } + + try { + $config = $this->service->createConfig(Uuid::fromString($typeId), Uuid::fromString($data['platformId']), $data['categoryId']); + } catch (\DomainException $e) { + return $this->json(['error' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + return $this->json(['id' => $config->getId()->toRfc4122()], Response::HTTP_CREATED); + } + + #[Route('/{configId}/mappings', name: 'add_mapping', methods: ['POST'])] + public function addMapping(string $typeId, string $configId, Request $request): JsonResponse + { + $data = $request->toArray(); + if (empty($data['attributeDefinitionId']) || empty($data['channelFieldId'])) { + return $this->json(['error' => 'attributeDefinitionId and channelFieldId are required'], Response::HTTP_BAD_REQUEST); + } + + try { + $mapping = $this->service->addMapping( + Uuid::fromString($configId), + Uuid::fromString($data['attributeDefinitionId']), + Uuid::fromString($data['channelFieldId']), + $data['transformer'] ?? null, + ); + } catch (\DomainException $e) { + return $this->json(['error' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + return $this->json(['id' => $mapping->getId()->toRfc4122()], Response::HTTP_CREATED); + } + + #[Route('/mappings/{mappingId}', name: 'remove_mapping', methods: ['DELETE'])] + public function removeMapping(string $typeId, string $mappingId): JsonResponse + { + try { + $this->service->removeMapping(Uuid::fromString($mappingId)); + } catch (\DomainException $e) { + return $this->json(['error' => $e->getMessage()], Response::HTTP_NOT_FOUND); + } + + return $this->json(null, Response::HTTP_NO_CONTENT); + } +} +``` + +- [ ] **Step 3: PHPStan + CS Fixer** + +```bash +docker compose run --rm app ./vendor/bin/phpstan analyse src/Application/Channel/MappingService.php src/Infrastructure/Http/Controller/Api/MappingController.php --no-progress +docker compose run --rm app ./vendor/bin/php-cs-fixer fix src/Application/Channel/MappingService.php src/Infrastructure/Http/Controller/Api/MappingController.php --dry-run --diff +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/Application/Channel/MappingService.php src/Infrastructure/Http/Controller/Api/MappingController.php +git commit -m "feat: add ArticleTypePlatformConfig + AttributeMapping REST API" +``` + +--- + +## Task 6: ArticleValidator + +**Files:** +- Create: `src/Application/Article/ArticleValidator.php` +- Test: `tests/Unit/Application/Article/ArticleValidatorTest.php` + +- [ ] **Step 1: Failing-Test schreiben** + +```php +validator = new ArticleValidator(); + $this->type = new ArticleType('Notebook'); + $this->ramDef = new AttributeDefinition('RAM', AttributeType::String); + $this->cpuDef = new AttributeDefinition('CPU', AttributeType::String); + $this->type->addAttributeDefinition($this->ramDef); + $this->type->addAttributeDefinition($this->cpuDef); + } + + public function test_valid_when_all_attributes_set(): void + { + $article = new Article($this->type, 'NB-001', 'INV-001', 1, ArticleCondition::Good); + $article->setAttributeValue(new AttributeValue($article, $this->ramDef, '16 GB')); + $article->setAttributeValue(new AttributeValue($article, $this->cpuDef, 'Intel i7')); + + $missing = $this->validator->getMissingAttributes($article); + + $this->assertEmpty($missing); + $this->assertTrue($this->validator->isValid($article)); + } + + public function test_returns_missing_attribute_names(): void + { + $article = new Article($this->type, 'NB-001', 'INV-001', 1, ArticleCondition::Good); + $article->setAttributeValue(new AttributeValue($article, $this->ramDef, '16 GB')); + // cpuDef not set + + $missing = $this->validator->getMissingAttributes($article); + + $this->assertCount(1, $missing); + $this->assertContains('CPU', $missing); + $this->assertFalse($this->validator->isValid($article)); + } + + public function test_all_missing_when_no_values_set(): void + { + $article = new Article($this->type, 'NB-001', 'INV-001', 1, ArticleCondition::Good); + + $missing = $this->validator->getMissingAttributes($article); + + $this->assertCount(2, $missing); + } +} +``` + +- [ ] **Step 2: Test ausführen — muss fehlschlagen** + +```bash +docker compose run --rm app ./vendor/bin/pest tests/Unit/Application/Article/ArticleValidatorTest.php +# Expected: FAIL +``` + +- [ ] **Step 3: Article-Entity um setAttributeValue erweitern** + +Ergänze `src/Domain/Article/Article.php` — füge Methoden für AttributeValues hinzu: + +```php + /** @var Collection */ + #[ORM\OneToMany(mappedBy: 'article', targetEntity: AttributeValue::class, cascade: ['persist', 'remove'])] + private Collection $attributeValues; + + // In __construct hinzufügen: + $this->attributeValues = new ArrayCollection(); + + // Methoden hinzufügen: + public function setAttributeValue(AttributeValue $value): void + { + foreach ($this->attributeValues as $existing) { + if ($existing->getAttributeDefinition()->getId()->equals($value->getAttributeDefinition()->getId())) { + $this->attributeValues->removeElement($existing); + break; + } + } + $this->attributeValues->add($value); + } + + /** @return Collection */ + public function getAttributeValues(): Collection + { + return $this->attributeValues; + } +``` + +- [ ] **Step 4: AttributeValue-Entity prüfen** + +`src/Domain/Article/AttributeValue.php` benötigt eine Referenz zurück auf das Article-Objekt (für `mappedBy`). Stelle sicher, dass der Konstruktor das Article-Objekt akzeptiert: + +```php +id = Uuid::v7(); + $this->article = $article; + $this->attributeDefinition = $attributeDefinition; + $this->value = $value; + } + + public function getId(): Uuid { return $this->id; } + public function getArticle(): Article { return $this->article; } + public function getAttributeDefinition(): AttributeDefinition { return $this->attributeDefinition; } + public function getValue(): string { return $this->value; } + public function setValue(string $value): void { $this->value = $value; } +} +``` + +- [ ] **Step 5: ArticleValidator implementieren** + +```php + missing attribute names */ + public function getMissingAttributes(Article $article): array + { + $setValue = []; + foreach ($article->getAttributeValues() as $value) { + $setValue[] = $value->getAttributeDefinition()->getId()->toRfc4122(); + } + + $missing = []; + foreach ($article->getArticleType()->getAttributeDefinitions() as $def) { + if (!\in_array($def->getId()->toRfc4122(), $setValue, strict: true)) { + $missing[] = $def->getName(); + } + } + + return $missing; + } + + public function isValid(Article $article): bool + { + return [] === $this->getMissingAttributes($article); + } +} +``` + +- [ ] **Step 6: Tests ausführen** + +```bash +docker compose run --rm app ./vendor/bin/pest tests/Unit/Application/Article/ArticleValidatorTest.php +# Expected: PASS (3 tests) +``` + +- [ ] **Step 7: Commit** + +```bash +git add src/Application/Article/ArticleValidator.php src/Domain/Article/Article.php src/Domain/Article/AttributeValue.php tests/Unit/Application/Article/ArticleValidatorTest.php +git commit -m "feat: add ArticleValidator (required attribute check) + Article.setAttributeValue" +``` + +--- + +## Task 7: Inventory-Sequenz + Article CRUD API + +**Files:** +- Create: `migrations/Version20260513000002.php` +- Create: `src/Application/Article/ArticleService.php` +- Create: `src/Infrastructure/Http/Controller/Api/ArticleController.php` + +- [ ] **Step 1: Migration für Inventory-Sequenz** + +```bash +docker compose run --rm app php bin/console doctrine:migrations:generate +# Öffne die generierte Datei, ersetze den Inhalt: +``` + +```php +addSql('CREATE SEQUENCE IF NOT EXISTS app.inventory_seq START 1 INCREMENT 1'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP SEQUENCE IF EXISTS app.inventory_seq'); + } +} +``` + +```bash +docker compose run --rm app php bin/console doctrine:migrations:migrate --no-interaction +# Expected: migration applied +``` + +- [ ] **Step 2: ArticleService implementieren** + +```php +articleTypeRepository->findById($articleTypeId) + ?? throw new \DomainException("ArticleType {$articleTypeId->toRfc4122()} not found"); + + $inventoryNumber = $this->nextInventoryNumber(); + $sku = 'ART-'.\mb_strtoupper(\substr(\str_replace('-', '', Uuid::v7()->toRfc4122()), 0, 8)); + + $article = new Article($articleType, $sku, $inventoryNumber, $stock, $condition); + $article->setConditionNotes($conditionNotes); + $this->articleRepository->save($article); + + return $article; + } + + public function findById(Uuid $id): ?Article + { + return $this->articleRepository->findById($id); + } + + /** @return list
*/ + public function findByStatus(ArticleStatus $status): array + { + return $this->articleRepository->findByStatus($status); + } + + /** + * @param array $values attribute_definition_id => value + */ + public function updateAttributes(Uuid $articleId, array $values): Article + { + $article = $this->articleRepository->findById($articleId) + ?? throw new \DomainException("Article {$articleId->toRfc4122()} not found"); + + $articleType = $article->getArticleType(); + + foreach ($values as $defIdStr => $value) { + $defId = Uuid::fromString($defIdStr); + $def = null; + foreach ($articleType->getAttributeDefinitions() as $d) { + if ($d->getId()->equals($defId)) { + $def = $d; + break; + } + } + + if (null === $def) { + throw new \DomainException("AttributeDefinition {$defIdStr} not linked to this ArticleType"); + } + + $article->setAttributeValue(new AttributeValue($article, $def, $value)); + } + + $this->articleRepository->save($article); + + return $article; + } + + public function setListingPrice(Uuid $articleId, ?string $price): Article + { + $article = $this->articleRepository->findById($articleId) + ?? throw new \DomainException("Article {$articleId->toRfc4122()} not found"); + + $article->setListingPrice(null !== $price ? (float) $price : null); + $this->articleRepository->save($article); + + return $article; + } + + public function setEbayTexts(Uuid $articleId, ?string $title, ?string $description): Article + { + $article = $this->articleRepository->findById($articleId) + ?? throw new \DomainException("Article {$articleId->toRfc4122()} not found"); + + $article->setEbayTitle($title); + $article->setEbayDescription($description); + $this->articleRepository->save($article); + + return $article; + } + + /** + * Validates all required attributes are set, then transitions draft → active. + * + * @return array{article: Article, missing: list} + */ + public function activate(Uuid $articleId): array + { + $article = $this->articleRepository->findById($articleId) + ?? throw new \DomainException("Article {$articleId->toRfc4122()} not found"); + + $missing = $this->validator->getMissingAttributes($article); + if ([] !== $missing) { + return ['article' => $article, 'missing' => $missing]; + } + + $article->transitionTo(ArticleStatus::Active); + $this->articleRepository->save($article); + + return ['article' => $article, 'missing' => []]; + } + + private function nextInventoryNumber(): string + { + /** @var string $seq */ + $seq = $this->connection->fetchOne("SELECT nextval('app.inventory_seq')"); + + return \sprintf('INV-%s-%05d', \date('Y'), (int) $seq); + } +} +``` + +- [ ] **Step 3: Article-Entity um fehlende Setter erweitern** + +Füge in `src/Domain/Article/Article.php` fehlende Getter/Setter hinzu (falls noch nicht vorhanden aus Plan 1): + +```php + public function setConditionNotes(?string $notes): void { $this->conditionNotes = $notes; } + public function setListingPrice(?float $price): void { $this->listingPrice = $price; } + public function setEbayTitle(?string $title): void { $this->ebayTitle = $title; } + public function setEbayDescription(?string $description): void { $this->ebayDescription = $description; } + public function getArticleType(): ArticleType { return $this->articleType; } + public function getSku(): string { return $this->sku; } + public function getInventoryNumber(): string { return $this->inventoryNumber; } + public function getStatus(): ArticleStatus { return $this->status; } + public function getStock(): int { return $this->stock; } + public function getCondition(): ArticleCondition { return $this->condition; } + public function getConditionNotes(): ?string { return $this->conditionNotes; } + public function getListingPrice(): ?float { return $this->listingPrice; } + public function getSerialNumber(): ?string { return $this->serialNumber; } + public function setSerialNumber(?string $sn): void { $this->serialNumber = $sn; } + public function getEbayListingId(): ?string { return $this->ebayListingId; } + public function setEbayListingId(?string $id): void { $this->ebayListingId = $id; } + public function getEbayTitle(): ?string { return $this->ebayTitle; } + public function getEbayDescription(): ?string { return $this->ebayDescription; } +``` + +- [ ] **Step 4: ArticleController implementieren** + +```php +query->getString('status', 'draft'); + $status = ArticleStatus::tryFrom($statusParam) ?? ArticleStatus::Draft; + + return $this->json(\array_map( + static fn ($a) => [ + 'id' => $a->getId()->toRfc4122(), + 'sku' => $a->getSku(), + 'inventoryNumber' => $a->getInventoryNumber(), + 'status' => $a->getStatus()->value, + 'articleType' => $a->getArticleType()->getName(), + 'condition' => $a->getCondition()->value, + 'stock' => $a->getStock(), + 'listingPrice' => $a->getListingPrice(), + ], + $this->service->findByStatus($status), + )); + } + + #[Route('', name: 'create', methods: ['POST'])] + public function create(Request $request): JsonResponse + { + $data = $request->toArray(); + if (empty($data['articleTypeId']) || empty($data['condition'])) { + return $this->json(['error' => 'articleTypeId and condition are required'], Response::HTTP_BAD_REQUEST); + } + + $condition = ArticleCondition::tryFrom($data['condition']); + if (null === $condition) { + $valid = \implode(', ', \array_column(ArticleCondition::cases(), 'value')); + + return $this->json(['error' => "condition must be one of: {$valid}"], Response::HTTP_BAD_REQUEST); + } + + try { + $article = $this->service->create( + Uuid::fromString($data['articleTypeId']), + $condition, + (int) ($data['stock'] ?? 1), + $data['conditionNotes'] ?? null, + ); + } catch (\DomainException $e) { + return $this->json(['error' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + return $this->json([ + 'id' => $article->getId()->toRfc4122(), + 'sku' => $article->getSku(), + 'inventoryNumber' => $article->getInventoryNumber(), + 'status' => $article->getStatus()->value, + ], Response::HTTP_CREATED); + } + + #[Route('/{id}', name: 'get', methods: ['GET'])] + public function get(string $id): JsonResponse + { + $article = $this->service->findById(Uuid::fromString($id)); + if (null === $article) { + return $this->json(['error' => 'Not found'], Response::HTTP_NOT_FOUND); + } + + return $this->json([ + 'id' => $article->getId()->toRfc4122(), + 'sku' => $article->getSku(), + 'inventoryNumber' => $article->getInventoryNumber(), + 'status' => $article->getStatus()->value, + 'articleType' => ['id' => $article->getArticleType()->getId()->toRfc4122(), 'name' => $article->getArticleType()->getName()], + 'condition' => $article->getCondition()->value, + 'conditionNotes' => $article->getConditionNotes(), + 'stock' => $article->getStock(), + 'listingPrice' => $article->getListingPrice(), + 'serialNumber' => $article->getSerialNumber(), + 'ebayListingId' => $article->getEbayListingId(), + 'ebayTitle' => $article->getEbayTitle(), + 'ebayDescription' => $article->getEbayDescription(), + 'attributes' => \array_map(static fn ($v) => [ + 'definitionId' => $v->getAttributeDefinition()->getId()->toRfc4122(), + 'name' => $v->getAttributeDefinition()->getName(), + 'value' => $v->getValue(), + ], $article->getAttributeValues()->toArray()), + 'photos' => \array_map(static fn ($p) => [ + 'id' => $p->getId()->toRfc4122(), + 'isMain' => $p->isMain(), + 'order' => $p->getSortOrder(), + ], $article->getPhotos()->toArray()), + ]); + } + + #[Route('/{id}/attributes', name: 'update_attributes', methods: ['PATCH'])] + public function updateAttributes(string $id, Request $request): JsonResponse + { + /** @var array $values */ + $values = $request->toArray()['attributes'] ?? []; + if (!\is_array($values)) { + return $this->json(['error' => 'attributes must be an object mapping definitionId => value'], Response::HTTP_BAD_REQUEST); + } + + try { + $article = $this->service->updateAttributes(Uuid::fromString($id), $values); + } catch (\DomainException $e) { + return $this->json(['error' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + return $this->json(['id' => $article->getId()->toRfc4122(), 'status' => $article->getStatus()->value]); + } + + #[Route('/{id}', name: 'update', methods: ['PATCH'])] + public function update(string $id, Request $request): JsonResponse + { + $data = $request->toArray(); + + try { + if (\array_key_exists('listingPrice', $data)) { + $article = $this->service->setListingPrice(Uuid::fromString($id), $data['listingPrice']); + } + if (\array_key_exists('ebayTitle', $data) || \array_key_exists('ebayDescription', $data)) { + $article = $this->service->setEbayTexts( + Uuid::fromString($id), + $data['ebayTitle'] ?? null, + $data['ebayDescription'] ?? null, + ); + } + } catch (\DomainException $e) { + return $this->json(['error' => $e->getMessage()], Response::HTTP_NOT_FOUND); + } + + return $this->json(['id' => $id]); + } + + #[Route('/{id}/activate', name: 'activate', methods: ['POST'])] + public function activate(string $id): JsonResponse + { + try { + $result = $this->service->activate(Uuid::fromString($id)); + } catch (\DomainException $e) { + return $this->json(['error' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + if ([] !== $result['missing']) { + return $this->json([ + 'error' => 'Cannot activate: missing required attributes', + 'missing' => $result['missing'], + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + return $this->json(['id' => $result['article']->getId()->toRfc4122(), 'status' => $result['article']->getStatus()->value]); + } +} +``` + +- [ ] **Step 5: Article-Entity um getPhotos + getId ergänzen (falls fehlend)** + +Stelle sicher `src/Domain/Article/Article.php` hat: + +```php + public function getId(): Uuid { return $this->id; } + + /** @var Collection */ + #[ORM\OneToMany(mappedBy: 'article', targetEntity: ArticlePhoto::class, cascade: ['persist', 'remove'])] + private Collection $photos; + + // In __construct: + $this->photos = new ArrayCollection(); + + /** @return Collection */ + public function getPhotos(): Collection { return $this->photos; } +``` + +- [ ] **Step 6: PHPStan + CS Fixer** + +```bash +docker compose run --rm app ./vendor/bin/phpstan analyse src/ --no-progress +docker compose run --rm app ./vendor/bin/php-cs-fixer fix src/ --dry-run --diff +``` + +- [ ] **Step 7: Commit** + +```bash +git add migrations/ src/Application/Article/ArticleService.php src/Infrastructure/Http/Controller/Api/ArticleController.php src/Domain/Article/Article.php +git commit -m "feat: add Article CRUD API with inventory sequence and activation validation" +``` + +--- + +## Task 8: Photo Upload API + +**Files:** +- Create: `src/Application/Article/PhotoService.php` +- Create: `src/Infrastructure/Http/Controller/Api/PhotoController.php` + +- [ ] **Step 1: ArticlePhoto-Entity prüfen** + +Stelle sicher, dass `src/Domain/Article/ArticlePhoto.php` diese Methoden hat: + +```php +id = Uuid::v7(); + $this->article = $article; + $this->storagePath = $storagePath; + $this->filename = $filename; + } + + public function getId(): Uuid { return $this->id; } + public function getArticle(): Article { return $this->article; } + public function getStoragePath(): StoragePath { return $this->storagePath; } + public function getFilename(): string { return $this->filename; } + public function isMain(): bool { return $this->isMain; } + public function setIsMain(bool $isMain): void { $this->isMain = $isMain; } + public function getSortOrder(): int { return $this->sortOrder; } + public function setSortOrder(int $order): void { $this->sortOrder = $order; } +} +``` + +- [ ] **Step 2: PhotoService implementieren** + +```php +articleRepository->findById($articleId) + ?? throw new \DomainException("Article {$articleId->toRfc4122()} not found"); + + $stored = $this->storageManager->store($tempPath, $originalFilename); + + $existingPhotos = $this->photoRepository->findByArticle($articleId); + $isMain = 0 === \count($existingPhotos); + $sortOrder = \count($existingPhotos); + + $photo = new ArticlePhoto($article, $stored->storagePath, $stored->filename); + $photo->setIsMain($isMain); + $photo->setSortOrder($sortOrder); + + $this->photoRepository->save($photo); + + return $photo; + } + + public function setMain(Uuid $photoId): void + { + $photo = $this->photoRepository->findById($photoId) + ?? throw new \DomainException("Photo {$photoId->toRfc4122()} not found"); + + $allPhotos = $this->photoRepository->findByArticle($photo->getArticle()->getId()); + foreach ($allPhotos as $p) { + $p->setIsMain($p->getId()->equals($photoId)); + $this->photoRepository->save($p); + } + } + + public function delete(Uuid $photoId): void + { + $photo = $this->photoRepository->findById($photoId) + ?? throw new \DomainException("Photo {$photoId->toRfc4122()} not found"); + + $fullPath = $this->storageManager->getFullPath($photo->getStoragePath(), $photo->getFilename()); + if (\file_exists($fullPath)) { + \unlink($fullPath); + } + + if ($photo->isMain()) { + $articleId = $photo->getArticle()->getId(); + $this->photoRepository->remove($photo); + $remaining = $this->photoRepository->findByArticle($articleId); + if ([] !== $remaining) { + $remaining[0]->setIsMain(true); + $this->photoRepository->save($remaining[0]); + } + } else { + $this->photoRepository->remove($photo); + } + } +} +``` + +- [ ] **Step 3: PhotoController implementieren** + +```php +files->get('photo'); + if (null === $file) { + return $this->json(['error' => 'photo file is required (multipart/form-data, field: photo)'], Response::HTTP_BAD_REQUEST); + } + + $allowedMimes = ['image/jpeg', 'image/png', 'image/webp']; + if (!\in_array($file->getMimeType(), $allowedMimes, strict: true)) { + return $this->json(['error' => 'Only JPEG, PNG and WebP images are allowed'], Response::HTTP_BAD_REQUEST); + } + + try { + $photo = $this->service->upload( + Uuid::fromString($articleId), + $file->getPathname(), + $file->getClientOriginalName(), + ); + } catch (\DomainException|\RuntimeException $e) { + return $this->json(['error' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + return $this->json([ + 'id' => $photo->getId()->toRfc4122(), + 'isMain' => $photo->isMain(), + 'sortOrder' => $photo->getSortOrder(), + ], Response::HTTP_CREATED); + } + + #[Route('/{photoId}/main', name: 'set_main', methods: ['PATCH'])] + public function setMain(string $articleId, string $photoId): JsonResponse + { + try { + $this->service->setMain(Uuid::fromString($photoId)); + } catch (\DomainException $e) { + return $this->json(['error' => $e->getMessage()], Response::HTTP_NOT_FOUND); + } + + return $this->json(null, Response::HTTP_NO_CONTENT); + } + + #[Route('/{photoId}', name: 'delete', methods: ['DELETE'])] + public function delete(string $articleId, string $photoId): JsonResponse + { + try { + $this->service->delete(Uuid::fromString($photoId)); + } catch (\DomainException $e) { + return $this->json(['error' => $e->getMessage()], Response::HTTP_NOT_FOUND); + } + + return $this->json(null, Response::HTTP_NO_CONTENT); + } +} +``` + +- [ ] **Step 4: PHPStan + CS Fixer** + +```bash +docker compose run --rm app ./vendor/bin/phpstan analyse src/ --no-progress +docker compose run --rm app ./vendor/bin/php-cs-fixer fix src/ --dry-run --diff +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/Application/Article/PhotoService.php src/Infrastructure/Http/Controller/Api/PhotoController.php src/Domain/Article/ArticlePhoto.php +git commit -m "feat: add photo upload API (multipart, StorageManager-backed, auto-selects main)" +``` + +--- + +## Selbstreview + +**Spec-Abdeckung:** +- ArticleType CRUD ✓ (Task 3) +- AttributeDefinition CRUD ✓ (Task 3) +- Platform + ChannelField ✓ (Task 4) +- ArticleTypePlatformConfig + AttributeMapping ✓ (Task 5) +- Pflichtfeld-Validierung ✓ (Task 6) +- Artikel CRUD ✓ (Task 7) +- StorageManager Multi-Path ✓ (Task 2) +- Foto-Upload + Main-Foto ✓ (Task 8) + +**Noch nicht in diesem Plan:** +- Auth/ACL → Plan 3 +- AI-Pipeline-Trigger → Plan 4 +- eBay-Publish bei Aktivierung → Plan 5 +- EasyAdmin → Plan 3 diff --git a/docs/superpowers/plans/2026-05-13-03-auth-logging.md b/docs/superpowers/plans/2026-05-13-03-auth-logging.md new file mode 100644 index 0000000..f978fe4 --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-03-auth-logging.md @@ -0,0 +1,1787 @@ +# SuperSeller3000 — Plan 3: Auth, ACL & Logging + +> **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:** Vollständige Auth-Infrastruktur (Browser-Login mit TOTP-2FA, API-Key-Authentifizierung), Permission-basiertes ACL, EasyAdmin-Adminpanel mit Artikel-Freigabe-Workflow, PostgreSQL-basiertes Logging mit Rotation und täglichem Backup-Command. + +**Architecture:** Symfony Security mit zwei Firewalls (api / main). ApiKey-Authenticator liest `X-Api-Key`-Header, verifiziert gegen bcrypt-Hash nach Prefix-Lookup. PermissionVoter prüft User- und ApiKey-Permissions einheitlich. Monolog DatabaseLogHandler schreibt asynchron in `logs.log_entry`. EasyAdmin für Admin-UI. + +**Tech Stack:** PHP 8.4, Symfony 7, scheb/2fa-bundle, scheb/2fa-totp, easycorp/easyadmin-bundle, Monolog, PHPStan Level 9 + +--- + +## Dateistruktur (gesamter Plan) + +``` +src/ + Infrastructure/ + Http/ + Security/ + ApiKeyAuthenticator.php + Controller/ + SecurityController.php # Login, Logout, 2FA + TotpSetupController.php # TOTP einrichten + Admin/ + DashboardController.php + ArticleCrudController.php # Freigabe-Workflow + ArticleTypeCrudController.php + PlatformCrudController.php + UserCrudController.php + AIPipelineJobCrudController.php + LogEntryCrudController.php + Logging/ + DatabaseLogHandler.php + LogEntry.php # Doctrine entity → logs.log_entry + RotateLogsCommand.php + Security/ + PermissionVoter.php + Command/ + BackupCommand.php +config/ + packages/ + security.yaml + scheb_two_factor.yaml + monolog.yaml # ergänzen + routes/ + security.yaml + admin.yaml +migrations/ + Version20260513000003.php # ApiKey.key_prefix column +templates/ + security/ + login.html.twig + 2fa.html.twig + totp/ + setup.html.twig +tests/ + Unit/ + Infrastructure/ + Security/ + PermissionVoterTest.php + Logging/ + DatabaseLogHandlerTest.php +``` + +--- + +## Task 1: Pakete installieren + Security-Grundkonfiguration + +**Files:** +- Create: `config/packages/security.yaml` +- Create: `config/routes/security.yaml` +- Create: `templates/security/login.html.twig` +- Create: `templates/security/2fa.html.twig` + +- [ ] **Step 1: Pakete installieren** + +```bash +docker compose run --rm app composer require \ + scheb/2fa-bundle \ + scheb/2fa-totp \ + scheb/2fa-backup-code \ + easycorp/easyadmin-bundle \ + endroid/qr-code \ + endroid/qr-code-bundle + +docker compose run --rm app composer require --dev \ + symfony/browser-kit \ + symfony/css-selector +``` + +- [ ] **Step 2: security.yaml schreiben** + +```yaml +# config/packages/security.yaml +security: + password_hashers: + App\Domain\Auth\User: + algorithm: bcrypt + cost: 12 + + providers: + user_provider: + entity: + class: App\Domain\Auth\User + property: email + + firewalls: + dev: + pattern: ^/(_(profiler|wdt)|css|images|js)/ + security: false + + api: + pattern: ^/api/ + stateless: true + custom_authenticators: + - App\Infrastructure\Http\Security\ApiKeyAuthenticator + + main: + lazy: true + provider: user_provider + form_login: + login_path: app_login + check_path: app_login + default_target_path: /admin + enable_csrf: true + logout: + path: app_logout + target: app_login + two_factor: + auth_form_path: 2fa_login + check_path: 2fa_login_check + + access_control: + - { path: ^/login, roles: PUBLIC_ACCESS } + - { path: ^/2fa, roles: IS_AUTHENTICATED_2FA_IN_PROGRESS } + - { path: ^/api/, roles: IS_AUTHENTICATED_FULLY } + - { path: ^/admin, roles: ROLE_USER } +``` + +- [ ] **Step 3: Scheb 2FA konfigurieren** + +```yaml +# config/packages/scheb_two_factor.yaml +scheb_two_factor: + security_tokens: + - Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken + ip_whitelist: [] + totp: + enabled: true + issuer: SuperSeller3000 + digits: 6 + period: 30 + algorithm: sha1 + backup_codes: + enabled: true + trusted_device: + enabled: false + two_factor_finish_url: /admin + login_check_path: app_login + auth_code_parameter_name: _auth_code + trusted_parameter_name: _trusted +``` + +- [ ] **Step 4: Route-Konfiguration** + +```yaml +# config/routes/security.yaml +app_login: + path: /login + controller: App\Infrastructure\Http\Controller\SecurityController::login + +app_logout: + path: /logout + methods: [POST] + +2fa_login: + path: /2fa + controller: scheb_two_factor.form_renderer::renderForm + +2fa_login_check: + path: /2fa_check +``` + +- [ ] **Step 5: Login-Template** + +```twig +{# templates/security/login.html.twig #} + + + + + SuperSeller3000 — Login + + + +
+

SuperSeller3000

+ {% if error %} +
{{ error.messageKey|trans(error.messageData, 'security') }}
+ {% endif %} +
+ + + + +
+
+ + +``` + +- [ ] **Step 6: 2FA-Template** + +```twig +{# templates/security/2fa.html.twig #} + + + + + SuperSeller3000 — 2FA + + + +
+

Zwei-Faktor-Auth

+

Code aus deiner Authenticator-App eingeben.

+ {% if authenticationError %} +
{{ authenticationError.messageKey|trans(authenticationError.messageData, 'security') }}
+ {% endif %} +
+ + +
+
+ + +``` + +- [ ] **Step 7: SecurityController** + +```php +getUser()) { + return $this->redirectToRoute('admin'); + } + + return $this->render('security/login.html.twig', [ + 'last_username' => $authUtils->getLastUsername(), + 'error' => $authUtils->getLastAuthenticationError(), + ]); + } +} +``` + +- [ ] **Step 8: Commit** + +```bash +git add config/packages/security.yaml config/packages/scheb_two_factor.yaml config/routes/security.yaml templates/security/ src/Infrastructure/Http/Controller/SecurityController.php composer.json composer.lock +git commit -m "feat: add Symfony Security, form login, 2FA config, login templates" +``` + +--- + +## Task 2: ApiKey-Migration + Authenticator + +**Files:** +- Create: `migrations/Version20260513000003.php` +- Create: `src/Infrastructure/Http/Security/ApiKeyAuthenticator.php` +- Modify: `src/Domain/Auth/ApiKey.php` + +- [ ] **Step 1: ApiKey-Entity um key_prefix erweitern** + +Ergänze `src/Domain/Auth/ApiKey.php`: + +```php + #[ORM\Column(type: 'string', length: 16)] + private string $keyPrefix; + + // In __construct hinzufügen (nach $keyHash): + $this->keyPrefix = \substr($rawKey, 0, 16); + + // Neuer Konstruktor-Parameter: + public function __construct(User $user, string $label, string $rawKey, string $keyHash) + { + $this->id = Uuid::v7(); + $this->user = $user; + $this->label = $label; + $this->keyPrefix = \substr($rawKey, 0, 16); + $this->keyHash = $keyHash; + } + + public function getKeyPrefix(): string { return $this->keyPrefix; } +``` + +- [ ] **Step 2: Migration schreiben** + +```bash +docker compose run --rm app php bin/console doctrine:migrations:generate +``` + +```php +addSql("ALTER TABLE app.api_keys ADD COLUMN key_prefix VARCHAR(16) NOT NULL DEFAULT ''"); + $this->addSql('CREATE INDEX idx_api_keys_prefix ON app.api_keys (key_prefix)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX idx_api_keys_prefix'); + $this->addSql('ALTER TABLE app.api_keys DROP COLUMN key_prefix'); + } +} +``` + +```bash +docker compose run --rm app php bin/console doctrine:migrations:migrate --no-interaction +``` + +- [ ] **Step 3: ApiKey-Repository-Interface erweitern** + +Erstelle `src/Domain/Auth/Repository/ApiKeyRepositoryInterface.php`: + +```php + active keys with this prefix */ + public function findActiveByPrefix(string $prefix): array; + + public function findById(Uuid $id): ?ApiKey; + + public function save(ApiKey $apiKey): void; + + public function remove(ApiKey $apiKey): void; +} +``` + +Erstelle `src/Infrastructure/Persistence/Repository/DoctrineApiKeyRepository.php`: + +```php + */ + public function findActiveByPrefix(string $prefix): array + { + /** @var list */ + return $this->em->getRepository(ApiKey::class) + ->createQueryBuilder('k') + ->where('k.keyPrefix = :prefix') + ->andWhere('k.isActive = :active') + ->setParameter('prefix', $prefix) + ->setParameter('active', true) + ->getQuery() + ->getResult(); + } + + public function findById(Uuid $id): ?ApiKey + { + return $this->em->find(ApiKey::class, $id); + } + + public function save(ApiKey $apiKey): void + { + $this->em->persist($apiKey); + $this->em->flush(); + } + + public function remove(ApiKey $apiKey): void + { + $this->em->remove($apiKey); + $this->em->flush(); + } +} +``` + +Ergänze `config/services.yaml`: + +```yaml + App\Domain\Auth\Repository\ApiKeyRepositoryInterface: + alias: App\Infrastructure\Persistence\Repository\DoctrineApiKeyRepository +``` + +- [ ] **Step 4: ApiKeyAuthenticator implementieren** + +```php +headers->has('X-Api-Key'); + } + + public function authenticate(Request $request): Passport + { + $rawKey = $request->headers->get('X-Api-Key', ''); + if (\strlen($rawKey) < 16) { + throw new CustomUserMessageAuthenticationException('Invalid API key format'); + } + + $prefix = \substr($rawKey, 0, 16); + $candidates = $this->apiKeyRepository->findActiveByPrefix($prefix); + + foreach ($candidates as $apiKey) { + if (\password_verify($rawKey, $apiKey->getKeyHash())) { + if ($apiKey->isExpired()) { + throw new CustomUserMessageAuthenticationException('API key has expired'); + } + $apiKey->markUsed(); + $this->apiKeyRepository->save($apiKey); + + return new SelfValidatingPassport( + new UserBadge($apiKey->getUser()->getUserIdentifier()), + ); + } + } + + throw new CustomUserMessageAuthenticationException('Invalid API key'); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + return null; + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + return new JsonResponse(['error' => $exception->getMessageKey()], Response::HTTP_UNAUTHORIZED); + } +} +``` + +- [ ] **Step 5: Commit** + +```bash +git add migrations/ src/Domain/Auth/ src/Infrastructure/Http/Security/ src/Infrastructure/Persistence/Repository/DoctrineApiKeyRepository.php config/services.yaml +git commit -m "feat: add ApiKey prefix-based authenticator with bcrypt verification" +``` + +--- + +## Task 3: TOTP 2FA Setup + +**Files:** +- Create: `src/Infrastructure/Http/Controller/TotpSetupController.php` +- Create: `templates/totp/setup.html.twig` + +- [ ] **Step 1: User für 2FA konfigurieren** + +Die `User`-Entity muss `TwoFactorInterface` implementieren. Ergänze `src/Domain/Auth/User.php`: + +```php +use Scheb\TwoFactorBundle\Model\Totp\TotpConfiguration; +use Scheb\TwoFactorBundle\Model\Totp\TotpConfigurationInterface; +use Scheb\TwoFactorBundle\Model\Totp\TwoFactorInterface; + +// Implements-Liste um TwoFactorInterface erweitern: +class User implements UserInterface, PasswordAuthenticatedUserInterface, TwoFactorInterface +{ + // Neue Methoden: + public function isTotpAuthenticationEnabled(): bool + { + return null !== $this->totpSecret; + } + + public function getTotpAuthenticationUsername(): string + { + return $this->email; + } + + public function getTotpAuthenticationConfiguration(): ?TotpConfigurationInterface + { + if (null === $this->totpSecret) { + return null; + } + + return new TotpConfiguration($this->totpSecret, TotpConfiguration::ALGORITHM_SHA1, 30, 6); + } +} +``` + +- [ ] **Step 2: TotpSetupController** + +```php +getUser(); + + if (null === $user->getTotpSecret()) { + $secret = $this->totpAuthenticator->generateSecret(); + $user->setTotpSecret($secret); + $this->userRepository->save($user); + } + + $qrContent = $this->totpAuthenticator->getQRContent($user); + $qrCode = Builder::create() + ->writer(new PngWriter()) + ->data($qrContent) + ->encoding(new Encoding('UTF-8')) + ->errorCorrectionLevel(ErrorCorrectionLevel::High) + ->size(200) + ->margin(10) + ->build(); + + return $this->render('totp/setup.html.twig', [ + 'qrCodeDataUri' => $qrCode->getDataUri(), + 'secret' => $user->getTotpSecret(), + 'enabled' => $user->isTotpAuthenticationEnabled(), + ]); + } + + #[Route('/disable', name: 'disable', methods: ['POST'])] + public function disable(Request $request): Response + { + if (!$this->isCsrfTokenValid('totp_disable', $request->request->getString('_token'))) { + throw $this->createAccessDeniedException('Invalid CSRF token'); + } + + /** @var User $user */ + $user = $this->getUser(); + $user->setTotpSecret(null); + $this->userRepository->save($user); + + $this->addFlash('success', '2FA deaktiviert.'); + + return $this->redirectToRoute('totp_setup'); + } +} +``` + +- [ ] **Step 3: UserRepositoryInterface ergänzen** + +Erstelle `src/Domain/Auth/Repository/UserRepositoryInterface.php`: + +```php + */ + public function findAll(): array; + + public function save(User $user): void; + + public function remove(User $user): void; +} +``` + +Erstelle `src/Infrastructure/Persistence/Repository/DoctrineUserRepository.php`: + +```php +em->find(User::class, $id); + } + + public function findByEmail(string $email): ?User + { + return $this->em->getRepository(User::class)->findOneBy(['email' => $email]); + } + + /** @return list */ + public function findAll(): array + { + /** @var list */ + return $this->em->getRepository(User::class)->findAll(); + } + + public function save(User $user): void + { + $this->em->persist($user); + $this->em->flush(); + } + + public function remove(User $user): void + { + $this->em->remove($user); + $this->em->flush(); + } +} +``` + +Ergänze `config/services.yaml`: + +```yaml + App\Domain\Auth\Repository\UserRepositoryInterface: + alias: App\Infrastructure\Persistence\Repository\DoctrineUserRepository +``` + +- [ ] **Step 4: TOTP-Setup-Template** + +```twig +{# templates/totp/setup.html.twig #} + + +2FA Setup + + + +
+

Zwei-Faktor-Authentifizierung

+ {% if enabled %} +

✓ 2FA ist aktiv.

+
+ + +
+ {% else %} +

2FA ist noch nicht eingerichtet.

+

Scanne den QR-Code mit deiner Authenticator-App (Google Authenticator, Authy, …):

+ QR Code + Manueller Code: {{ secret }} +

Nach dem Scannen: beim nächsten Login wirst du nach dem Code gefragt.

+ {% endif %} +
+ + +``` + +- [ ] **Step 5: Route ergänzen** + +```yaml +# config/routes/security.yaml (ergänzen) +totp_setup: + path: /totp/setup + controller: App\Infrastructure\Http\Controller\TotpSetupController::setup + +totp_disable: + path: /totp/disable + controller: App\Infrastructure\Http\Controller\TotpSetupController::disable + methods: [POST] +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/Domain/Auth/ src/Infrastructure/Http/Controller/TotpSetupController.php src/Infrastructure/Persistence/Repository/DoctrineUserRepository.php templates/totp/ config/routes/security.yaml config/services.yaml +git commit -m "feat: add TOTP 2FA setup, UserRepository" +``` + +--- + +## Task 4: PermissionVoter + +**Files:** +- Create: `src/Infrastructure/Security/PermissionVoter.php` +- Test: `tests/Unit/Infrastructure/Security/PermissionVoterTest.php` + +- [ ] **Step 1: Failing-Test schreiben** + +```php +voter = new PermissionVoter(); + } + + private function tokenFor(User $user): UsernamePasswordToken + { + return new UsernamePasswordToken($user, 'main', $user->getRoles()); + } + + public function test_grants_permission_to_user_with_it(): void + { + $user = new User('test@example.com', 'hash'); + $user->grantPermission('article:view'); + + $result = $this->voter->vote($this->tokenFor($user), null, ['PERMISSION_article:view']); + + $this->assertSame(VoterInterface::ACCESS_GRANTED, $result); + } + + public function test_denies_permission_user_lacks(): void + { + $user = new User('test@example.com', 'hash'); + + $result = $this->voter->vote($this->tokenFor($user), null, ['PERMISSION_order:delete']); + + $this->assertSame(VoterInterface::ACCESS_DENIED, $result); + } + + public function test_abstains_for_non_permission_attribute(): void + { + $user = new User('test@example.com', 'hash'); + + $result = $this->voter->vote($this->tokenFor($user), null, ['ROLE_ADMIN']); + + $this->assertSame(VoterInterface::ACCESS_ABSTAIN, $result); + } +} +``` + +- [ ] **Step 2: Test ausführen — muss fehlschlagen** + +```bash +docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Security/PermissionVoterTest.php +# Expected: FAIL +``` + +- [ ] **Step 3: PermissionVoter implementieren** + +```php +denyAccessUnlessGranted('PERMISSION_article:view') + * + * @extends Voter + */ +final class PermissionVoter extends Voter +{ + private const PREFIX = 'PERMISSION_'; + + protected function supports(string $attribute, mixed $subject): bool + { + return \str_starts_with($attribute, self::PREFIX); + } + + protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool + { + $user = $token->getUser(); + if (!$user instanceof User) { + return false; + } + + $permission = \substr($attribute, \strlen(self::PREFIX)); + + return $user->hasPermission($permission); + } +} +``` + +- [ ] **Step 4: Test ausführen** + +```bash +docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Security/PermissionVoterTest.php +# Expected: PASS (3 tests) +``` + +- [ ] **Step 5: PHPStan + Commit** + +```bash +docker compose run --rm app ./vendor/bin/phpstan analyse src/Infrastructure/Security/ --no-progress + +git add src/Infrastructure/Security/PermissionVoter.php tests/Unit/Infrastructure/Security/PermissionVoterTest.php +git commit -m "feat: add PermissionVoter (PERMISSION_* attribute → User.hasPermission)" +``` + +--- + +## Task 5: EasyAdmin Setup + +**Files:** +- Create: `src/Infrastructure/Http/Admin/DashboardController.php` +- Create: `src/Infrastructure/Http/Admin/ArticleCrudController.php` +- Create: `src/Infrastructure/Http/Admin/ArticleTypeCrudController.php` +- Create: `src/Infrastructure/Http/Admin/UserCrudController.php` +- Create: `src/Infrastructure/Http/Admin/AIPipelineJobCrudController.php` +- Create: `config/routes/admin.yaml` + +- [ ] **Step 1: DashboardController** + +```php +render('@EasyAdmin/page/content.html.twig'); + } + + public function configureDashboard(): Dashboard + { + return Dashboard::new()->setTitle('SuperSeller3000'); + } + + public function configureMenuItems(): iterable + { + yield MenuItem::linkToDashboard('Dashboard', 'fa fa-home'); + yield MenuItem::section('Artikel'); + yield MenuItem::linkToCrud('Artikel', 'fa fa-box', \App\Domain\Article\Article::class); + yield MenuItem::linkToCrud('Artikel-Typen', 'fa fa-tags', \App\Domain\Article\ArticleType::class); + yield MenuItem::section('Plattformen'); + yield MenuItem::linkToCrud('Plattformen', 'fa fa-plug', \App\Domain\Channel\Platform::class); + yield MenuItem::section('Monitoring'); + yield MenuItem::linkToCrud('KI-Jobs', 'fa fa-robot', \App\Domain\Pipeline\AIPipelineJob::class); + yield MenuItem::linkToCrud('Logs', 'fa fa-list', \App\Infrastructure\Logging\LogEntry::class); + yield MenuItem::section('Administration'); + yield MenuItem::linkToCrud('Benutzer', 'fa fa-users', \App\Domain\Auth\User::class); + } +} +``` + +- [ ] **Step 2: ArticleCrudController (Freigabe-Workflow)** + +```php +setEntityLabelInSingular('Artikel') + ->setEntityLabelInPlural('Artikel') + ->setDefaultSort(['status' => 'ASC']); + } + + public function configureFields(string $pageName): iterable + { + yield TextField::new('sku', 'SKU'); + yield TextField::new('inventoryNumber', 'Inventar-Nr.'); + yield ChoiceField::new('status', 'Status')->setChoices(ArticleStatus::class); + yield TextField::new('articleType.name', 'Typ'); + yield ChoiceField::new('condition', 'Zustand'); + yield MoneyField::new('listingPrice', 'Preis')->setCurrency('EUR'); + yield DateTimeField::new('createdAt', 'Erstellt')->onlyOnIndex(); + } + + public function configureFilters(Filters $filters): Filters + { + return $filters->add(ChoiceFilter::new('status')->setChoices(\array_column(ArticleStatus::cases(), 'value', 'value'))); + } + + public function configureActions(Actions $actions): Actions + { + $activate = Action::new('activate', 'Freigeben', 'fa fa-check') + ->linkToRoute('admin_article_activate', fn (Article $a) => ['id' => $a->getId()->toRfc4122()]) + ->displayIf(static fn (Article $a) => $a->getStatus() === ArticleStatus::Draft); + + return $actions + ->add(Crud::PAGE_INDEX, $activate) + ->disable(Action::DELETE); + } + + #[Route('/admin/articles/{id}/activate', name: 'admin_article_activate')] + public function activateAction(string $id, Request $request): RedirectResponse + { + $result = $this->articleService->activate(\Symfony\Component\Uid\Uuid::fromString($id)); + + if ([] !== $result['missing']) { + $this->addFlash('danger', 'Fehlende Pflichtfelder: '.\implode(', ', $result['missing'])); + } else { + $this->addFlash('success', 'Artikel freigegeben.'); + } + + $url = $this->adminUrlGenerator->setController(self::class)->setAction(Action::INDEX)->generateUrl(); + + return $this->redirect($url); + } +} +``` + +- [ ] **Step 3: Weitere CRUDs (ArticleType, User, AIPipelineJob)** + +```php +disable(Action::DELETE); + } +} +``` + +```php +setDefaultSort(['createdAt' => 'DESC']); + } + + public function configureFields(string $pageName): iterable + { + yield ChoiceField::new('type', 'Typ'); + yield ChoiceField::new('status', 'Status'); + yield IntegerField::new('attemptCount', 'Versuche'); + yield TextField::new('errorMessage', 'Fehler')->onlyOnDetail(); + yield DateTimeField::new('createdAt', 'Erstellt'); + yield DateTimeField::new('completedAt', 'Abgeschlossen'); + } + + public function configureActions(Actions $actions): Actions + { + return $actions->disable(Action::NEW, Action::EDIT, Action::DELETE); + } +} +``` + +- [ ] **Step 4: PHPStan + Commit** + +```bash +docker compose run --rm app ./vendor/bin/phpstan analyse src/Infrastructure/Http/Admin/ --no-progress +docker compose run --rm app ./vendor/bin/php-cs-fixer fix src/Infrastructure/Http/Admin/ --dry-run --diff + +git add src/Infrastructure/Http/Admin/ config/routes/ +git commit -m "feat: add EasyAdmin dashboard with Article approval workflow, ArticleType, User, AIPipelineJob CRUDs" +``` + +--- + +## Task 6: DatabaseLogHandler + LogEntry + +**Files:** +- Create: `src/Infrastructure/Logging/LogEntry.php` +- Create: `src/Infrastructure/Logging/DatabaseLogHandler.php` +- Test: `tests/Unit/Logging/DatabaseLogHandlerTest.php` + +- [ ] **Step 1: LogEntry-Entity erstellen** + +Plan 1 hat die Tabellen `logs.log_entry` und `logs_archive.log_entry` bereits angelegt. Jetzt erstellen wir die Doctrine-Entity für die aktive Tabelle: + +```php + */ + #[ORM\Column(type: 'json')] + private array $context = []; + + // message_search is a GENERATED ALWAYS column — never written from PHP + #[ORM\Column(type: 'string', insertable: false, updatable: false, nullable: true)] + private ?string $messageSearch = null; + + #[ORM\Column(type: 'datetime_immutable')] + private \DateTimeImmutable $createdAt; + + public function __construct(string $level, string $channel, string $message, array $context = []) + { + $this->id = Uuid::v7(); + $this->level = $level; + $this->channel = $channel; + $this->message = $message; + $this->context = $context; + $this->createdAt = new \DateTimeImmutable(); + } + + public function getId(): Uuid { return $this->id; } + public function getLevel(): string { return $this->level; } + public function getChannel(): string { return $this->channel; } + public function getMessage(): string { return $this->message; } + /** @return array */ + public function getContext(): array { return $this->context; } + public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; } +} +``` + +- [ ] **Step 2: Failing-Test schreiben** + +```php +connection = $this->createMock(Connection::class); + $this->handler = new DatabaseLogHandler($this->connection); + } + + public function test_writes_record_to_database(): void + { + $this->connection->expects($this->once()) + ->method('insert') + ->with( + 'logs.log_entry', + $this->callback(static fn (array $data) => + $data['level'] === 'ERROR' && + $data['channel'] === 'app' && + $data['message'] === 'Something went wrong' && + isset($data['id']) && + isset($data['created_at']) + ), + ); + + $record = new LogRecord( + datetime: new \DateTimeImmutable(), + channel: 'app', + level: Level::Error, + message: 'Something went wrong', + context: [], + extra: [], + ); + + $this->handler->handle($record); + } + + public function test_handles_below_min_level_by_ignoring(): void + { + $this->connection->expects($this->never())->method('insert'); + + $record = new LogRecord( + datetime: new \DateTimeImmutable(), + channel: 'app', + level: Level::Debug, + message: 'Debug message', + context: [], + extra: [], + ); + + $this->handler->handle($record); + } +} +``` + +- [ ] **Step 3: Test ausführen — muss fehlschlagen** + +```bash +docker compose run --rm app ./vendor/bin/pest tests/Unit/Logging/DatabaseLogHandlerTest.php +# Expected: FAIL +``` + +- [ ] **Step 4: DatabaseLogHandler implementieren** + +```php +connection->insert('logs.log_entry', [ + 'id' => Uuid::v7()->toRfc4122(), + 'level' => $record->level->name, + 'channel' => $record->channel, + 'message' => $record->message, + 'context' => \json_encode($record->context, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + 'created_at' => $record->datetime->format('Y-m-d H:i:s.u'), + ]); + } catch (\Throwable) { + // Never let logging break the application + } + } +} +``` + +- [ ] **Step 5: Monolog konfigurieren** + +```yaml +# config/packages/monolog.yaml (ergänzen, unter handlers:) + database: + type: service + id: App\Infrastructure\Logging\DatabaseLogHandler +``` + +Für Prod (`config/packages/prod/monolog.yaml`): + +```yaml +monolog: + handlers: + main: + type: fingers_crossed + action_level: error + handler: nested + excluded_http_codes: [404, 405] + nested: + type: rotating_file + path: '%kernel.logs_dir%/%kernel.environment%.log' + level: debug + max_files: 7 + database: + type: service + id: App\Infrastructure\Logging\DatabaseLogHandler + level: info +``` + +- [ ] **Step 6: Test ausführen** + +```bash +docker compose run --rm app ./vendor/bin/pest tests/Unit/Logging/DatabaseLogHandlerTest.php +# Expected: PASS (2 tests) +``` + +- [ ] **Step 7: EasyAdmin LogEntry CRUD ergänzen** + +```php +setDefaultSort(['createdAt' => 'DESC']); + } + + public function configureFields(string $pageName): iterable + { + yield DateTimeField::new('createdAt', 'Zeit'); + yield ChoiceField::new('level', 'Level')->setChoices(['DEBUG' => 'DEBUG', 'INFO' => 'INFO', 'WARNING' => 'WARNING', 'ERROR' => 'ERROR', 'CRITICAL' => 'CRITICAL']); + yield TextField::new('channel', 'Channel'); + yield TextField::new('message', 'Meldung'); + } + + public function configureActions(Actions $actions): Actions + { + return $actions->disable(Action::NEW, Action::EDIT); + } +} +``` + +- [ ] **Step 8: Commit** + +```bash +git add src/Infrastructure/Logging/ tests/Unit/Logging/ config/packages/monolog.yaml +git commit -m "feat: add DatabaseLogHandler, LogEntry entity, EasyAdmin log viewer" +``` + +--- + +## Task 7: Log-Rotations-Command + +**Files:** +- Create: `src/Infrastructure/Logging/RotateLogsCommand.php` + +- [ ] **Step 1: RotateLogsCommand implementieren** + +```php + DEBUG), löscht alle alten Einträge')] +final class RotateLogsCommand extends Command +{ + public function __construct(private readonly Connection $connection) + { + parent::__construct(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $cutoff = (new \DateTimeImmutable('-90 days'))->format('Y-m-d H:i:s'); + + // Archive non-DEBUG entries older than 90 days + $archived = $this->connection->executeStatement( + "INSERT INTO logs_archive.log_entry (id, level, channel, message, context, created_at) + SELECT id, level, channel, message, context, created_at + FROM logs.log_entry + WHERE created_at < :cutoff AND level != 'DEBUG' + ON CONFLICT (id) DO NOTHING", + ['cutoff' => $cutoff], + ); + + // Delete all entries older than 90 days from active log + $deleted = $this->connection->executeStatement( + 'DELETE FROM logs.log_entry WHERE created_at < :cutoff', + ['cutoff' => $cutoff], + ); + + $io->success("Archiviert: {$archived} Einträge. Gelöscht: {$deleted} Einträge."); + + return Command::SUCCESS; + } +} +``` + +- [ ] **Step 2: Cron-Eintrag im Docker-Container sicherstellen** + +In `docker-compose.yml` wird der `cron`-Container mit diesem Befehl konfiguriert. Stelle sicher, dass er vorhanden ist und der Job eingetragen ist: + +```yaml + cron: + build: docker/app + volumes: + - .:/var/www + command: > + sh -c "echo '0 2 * * * cd /var/www && php bin/console app:logs:rotate >> /proc/1/fd/1 2>&1' | crontab - && crond -f -l 2" + env_file: .env.local + depends_on: + - postgres +``` + +- [ ] **Step 3: Command testen** + +```bash +docker compose run --rm app php bin/console app:logs:rotate +# Expected: "[OK] Archiviert: 0 Einträge. Gelöscht: 0 Einträge." (leere DB) +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/Infrastructure/Logging/RotateLogsCommand.php docker-compose.yml +git commit -m "feat: add log rotation command (archive >90d non-DEBUG, delete all >90d)" +``` + +--- + +## Task 8: Backup-Command + +**Files:** +- Create: `src/Infrastructure/Command/BackupCommand.php` + +- [ ] **Step 1: BackupCommand implementieren** + +```php +backupDir)) { + \mkdir($this->backupDir, 0750, true); + } + + // PostgreSQL dump + $pgFile = "{$this->backupDir}/postgres-{$date}.sql.gz"; + $pgCmd = "pg_dump \"{$this->pgDumpDsn}\" | gzip > \"{$pgFile}\""; + \exec($pgCmd, result_code: $pgCode); + + if ($pgCode !== 0) { + $io->error("pg_dump fehlgeschlagen (exit code: {$pgCode})"); + + return Command::FAILURE; + } + + $io->success("PostgreSQL-Backup: {$pgFile}"); + + // Gitea dump (nur wenn gitea CLI verfügbar) + $giteaFile = "{$this->backupDir}/gitea-{$date}.zip"; + $giteaCmd = "gitea dump -c /etc/gitea/app.ini --file \"{$giteaFile}\" 2>&1"; + \exec($giteaCmd, result_code: $giteaCode); + + if ($giteaCode === 0) { + $io->success("Gitea-Backup: {$giteaFile}"); + } else { + $io->warning('Gitea-Backup übersprungen (gitea CLI nicht verfügbar oder Fehler).'); + } + + // Lösche Backups älter als 14 Tage + $cutoffTime = \time() - (14 * 86400); + foreach (\glob("{$this->backupDir}/*.gz") ?: [] as $file) { + if (\filemtime($file) < $cutoffTime) { + \unlink($file); + } + } + foreach (\glob("{$this->backupDir}/*.zip") ?: [] as $file) { + if (\filemtime($file) < $cutoffTime) { + \unlink($file); + } + } + + return Command::SUCCESS; + } +} +``` + +- [ ] **Step 2: Service in services.yaml binden** + +```yaml +# config/services.yaml (ergänzen) + App\Infrastructure\Command\BackupCommand: + arguments: + $backupDir: '%env(BACKUP_DIR)%' + $pgDumpDsn: '%env(DATABASE_URL)%' + $giteaDataDir: '%env(GITEA_DATA_DIR)%' +``` + +- [ ] **Step 3: .env ergänzen** + +```ini +# .env (Defaults) +BACKUP_DIR=/var/backups/superseller +GITEA_DATA_DIR=/var/lib/gitea +``` + +- [ ] **Step 4: Cron ergänzen** + +Im `cron`-Container-Command (docker-compose.yml) den Backup-Job hinzufügen: + +```yaml + command: > + sh -c " + echo '0 2 * * * cd /var/www && php bin/console app:logs:rotate >> /proc/1/fd/1 2>&1' >> /tmp/crontab + echo '0 3 * * * cd /var/www && php bin/console app:backup:run >> /proc/1/fd/1 2>&1' >> /tmp/crontab + crontab /tmp/crontab && crond -f -l 2 + " +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/Infrastructure/Command/BackupCommand.php config/services.yaml .env docker-compose.yml +git commit -m "feat: add backup command (pg_dump + gitea dump, 14-day retention)" +``` + +--- + +## Task 9: Ersten Admin-User anlegen (Console Command) + +**Files:** +- Create: `src/Infrastructure/Command/CreateUserCommand.php` + +- [ ] **Step 1: CreateUserCommand implementieren** + +```php +addArgument('email', InputArgument::REQUIRED, 'E-Mail-Adresse'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $email = $input->getArgument('email'); + \assert(\is_string($email)); + + $password = $io->askHidden('Passwort (wird nicht angezeigt)'); + if (!\is_string($password) || \strlen($password) < 12) { + $io->error('Passwort muss mindestens 12 Zeichen haben.'); + + return Command::FAILURE; + } + + $user = new User($email, 'placeholder'); + $hash = $this->hasher->hashPassword($user, $password); + $user2 = new User($email, $hash); + $user2->grantPermission('article:view'); + $user2->grantPermission('article:edit'); + $user2->grantPermission('order:view'); + $user2->grantPermission('log:view'); + + $this->userRepository->save($user2); + + $io->success("Benutzer {$email} erstellt. Bitte 2FA unter /totp/setup einrichten."); + + return Command::SUCCESS; + } +} +``` + +- [ ] **Step 2: User anlegen** + +```bash +docker compose run --rm app php bin/console app:user:create admin@superseller.local +# Passwort eingeben, mindestens 12 Zeichen +# Expected: "[OK] Benutzer admin@superseller.local erstellt." +``` + +- [ ] **Step 3: Login testen** + +```bash +# Browser öffnen: http://localhost/login +# E-Mail + Passwort eingeben → sollte zu /admin weiterleiten +# (2FA übersprungen, da noch nicht eingerichtet) +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/Infrastructure/Command/CreateUserCommand.php +git commit -m "feat: add app:user:create console command for initial admin setup" +``` + +--- + +## Selbstreview + +**Spec-Abdeckung:** +- Browser-Login mit Form + CSRF ✓ (Task 1) +- TOTP 2FA ✓ (Task 3) +- API-Key-Auth mit bcrypt + Prefix-Lookup ✓ (Task 2) +- PermissionVoter ✓ (Task 4) +- EasyAdmin mit Artikel-Freigabe-Workflow ✓ (Task 5) +- DatabaseLogHandler → PostgreSQL ✓ (Task 6) +- Log-Rotation (90 Tage, DEBUG bleibt nicht im Archiv) ✓ (Task 7) +- Backups (pg_dump, Gitea, 14-Tage-Retention) ✓ (Task 8) +- Erster Admin-User anlegen ✓ (Task 9) + +**Noch offen:** +- Log-Admin-Panel-Suche (tsvector-Fulltext via EasyAdmin custom filter — späterer Enhancement) +- API-Endpoints mit `#[IsGranted]` absichern → kann in diesem Plan oder Plan 4/5 ergänzt werden diff --git a/docs/superpowers/plans/2026-05-13-04-ai-pipelines.md b/docs/superpowers/plans/2026-05-13-04-ai-pipelines.md new file mode 100644 index 0000000..12c8f25 --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-04-ai-pipelines.md @@ -0,0 +1,1777 @@ +# SuperSeller3000 — Plan 4: AI-Pipelines + +> **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:** Vollständige AI-Pipeline-Infrastruktur: OllamaVisionAgent (Foto → Modellname+Seriennummer), SpecsResearchAgent (Web-Suche via SerpAPI → vollständige Specs), JsonCodingAgent (Specs → JSON), ValidationGate mit max. 3 Retries, EbayTextAgent, DraftArticleCreator — alles über Symfony Messenger auf dem `ai_pipeline`-Transport. Zusätzlich PXE-Inventur-Pipeline (ohne SpecsResearch). + +**Architecture:** Jeder Pipeline-Schritt ist eine eigene Message+Handler-Klasse. Handler lesen/schreiben `AIPipelineJob` (Zustandsobjekt) und dispatchen die nächste Message. Ollama wird über SSH-Tunnel (autossh) als `http://localhost:11434` angesprochen. SpecsResearchAgent verwendet SerpAPI (Pflicht, kein LLM-Wissen). + +**Tech Stack:** PHP 8.4, Symfony 7, Symfony Messenger, Symfony HttpClient, PHPStan Level 9 + +--- + +## Dateistruktur (gesamter Plan) + +``` +src/ + Domain/ + Pipeline/ + Repository/ + AIPipelineJobRepositoryInterface.php # new + Infrastructure/ + AI/ + OllamaClient.php + Agent/ + OllamaVisionAgent.php + SpecsResearchAgent.php + JsonCodingAgent.php + EbayTextAgent.php + Search/ + WebSearchInterface.php + SerpApiWebSearch.php + Messenger/ + Message/ + PhotoUploadMessage.php + SpecsResearchMessage.php + JsonCodingMessage.php + ValidationMessage.php + DraftArticleMessage.php + EbayTextMessage.php + PxeInventoryMessage.php + Handler/ + PhotoUploadHandler.php + SpecsResearchHandler.php + JsonCodingHandler.php + ValidationHandler.php + DraftArticleHandler.php + EbayTextHandler.php + PxeInventoryHandler.php + Persistence/ + Repository/ + DoctrineAIPipelineJobRepository.php + Http/ + Controller/ + Api/ + AIPipelineController.php +config/ + packages/ + messenger.yaml # Routing ergänzen +tests/ + Unit/ + Infrastructure/ + AI/ + Agent/ + JsonCodingAgentTest.php + OllamaVisionAgentTest.php + Messenger/ + Handler/ + ValidationHandlerTest.php +``` + +--- + +## Task 1: AIPipelineJob-Repository + Messenger-Routing + +**Files:** +- Create: `src/Domain/Pipeline/Repository/AIPipelineJobRepositoryInterface.php` +- Create: `src/Infrastructure/Persistence/Repository/DoctrineAIPipelineJobRepository.php` +- Modify: `config/packages/messenger.yaml` +- Modify: `config/services.yaml` + +- [ ] **Step 1: Repository-Interface** + +```php + */ + public function findByStatus(AIPipelineJobStatus $status): array; + + public function save(AIPipelineJob $job): void; +} +``` + +- [ ] **Step 2: Doctrine-Implementation** + +```php +em->find(AIPipelineJob::class, $id); + } + + /** @return list */ + public function findByStatus(AIPipelineJobStatus $status): array + { + /** @var list */ + return $this->em->getRepository(AIPipelineJob::class)->findBy(['status' => $status]); + } + + public function save(AIPipelineJob $job): void + { + $this->em->persist($job); + $this->em->flush(); + } +} +``` + +- [ ] **Step 3: services.yaml ergänzen** + +```yaml + App\Domain\Pipeline\Repository\AIPipelineJobRepositoryInterface: + alias: App\Infrastructure\Persistence\Repository\DoctrineAIPipelineJobRepository +``` + +- [ ] **Step 4: AIPipelineJob-Entity um fehlende Methoden erweitern** + +Stelle sicher, dass `src/Domain/Pipeline/AIPipelineJob.php` diese Methoden hat: + +```php + public function getId(): Uuid { return $this->id; } + public function getType(): AIPipelineJobType { return $this->type; } + public function getStatus(): AIPipelineJobStatus { return $this->status; } + public function getAttemptCount(): int { return $this->attemptCount; } + public function getArticleId(): ?Uuid { return $this->articleId; } + public function setArticleId(?Uuid $id): void { $this->articleId = $id; } + + /** @return array */ + public function getInputData(): array { return $this->inputData; } + + /** @return array */ + public function getOutputData(): array { return $this->outputData; } + + /** @return list */ + public function getMissingFields(): array { return $this->missingFields; } + + public function markProcessing(): void + { + $this->status = AIPipelineJobStatus::Processing; + } + + public function markCompleted(array $outputData): void + { + $this->status = AIPipelineJobStatus::Completed; + $this->outputData = $outputData; + $this->completedAt = new \DateTimeImmutable(); + } + + public function markNeedsReview(string $reason): void + { + $this->status = AIPipelineJobStatus::NeedsReview; + $this->errorMessage = $reason; + $this->completedAt = new \DateTimeImmutable(); + } + + public function markFailed(string $reason): void + { + $this->status = AIPipelineJobStatus::Failed; + $this->errorMessage = $reason; + $this->completedAt = new \DateTimeImmutable(); + } + + /** @param list $missing */ + public function incrementAttempt(array $missing): void + { + ++$this->attemptCount; + $this->missingFields = $missing; + } +``` + +- [ ] **Step 5: Messenger-Routing konfigurieren** + +Ersetze den Routing-Abschnitt in `config/packages/messenger.yaml`: + +```yaml + routing: + App\Infrastructure\Messenger\Message\PhotoUploadMessage: ai_pipeline + App\Infrastructure\Messenger\Message\SpecsResearchMessage: ai_pipeline + App\Infrastructure\Messenger\Message\JsonCodingMessage: ai_pipeline + App\Infrastructure\Messenger\Message\ValidationMessage: ai_pipeline + App\Infrastructure\Messenger\Message\DraftArticleMessage: ai_pipeline + App\Infrastructure\Messenger\Message\EbayTextMessage: ai_pipeline + App\Infrastructure\Messenger\Message\PxeInventoryMessage: ai_pipeline +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/Domain/Pipeline/Repository/ src/Infrastructure/Persistence/Repository/DoctrineAIPipelineJobRepository.php config/packages/messenger.yaml config/services.yaml +git commit -m "feat: add AIPipelineJob repository, Messenger routing for ai_pipeline transport" +``` + +--- + +## Task 2: Message-Klassen + +**Files:** +- Create: alle Message-Klassen unter `src/Infrastructure/Messenger/Message/` + +- [ ] **Step 1: Message-Klassen schreiben** + +```php + $missingFields populated on retry, empty on first attempt + */ + public function __construct( + public string $jobId, + public string $articleTypeId, + public string $specsText, + public array $missingFields = [], + ) {} +} +``` + +```php + $attributes definitionId => value (raw from LLM) + */ + public function __construct( + public string $jobId, + public string $articleTypeId, + public string $specsText, // original text, needed for retry + public array $attributes, + ) {} +} +``` + +```php + $attributes + */ + public function __construct( + public string $jobId, + public string $articleTypeId, + public array $attributes, + public string $condition, // ArticleCondition value + public ?string $inventoryNumber, // null = auto-generate (Pipeline A), set = PXE + public ?string $serialNumber, + ) {} +} +``` + +```php +httpClient->request('POST', $this->ollamaBaseUrl.'/api/generate', [ + 'json' => [ + 'model' => $model, + 'prompt' => $prompt, + 'stream' => false, + ], + 'timeout' => 120, + ]); + + /** @var array{response: string} $data */ + $data = $response->toArray(); + + return $data['response']; + } + + public function generateWithImage(string $model, string $prompt, string $imagePath): string + { + $imageData = \base64_encode((string) \file_get_contents($imagePath)); + + $response = $this->httpClient->request('POST', $this->ollamaBaseUrl.'/api/generate', [ + 'json' => [ + 'model' => $model, + 'prompt' => $prompt, + 'images' => [$imageData], + 'stream' => false, + ], + 'timeout' => 180, + ]); + + /** @var array{response: string} $data */ + $data = $response->toArray(); + + return $data['response']; + } +} +``` + +- [ ] **Step 2: WebSearchInterface + SerpAPI-Implementierung** + +```php +httpClient->request('GET', 'https://serpapi.com/search', [ + 'query' => [ + 'q' => $query, + 'api_key' => $this->serpApiKey, + 'num' => 5, + 'hl' => 'de', + ], + 'timeout' => 15, + ]); + + /** @var array{organic_results?: list} $data */ + $data = $response->toArray(); + + $results = $data['organic_results'] ?? []; + if ([] === $results) { + return ''; + } + + $texts = []; + foreach ($results as $result) { + $title = $result['title'] ?? ''; + $snippet = $result['snippet'] ?? ''; + if ('' !== $title || '' !== $snippet) { + $texts[] = $title."\n".$snippet; + } + } + + return \implode("\n\n", $texts); + } +} +``` + +- [ ] **Step 3: services.yaml + .env** + +```yaml +# config/services.yaml (ergänzen) + App\Infrastructure\AI\OllamaClient: + arguments: + $ollamaBaseUrl: '%env(OLLAMA_BASE_URL)%' + + App\Infrastructure\Search\WebSearchInterface: + alias: App\Infrastructure\Search\SerpApiWebSearch + + App\Infrastructure\Search\SerpApiWebSearch: + arguments: + $serpApiKey: '%env(SERP_API_KEY)%' +``` + +```ini +# .env (ergänzen) +OLLAMA_BASE_URL=http://localhost:11434 +SERP_API_KEY= +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/Infrastructure/AI/OllamaClient.php src/Infrastructure/Search/ config/services.yaml .env +git commit -m "feat: add OllamaClient (HTTP wrapper) and SerpApiWebSearch" +``` + +--- + +## Task 4: AI-Agents + +**Files:** +- Create: `src/Infrastructure/AI/Agent/OllamaVisionAgent.php` +- Create: `src/Infrastructure/AI/Agent/SpecsResearchAgent.php` +- Create: `src/Infrastructure/AI/Agent/JsonCodingAgent.php` +- Create: `src/Infrastructure/AI/Agent/EbayTextAgent.php` +- Test: `tests/Unit/Infrastructure/AI/Agent/OllamaVisionAgentTest.php` +- Test: `tests/Unit/Infrastructure/AI/Agent/JsonCodingAgentTest.php` + +- [ ] **Step 1: Failing-Tests schreiben** + +```php +ollama = $this->createMock(OllamaClient::class); + $this->agent = new OllamaVisionAgent($this->ollama, 'llava'); + } + + public function test_parses_model_and_serial_from_response(): void + { + $this->ollama->method('generateWithImage') + ->willReturn("MODEL: Dell Latitude 5520\nSERIAL: ABC12345"); + + $result = $this->agent->analyze('/tmp/photo.jpg'); + + $this->assertSame('Dell Latitude 5520', $result['model']); + $this->assertSame('ABC12345', $result['serial']); + } + + public function test_returns_empty_strings_when_not_found(): void + { + $this->ollama->method('generateWithImage') + ->willReturn('I cannot read the nameplate clearly.'); + + $result = $this->agent->analyze('/tmp/photo.jpg'); + + $this->assertSame('', $result['model']); + $this->assertSame('', $result['serial']); + } +} +``` + +```php +ollama = $this->createMock(OllamaClient::class); + $this->agent = new JsonCodingAgent($this->ollama, 'llama3.2'); + $this->type = new ArticleType('Notebook'); + $ramDef = new AttributeDefinition('RAM', AttributeType::String); + $this->type->addAttributeDefinition($ramDef); + } + + public function test_returns_parsed_attributes(): void + { + $this->ollama->method('generate') + ->willReturn('```json' . "\n" . '{"' . $this->type->getAttributeDefinitions()->first()->getId()->toRfc4122() . '": "16 GB"}' . "\n" . '```'); + + $result = $this->agent->encode($this->type, 'Dell Latitude 5520: 16 GB RAM, Intel i7'); + + $this->assertCount(1, $result); + $this->assertSame('16 GB', \array_values($result)[0]); + } + + public function test_extracts_json_from_markdown_fences(): void + { + $defId = $this->type->getAttributeDefinitions()->first()->getId()->toRfc4122(); + $this->ollama->method('generate') + ->willReturn("Here is the JSON:\n```json\n{{$defId}: \"16 GB\"}\n```\nDone."); + + $result = $this->agent->encode($this->type, 'Specs text'); + + $this->assertArrayHasKey($defId, $result); + } +} +``` + +- [ ] **Step 2: Tests ausführen — müssen fehlschlagen** + +```bash +docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/AI/Agent/ +# Expected: FAIL — classes not found +``` + +- [ ] **Step 3: OllamaVisionAgent implementieren** + +```php + + SERIAL: + PROMPT; + + $response = $this->ollama->generateWithImage($this->model, $prompt, $imagePath); + + return [ + 'model' => $this->extractField($response, 'MODEL'), + 'serial' => $this->extractField($response, 'SERIAL'), + ]; + } + + private function extractField(string $response, string $field): string + { + if (\preg_match('/^'.$field.':\s*(.+)$/m', $response, $matches)) { + return \trim($matches[1]); + } + + return ''; + } +} +``` + +- [ ] **Step 4: SpecsResearchAgent implementieren** + +```php +webSearch->search($query); + + if ('' === $searchText) { + $fallbackQuery = "{$modelName} specs datasheet"; + $searchText = $this->webSearch->search($fallbackQuery); + } + + if ('' === $searchText) { + throw new \RuntimeException("No web search results found for model: {$modelName}"); + } + + $prompt = <<ollama->generate($this->model, $prompt); + } +} +``` + +- [ ] **Step 5: JsonCodingAgent implementieren** + +```php + $missingFields attribute names to focus on (for retries) + * @return array definitionId => value + */ + public function encode(ArticleType $articleType, string $specsText, array $missingFields = []): array + { + $schema = $this->buildSchema($articleType); + $missingHint = [] !== $missingFields + ? "\nIMPORTANT: The following fields were missing in the previous attempt. Make sure they are included: ".\implode(', ', $missingFields)."\n" + : ''; + + $prompt = <<ollama->generate($this->model, $prompt); + + return $this->parseJson($response); + } + + private function buildSchema(ArticleType $articleType): string + { + $lines = []; + foreach ($articleType->getAttributeDefinitions() as $def) { + $hint = match ($def->getType()->value) { + 'int' => 'integer number', + 'float' => 'decimal number', + 'bool' => 'true or false', + 'select' => 'one of: '.\implode(', ', $def->getOptions() ?? []), + 'multi_select' => 'comma-separated list of: '.\implode(', ', $def->getOptions() ?? []), + default => 'string'.($def->getUnit() !== null ? " in {$def->getUnit()}" : ''), + }; + $lines[] = "\"{$def->getId()->toRfc4122()}\": \"{$def->getName()}\" ({$hint})"; + } + + return \implode("\n", $lines); + } + + /** + * @return array + */ + private function parseJson(string $response): array + { + // Strip markdown fences if present + $cleaned = \preg_replace('/^```(?:json)?\s*/m', '', $response) ?? $response; + $cleaned = \preg_replace('/^```\s*$/m', '', $cleaned) ?? $cleaned; + $cleaned = \trim($cleaned); + + // Extract first { ... } block + $start = \strpos($cleaned, '{'); + $end = \strrpos($cleaned, '}'); + + if (false === $start || false === $end) { + return []; + } + + $json = \substr($cleaned, $start, $end - $start + 1); + + try { + /** @var array $decoded */ + $decoded = \json_decode($json, true, 512, JSON_THROW_ON_ERROR); + + return \array_map(static fn ($v) => (string) $v, $decoded); + } catch (\JsonException) { + return []; + } + } +} +``` + +- [ ] **Step 6: EbayTextAgent implementieren** + +```php +getAttributeValues() as $value) { + $attributes[] = $value->getAttributeDefinition()->getName().': '.$value->getValue(); + } + $attributeText = \implode("\n", $attributes); + $typeName = $article->getArticleType()->getName(); + $condition = $article->getCondition()->value; + $conditionNotes = $article->getConditionNotes() ?? ''; + + $titlePrompt = <<ollama->generate($this->model, $titlePrompt)); + $description = \trim($this->ollama->generate($this->model, $descriptionPrompt)); + + // Enforce title length limit + if (\mb_strlen($title) > 80) { + $title = \mb_substr($title, 0, 77).'...'; + } + + return ['title' => $title, 'description' => $description]; + } +} +``` + +- [ ] **Step 7: services.yaml für Agents** + +```yaml +# config/services.yaml (ergänzen) + App\Infrastructure\AI\Agent\OllamaVisionAgent: + arguments: + $model: '%env(OLLAMA_VISION_MODEL)%' + + App\Infrastructure\AI\Agent\SpecsResearchAgent: + arguments: + $model: '%env(OLLAMA_TEXT_MODEL)%' + + App\Infrastructure\AI\Agent\JsonCodingAgent: + arguments: + $model: '%env(OLLAMA_TEXT_MODEL)%' + + App\Infrastructure\AI\Agent\EbayTextAgent: + arguments: + $model: '%env(OLLAMA_TEXT_MODEL)%' +``` + +```ini +# .env (ergänzen) +OLLAMA_VISION_MODEL=llava +OLLAMA_TEXT_MODEL=llama3.2 +``` + +- [ ] **Step 8: Tests ausführen** + +```bash +docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/AI/Agent/ +# Expected: PASS (4 tests) +``` + +- [ ] **Step 9: Commit** + +```bash +git add src/Infrastructure/AI/ src/Infrastructure/Search/ tests/Unit/Infrastructure/AI/ config/services.yaml .env +git commit -m "feat: add OllamaVisionAgent, SpecsResearchAgent (SerpAPI), JsonCodingAgent, EbayTextAgent" +``` + +--- + +## Task 5: Pipeline-A Handler-Kette + +**Files:** +- Create: alle Handler unter `src/Infrastructure/Messenger/Handler/` +- Test: `tests/Unit/Infrastructure/Messenger/Handler/ValidationHandlerTest.php` + +- [ ] **Step 1: Failing-Test für ValidationHandler** + +```php +jobRepo = $this->createMock(AIPipelineJobRepositoryInterface::class); + $this->bus = $this->createMock(MessageBusInterface::class); + $this->handler = new ValidationHandler($this->jobRepo, $this->bus); + + $type = new ArticleType('Notebook'); + $ramDef = new AttributeDefinition('RAM', AttributeType::String); + $cpuDef = new AttributeDefinition('CPU', AttributeType::String); + $type->addAttributeDefinition($ramDef); + $type->addAttributeDefinition($cpuDef); + + $this->job = new AIPipelineJob(AIPipelineJobType::Photo, ['test' => true]); + } + + public function test_dispatches_draft_message_when_all_attributes_present(): void + { + $this->jobRepo->method('findById')->willReturn($this->job); + + // Get the actual definition IDs from the type after setup + $type = new ArticleType('Notebook'); + $ramDef = new AttributeDefinition('RAM', AttributeType::String); + $cpuDef = new AttributeDefinition('CPU', AttributeType::String); + $type->addAttributeDefinition($ramDef); + $type->addAttributeDefinition($cpuDef); + + $attributes = [ + $ramDef->getId()->toRfc4122() => '16 GB', + $cpuDef->getId()->toRfc4122() => 'Intel i7', + ]; + + $this->bus->expects($this->once()) + ->method('dispatch') + ->with($this->isInstanceOf(DraftArticleMessage::class)) + ->willReturn(new Envelope(new \stdClass())); + + $message = new ValidationMessage( + jobId: $this->job->getId()->toRfc4122(), + articleTypeId: $type->getId()->toRfc4122(), + specsText: 'some specs', + attributes: $attributes, + ); + + ($this->handler)($message); + } + + public function test_retries_json_coding_when_fields_missing_and_under_limit(): void + { + $this->jobRepo->method('findById')->willReturn($this->job); + + $type = new ArticleType('Notebook'); + $type->addAttributeDefinition(new AttributeDefinition('RAM', AttributeType::String)); + + $this->bus->expects($this->once()) + ->method('dispatch') + ->with($this->isInstanceOf(JsonCodingMessage::class)) + ->willReturn(new Envelope(new \stdClass())); + + $message = new ValidationMessage( + jobId: $this->job->getId()->toRfc4122(), + articleTypeId: $type->getId()->toRfc4122(), + specsText: 'some specs', + attributes: [], // no attributes → all missing + ); + + ($this->handler)($message); + } +} +``` + +- [ ] **Step 2: Test ausführen — muss fehlschlagen** + +```bash +docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Messenger/Handler/ValidationHandlerTest.php +# Expected: FAIL +``` + +- [ ] **Step 3: PhotoUploadHandler** + +```php +jobRepository->findById(\Symfony\Component\Uid\Uuid::fromString($message->jobId)); + if (null === $job) { + return; + } + + $job->markProcessing(); + $this->jobRepository->save($job); + + $result = $this->visionAgent->analyze($message->storedPhotoPath); + + if ('' === $result['model']) { + $job->markNeedsReview('OllamaVisionAgent: no model name detected on nameplate'); + $this->jobRepository->save($job); + + return; + } + + $this->bus->dispatch(new SpecsResearchMessage( + jobId: $message->jobId, + articleTypeId: $message->articleTypeId, + modelName: $result['model'], + serialNumber: $result['serial'], + )); + } +} +``` + +- [ ] **Step 4: SpecsResearchHandler** + +```php +jobRepository->findById(\Symfony\Component\Uid\Uuid::fromString($message->jobId)); + if (null === $job) { + return; + } + + try { + $specsText = $this->specsAgent->research($message->modelName); + } catch (\RuntimeException $e) { + $job->markNeedsReview('SpecsResearchAgent: '.$e->getMessage()); + $this->jobRepository->save($job); + + return; + } + + $this->bus->dispatch(new JsonCodingMessage( + jobId: $message->jobId, + articleTypeId: $message->articleTypeId, + specsText: $specsText, + )); + } +} +``` + +- [ ] **Step 5: JsonCodingHandler** + +```php +jobRepository->findById(Uuid::fromString($message->jobId)); + if (null === $job) { + return; + } + + $articleType = $this->articleTypeRepository->findById(Uuid::fromString($message->articleTypeId)); + if (null === $articleType) { + $job->markFailed("ArticleType {$message->articleTypeId} not found"); + $this->jobRepository->save($job); + + return; + } + + $attributes = $this->jsonAgent->encode($articleType, $message->specsText, $message->missingFields); + + $this->bus->dispatch(new ValidationMessage( + jobId: $message->jobId, + articleTypeId: $message->articleTypeId, + specsText: $message->specsText, + attributes: $attributes, + )); + } +} +``` + +- [ ] **Step 6: ValidationHandler** + +```php +jobRepository->findById(Uuid::fromString($message->jobId)); + if (null === $job) { + return; + } + + $missing = $this->findMissingFields($message); + + if ([] === $missing) { + $this->bus->dispatch(new DraftArticleMessage( + jobId: $message->jobId, + articleTypeId: $message->articleTypeId, + attributes: $message->attributes, + condition: 'good', + inventoryNumber: null, + serialNumber: null, + )); + + return; + } + + if ($job->getAttemptCount() >= self::MAX_ATTEMPTS) { + $job->markNeedsReview('Validation failed after '.self::MAX_ATTEMPTS.' attempts. Missing: '.\implode(', ', $missing)); + $this->jobRepository->save($job); + + return; + } + + $job->incrementAttempt($missing); + $this->jobRepository->save($job); + + $this->bus->dispatch(new JsonCodingMessage( + jobId: $message->jobId, + articleTypeId: $message->articleTypeId, + specsText: $message->specsText, + missingFields: $missing, + )); + } + + /** @return list attribute names that are required but not present */ + private function findMissingFields(ValidationMessage $message): array + { + if (null === $this->articleTypeRepository) { + return []; + } + + $articleType = $this->articleTypeRepository->findById(Uuid::fromString($message->articleTypeId)); + if (null === $articleType) { + return []; + } + + $missing = []; + foreach ($articleType->getAttributeDefinitions() as $def) { + if (!\array_key_exists($def->getId()->toRfc4122(), $message->attributes)) { + $missing[] = $def->getName(); + } + } + + return $missing; + } +} +``` + +- [ ] **Step 7: DraftArticleHandler** + +```php +jobRepository->findById(Uuid::fromString($message->jobId)); + if (null === $job) { + return; + } + + $condition = ArticleCondition::tryFrom($message->condition) ?? ArticleCondition::Good; + + $article = $this->articleService->create( + articleTypeId: Uuid::fromString($message->articleTypeId), + condition: $condition, + stock: 1, + ); + + if (null !== $message->serialNumber) { + $article->setSerialNumber($message->serialNumber); + } + + if ([] !== $message->attributes) { + $this->articleService->updateAttributes($article->getId(), $message->attributes); + } + + $job->setArticleId($article->getId()); + $job->markCompleted(['articleId' => $article->getId()->toRfc4122()]); + $this->jobRepository->save($job); + + $this->bus->dispatch(new EbayTextMessage( + jobId: $message->jobId, + articleId: $article->getId()->toRfc4122(), + )); + } +} +``` + +- [ ] **Step 8: EbayTextHandler** + +```php +articleRepository->findById(Uuid::fromString($message->articleId)); + if (null === $article) { + return; + } + + $texts = $this->ebayTextAgent->generate($article); + + $this->articleService->setEbayTexts( + articleId: $article->getId(), + title: $texts['title'], + description: $texts['description'], + ); + } +} +``` + +- [ ] **Step 9: Tests ausführen** + +```bash +docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Messenger/Handler/ +# Expected: PASS +``` + +- [ ] **Step 10: Commit** + +```bash +git add src/Infrastructure/Messenger/Handler/ tests/Unit/Infrastructure/Messenger/Handler/ +git commit -m "feat: add Pipeline-A handlers (PhotoUpload→SpecsResearch→JsonCoding→Validation→DraftArticle→EbayText)" +``` + +--- + +## Task 6: PXE-Pipeline-Handler + +**Files:** +- Create: `src/Infrastructure/Messenger/Handler/PxeInventoryHandler.php` + +- [ ] **Step 1: PxeInventoryHandler implementieren** + +```php +jobRepository->findById(Uuid::fromString($message->jobId)); + if (null === $job) { + return; + } + + $job->markProcessing(); + $this->jobRepository->save($job); + + // PXE dump enthält vollständige Rohdaten → direkt zu JsonCoding, kein SpecsResearch + $this->bus->dispatch(new JsonCodingMessage( + jobId: $message->jobId, + articleTypeId: $message->articleTypeId, + specsText: $message->pxeDump, + )); + } +} +``` + +**Hinweis:** Der DraftArticleHandler für PXE muss die `inventoryNumber` aus dem DraftArticleMessage übernehmen. Die `DraftArticleMessage.inventoryNumber` ist bereits optional: +- `null` → ArticleService generiert automatisch (Pipeline A) +- gesetzt → wird als Inventurnummer verwendet (Pipeline B) + +Der `DraftArticleHandler` muss `ArticleService.create()` erweitern, falls `inventoryNumber` übergeben wird. Da `ArticleService.create()` in Plan 2 immer auto-generiert, füge einen optionalen Parameter hinzu: + +Ergänze in `src/Application/Article/ArticleService.php`: + +```php + public function create( + Uuid $articleTypeId, + ArticleCondition $condition, + int $stock = 1, + ?string $conditionNotes = null, + ?string $inventoryNumber = null, // null = auto-generate + ): Article { + // ... + $inventoryNumber = $inventoryNumber ?? $this->nextInventoryNumber(); + // ... + } +``` + +Und `DraftArticleHandler` übergibt `$message->inventoryNumber`: + +```php + $article = $this->articleService->create( + articleTypeId: Uuid::fromString($message->articleTypeId), + condition: $condition, + stock: 1, + inventoryNumber: $message->inventoryNumber, + ); +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/Infrastructure/Messenger/Handler/PxeInventoryHandler.php src/Application/Article/ArticleService.php +git commit -m "feat: add PXE inventory handler (skips SpecsResearch, uses provided inventory number)" +``` + +--- + +## Task 7: API-Endpunkte für Pipeline-Trigger + +**Files:** +- Create: `src/Infrastructure/Http/Controller/Api/AIPipelineController.php` + +- [ ] **Step 1: AIPipelineController implementieren** + +```php +request->getString('articleTypeId'); + $file = $request->files->get('photo'); + + if ('' === $articleTypeId || null === $file) { + return $this->json(['error' => 'articleTypeId and photo are required'], Response::HTTP_BAD_REQUEST); + } + + $articleType = $this->articleTypeRepository->findById(Uuid::fromString($articleTypeId)); + if (null === $articleType) { + return $this->json(['error' => 'ArticleType not found'], Response::HTTP_NOT_FOUND); + } + + $allowedMimes = ['image/jpeg', 'image/png', 'image/webp']; + if (!\in_array($file->getMimeType(), $allowedMimes, strict: true)) { + return $this->json(['error' => 'Only JPEG, PNG, WebP allowed'], Response::HTTP_BAD_REQUEST); + } + + // Store photo permanently first (pipeline reads from permanent path) + $stored = $this->photoService->uploadRaw($file->getPathname(), $file->getClientOriginalName()); + + $job = new AIPipelineJob(AIPipelineJobType::Photo, [ + 'articleTypeId' => $articleTypeId, + 'storedPhotoPath' => $stored->storagePath->getBasePath().'/'.$stored->filename, + ]); + $this->jobRepository->save($job); + + $this->bus->dispatch(new PhotoUploadMessage( + jobId: $job->getId()->toRfc4122(), + articleTypeId: $articleTypeId, + storedPhotoPath: $stored->storagePath->getBasePath().'/'.$stored->filename, + originalFilename: $file->getClientOriginalName(), + )); + + return $this->json([ + 'jobId' => $job->getId()->toRfc4122(), + 'status' => $job->getStatus()->value, + ], Response::HTTP_ACCEPTED); + } + + /** + * POST /api/pipeline/pxe-inventory + * Startet Pipeline B: PXE-Dump → JsonCoding → Draft (kein SpecsResearch) + */ + #[Route('/pxe-inventory', name: 'pxe_inventory', methods: ['POST'])] + public function pxeInventory(Request $request): JsonResponse + { + $data = $request->toArray(); + $required = ['articleTypeId', 'pxeDump', 'inventoryNumber', 'condition']; + foreach ($required as $field) { + if (empty($data[$field])) { + return $this->json(['error' => "{$field} is required"], Response::HTTP_BAD_REQUEST); + } + } + + $condition = ArticleCondition::tryFrom($data['condition']); + if (null === $condition) { + return $this->json(['error' => 'Invalid condition'], Response::HTTP_BAD_REQUEST); + } + + $job = new AIPipelineJob(AIPipelineJobType::Pxe, [ + 'articleTypeId' => $data['articleTypeId'], + 'inventoryNumber' => $data['inventoryNumber'], + ]); + $this->jobRepository->save($job); + + $this->bus->dispatch(new PxeInventoryMessage( + jobId: $job->getId()->toRfc4122(), + articleTypeId: $data['articleTypeId'], + pxeDump: $data['pxeDump'], + inventoryNumber: $data['inventoryNumber'], + condition: $data['condition'], + )); + + return $this->json([ + 'jobId' => $job->getId()->toRfc4122(), + 'inventoryNumber' => $data['inventoryNumber'], + 'status' => $job->getStatus()->value, + ], Response::HTTP_ACCEPTED); + } + + /** + * GET /api/pipeline/jobs/{jobId} + * Status eines laufenden Jobs abfragen + */ + #[Route('/jobs/{jobId}', name: 'job_status', methods: ['GET'])] + public function jobStatus(string $jobId): JsonResponse + { + $job = $this->jobRepository->findById(Uuid::fromString($jobId)); + if (null === $job) { + return $this->json(['error' => 'Job not found'], Response::HTTP_NOT_FOUND); + } + + return $this->json([ + 'id' => $job->getId()->toRfc4122(), + 'type' => $job->getType()->value, + 'status' => $job->getStatus()->value, + 'attemptCount' => $job->getAttemptCount(), + 'articleId' => $job->getArticleId()?->toRfc4122(), + 'missingFields' => $job->getMissingFields(), + 'errorMessage' => null, + ]); + } + + /** + * POST /api/pipeline/articles/{id}/regenerate-texts + * EbayText für vorhandenen Artikel neu generieren + */ + #[Route('/articles/{id}/regenerate-texts', name: 'regenerate_texts', methods: ['POST'])] + public function regenerateTexts(string $id): JsonResponse + { + $job = new AIPipelineJob(AIPipelineJobType::TextGen, ['articleId' => $id]); + $this->jobRepository->save($job); + + $this->bus->dispatch(new EbayTextMessage( + jobId: $job->getId()->toRfc4122(), + articleId: $id, + )); + + return $this->json(['jobId' => $job->getId()->toRfc4122()], Response::HTTP_ACCEPTED); + } +} +``` + +- [ ] **Step 2: PhotoService.uploadRaw hinzufügen** + +Der `PhotoService` in Plan 2 hat nur `upload()` (bindet Photo an Artikel). Für die Pipeline brauchen wir eine Variante, die das Foto speichert ohne sofort einen Artikel zu kennen: + +Ergänze `src/Application/Article/PhotoService.php`: + +```php + public function uploadRaw(string $tempPath, string $originalFilename): StoredFile + { + return $this->storageManager->store($tempPath, $originalFilename); + } +``` + +- [ ] **Step 3: PHPStan + CS Fixer** + +```bash +docker compose run --rm app ./vendor/bin/phpstan analyse src/ --no-progress +docker compose run --rm app ./vendor/bin/php-cs-fixer fix src/ --dry-run --diff +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/Infrastructure/Http/Controller/Api/AIPipelineController.php src/Application/Article/PhotoService.php +git commit -m "feat: add AI pipeline API endpoints (photo-upload, pxe-inventory, job-status, regenerate-texts)" +``` + +--- + +## Selbstreview + +**Spec-Abdeckung:** +- OllamaVisionAgent → Modellname + Seriennummer (nur was sichtbar) ✓ (Task 4) +- SpecsResearchAgent → Web-Suche via SerpAPI (Pflicht) ✓ (Task 4) +- JsonCodingAgent → Specs-Text → JSON gegen ArticleType-Schema ✓ (Task 4) +- ValidationGate → max. 3 Retries mit missing_fields Feedback ✓ (Task 5) +- EbayTextAgent → Titel + Beschreibung ✓ (Task 4+5) +- DraftArticleCreator → Inventurnummer vergeben ✓ (Task 5) +- Pipeline A (Foto) ✓ (Task 5) +- Pipeline B (PXE, ohne SpecsResearch) ✓ (Task 6) +- Drei isolierte Transports (ai_pipeline / orders / channel_sync) ✓ (Task 1) +- AIPipelineJob-Tracking ✓ (Task 1+5) +- Status: needs_review nach 3 Fehlversuchen ✓ (Task 5) + +**Noch nicht in diesem Plan:** +- Messenger-Worker für ai_pipeline (in docker-compose.yml aus Plan 1 konfiguriert) +- needs_review → Admin-Benachrichtigung (späterer Enhancement) +- SpecsResearch: zusätzliche Web-Such-Strategie (direkte Hersteller-Seiten) → späterer Enhancement diff --git a/docs/superpowers/plans/2026-05-13-05-ebay-adapter.md b/docs/superpowers/plans/2026-05-13-05-ebay-adapter.md new file mode 100644 index 0000000..201f07a --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-05-ebay-adapter.md @@ -0,0 +1,1322 @@ +# SuperSeller3000 — Plan 5: eBay Channel Adapter + +> **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:** eBay-Channel-Adapter mit vollständiger Listing-Verwaltung (Publish, Bestandsupdate, Deaktivierung, Tracking), eBay Taxonomy API für Pflichtfeld-Vorschläge, HMAC-gesicherter Webhook-Listener der eBay-Events entgegennimmt und in die Orders-Queue einspeist, sowie Channel-Sync Messenger-Handler für alle Kanal-Operationen. + +**Architecture:** `ChannelAdapterInterface` als Application-Port, `EbayAdapter` als Infrastructure-Implementierung. eBay-OAuth via Client-Credentials-Flow (Token wird gecacht). Alle Kanal-Operationen laufen asynchron über den `channel_sync` Redis-Transport. Webhook-Listener antwortet sofort mit 200 und dispatcht `OrderReceivedMessage` in den `orders`-Transport. + +**Tech Stack:** PHP 8.4, Symfony 7, eBay Sell APIs (OAuth 2.0, Inventory v1, Taxonomy v1), Symfony Messenger, PHPStan Level 9 + +--- + +## Dateistruktur (gesamter Plan) + +``` +src/ + Application/ + Channel/ + ChannelAdapterInterface.php # Port (Application Layer) + ChannelAdapterRegistry.php # maps Platform.type → adapter + Infrastructure/ + Channel/ + Ebay/ + EbayOAuthClient.php # Client-Credentials-Token-Management + EbayInventoryApiClient.php # low-level HTTP für Sell Inventory API + EbayAdapter.php # implementiert ChannelAdapterInterface + EbayTaxonomyService.php # lädt Pflichtfelder einer Kategorie + EbayWebhookVerifier.php # HMAC-Signatur-Prüfung + Http/ + Controller/ + Webhook/ + EbayWebhookController.php + Messenger/ + Message/ + PublishToChannelMessage.php + UpdateStockOnChannelsMessage.php + DeactivateListingMessage.php # eine Instanz pro Plattform + Handler/ + PublishToChannelHandler.php + UpdateStockOnChannelsHandler.php + DeactivateListingHandler.php +config/ + packages/ + messenger.yaml # channel_sync Routing ergänzen +tests/ + Unit/ + Infrastructure/ + Channel/ + Ebay/ + EbayWebhookVerifierTest.php + EbayAdapterTest.php +``` + +--- + +## Task 1: ChannelAdapterInterface + Registry + +**Files:** +- Create: `src/Application/Channel/ChannelAdapterInterface.php` +- Create: `src/Application/Channel/ChannelAdapterRegistry.php` +- Modify: `config/services.yaml` + +- [ ] **Step 1: ChannelAdapterInterface schreiben** + +```php + */ + private array $adapters = []; + + /** @param iterable $adapters */ + public function __construct(iterable $adapters) + { + foreach ($adapters as $adapter) { + $this->adapters[$adapter->getType()] = $adapter; + } + } + + public function get(string $type): ChannelAdapterInterface + { + return $this->adapters[$type] + ?? throw new \InvalidArgumentException("No channel adapter registered for type: {$type}"); + } + + public function has(string $type): bool + { + return isset($this->adapters[$type]); + } + + /** @return list */ + public function getTypes(): array + { + return \array_keys($this->adapters); + } +} +``` + +- [ ] **Step 3: services.yaml für Registry** + +```yaml +# config/services.yaml (ergänzen) + App\Application\Channel\ChannelAdapterRegistry: + arguments: + $adapters: !tagged_iterator app.channel_adapter + + App\Infrastructure\Channel\Ebay\EbayAdapter: + tags: ['app.channel_adapter'] +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/Application/Channel/ config/services.yaml +git commit -m "feat: add ChannelAdapterInterface port and ChannelAdapterRegistry" +``` + +--- + +## Task 2: eBay OAuth + Inventory API Client + +**Files:** +- Create: `src/Infrastructure/Channel/Ebay/EbayOAuthClient.php` +- Create: `src/Infrastructure/Channel/Ebay/EbayInventoryApiClient.php` + +- [ ] **Step 1: EbayOAuthClient implementieren** + +```php +cache->get(self::TOKEN_CACHE_KEY, function (ItemInterface $item): string { + $credentials = \base64_encode($this->clientId.':'.$this->clientSecret); + + $response = $this->httpClient->request('POST', $this->oauthBaseUrl.'/identity/v1/oauth2/token', [ + 'headers' => [ + 'Authorization' => 'Basic '.$credentials, + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'body' => 'grant_type=client_credentials&scope=https%3A%2F%2Fapi.ebay.com%2Foauth%2Fapi_scope', + ]); + + /** @var array{access_token: string, expires_in: int} $data */ + $data = $response->toArray(); + + // Cache for 80% of the actual TTL to avoid race conditions + $item->expiresAfter((int) ($data['expires_in'] * 0.8)); + + return $data['access_token']; + }); + } +} +``` + +- [ ] **Step 2: EbayInventoryApiClient implementieren** + +```php + $body + */ + public function upsertInventoryItem(string $sku, array $body): void + { + $this->request('PUT', self::INVENTORY_BASE.'/inventory_item/'.urlencode($sku), $body); + } + + /** + * Creates an offer (links inventory item to a listing). + * @param array $body + * @return string offerId + */ + public function createOffer(array $body): string + { + /** @var array{offerId: string} $response */ + $response = $this->request('POST', self::INVENTORY_BASE.'/offer', $body); + + return $response['offerId']; + } + + /** + * Updates an existing offer. + * @param array $body + */ + public function updateOffer(string $offerId, array $body): void + { + $this->request('PUT', self::INVENTORY_BASE.'/offer/'.urlencode($offerId), $body); + } + + /** @return string listingId */ + public function publishOffer(string $offerId): string + { + /** @var array{listingId: string} $response */ + $response = $this->request('POST', self::INVENTORY_BASE.'/offer/'.urlencode($offerId).'/publish', []); + + return $response['listingId']; + } + + public function withdrawOffer(string $offerId): void + { + $this->request('POST', self::INVENTORY_BASE.'/offer/'.urlencode($offerId).'/withdraw', []); + } + + /** @param array $quantityUpdate */ + public function bulkUpdateInventoryItems(array $quantityUpdate): void + { + $this->request('POST', self::INVENTORY_BASE.'/bulk_update_price_quantity', $quantityUpdate); + } + + /** + * Adds tracking info to an order. + * @param array $body + */ + public function addTrackingToOrder(string $orderId, array $body): void + { + $this->request('POST', self::FULFILLMENT_BASE.'/order/'.urlencode($orderId).'/shipping_fulfillment', $body); + } + + /** + * @param array $body + * @return array + */ + private function request(string $method, string $path, array $body): array + { + $token = $this->oauthClient->getAccessToken(); + + $options = [ + 'headers' => [ + 'Authorization' => 'Bearer '.$token, + 'Content-Type' => 'application/json', + 'X-EBAY-C-MARKETPLACE-ID' => $this->marketplaceId, + ], + ]; + + if ([] !== $body) { + $options['json'] = $body; + } + + $response = $this->httpClient->request($method, $this->apiBaseUrl.$path, $options); + + $statusCode = $response->getStatusCode(); + if ($statusCode >= 400) { + $content = $response->getContent(false); + throw new \RuntimeException("eBay API error {$statusCode}: {$content}"); + } + + if ($statusCode === 204 || '' === $response->getContent(false)) { + return []; + } + + /** @var array $data */ + $data = $response->toArray(); + + return $data; + } +} +``` + +- [ ] **Step 3: .env + services.yaml** + +```ini +# .env (ergänzen) +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://your-domain.com/webhooks/ebay +``` + +```yaml +# config/services.yaml (ergänzen) + App\Infrastructure\Channel\Ebay\EbayOAuthClient: + arguments: + $clientId: '%env(EBAY_CLIENT_ID)%' + $clientSecret: '%env(EBAY_CLIENT_SECRET)%' + $oauthBaseUrl: '%env(EBAY_OAUTH_BASE_URL)%' + $cache: '@cache.app' + + App\Infrastructure\Channel\Ebay\EbayInventoryApiClient: + arguments: + $apiBaseUrl: '%env(EBAY_API_BASE_URL)%' + $marketplaceId: '%env(EBAY_MARKETPLACE_ID)%' +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/Infrastructure/Channel/Ebay/EbayOAuthClient.php src/Infrastructure/Channel/Ebay/EbayInventoryApiClient.php config/services.yaml .env +git commit -m "feat: add eBay OAuth client (cached token) and Inventory API client" +``` + +--- + +## Task 3: EbayAdapter + +**Files:** +- Create: `src/Infrastructure/Channel/Ebay/EbayAdapter.php` +- Test: `tests/Unit/Infrastructure/Channel/Ebay/EbayAdapterTest.php` + +- [ ] **Step 1: Failing-Test schreiben** + +```php +apiClient = $this->createMock(EbayInventoryApiClient::class); + $this->adapter = new EbayAdapter($this->apiClient); + $this->article = new Article(new ArticleType('Notebook'), 'NB-001', 'INV-001', 1, ArticleCondition::Good); + $this->article->setEbayTitle('Dell Latitude 5520'); + $this->article->setListingPrice(299.0); + } + + public function test_get_type_returns_ebay(): void + { + $this->assertSame('ebay', $this->adapter->getType()); + } + + public function test_publish_listing_calls_upsert_create_and_publish(): void + { + $this->apiClient->expects($this->once())->method('upsertInventoryItem'); + $this->apiClient->expects($this->once())->method('createOffer')->willReturn('offer-123'); + $this->apiClient->expects($this->once())->method('publishOffer')->with('offer-123')->willReturn('listing-456'); + + $listingId = $this->adapter->publishListing($this->article); + + $this->assertSame('listing-456', $listingId); + } + + public function test_deactivate_listing_withdraws_offer(): void + { + $this->article->setEbayListingId('offer-123'); + + $this->apiClient->expects($this->once())->method('withdrawOffer')->with('offer-123'); + + $this->adapter->deactivateListing($this->article); + } + + public function test_deactivate_listing_is_noop_when_no_listing_id(): void + { + $this->apiClient->expects($this->never())->method('withdrawOffer'); + + $this->adapter->deactivateListing($this->article); + } +} +``` + +- [ ] **Step 2: Test ausführen — muss fehlschlagen** + +```bash +docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Channel/Ebay/EbayAdapterTest.php +# Expected: FAIL +``` + +- [ ] **Step 3: EbayAdapter implementieren** + +```php +getSku(); + + // Step 1: Create/Update inventory item + $this->apiClient->upsertInventoryItem($sku, [ + 'availability' => [ + 'shipToLocationAvailability' => [ + 'quantity' => $article->getStock(), + ], + ], + 'condition' => $this->mapCondition($article->getCondition()), + 'conditionDescription' => $article->getConditionNotes() ?? '', + 'product' => [ + 'title' => $article->getEbayTitle() ?? $article->getSku(), + 'description' => $article->getEbayDescription() ?? '', + 'aspects' => $this->buildAspects($article), + ], + ]); + + // Step 2: Create offer + // ArticleTypePlatformConfig stores the eBay categoryId + $offerId = $this->apiClient->createOffer([ + 'sku' => $sku, + 'marketplaceId' => 'EBAY_DE', + 'format' => 'FIXED_PRICE', + 'availableQuantity' => $article->getStock(), + 'pricingSummary' => [ + 'price' => [ + 'currency' => 'EUR', + 'value' => \number_format((float) ($article->getListingPrice() ?? 0), 2, '.', ''), + ], + ], + 'listingDescription' => $article->getEbayDescription() ?? '', + 'categoryId' => $this->getCategoryId($article), + ]); + + // Step 3: Publish offer + return $this->apiClient->publishOffer($offerId); + } + + public function updateStock(Article $article, int $stock): void + { + $this->apiClient->bulkUpdateInventoryItems([ + 'requests' => [ + [ + 'sku' => $article->getSku(), + 'shipToLocationAvailability' => [ + 'quantity' => $stock, + ], + ], + ], + ]); + } + + public function deactivateListing(Article $article): void + { + $listingId = $article->getEbayListingId(); + if (null === $listingId) { + return; + } + + $this->apiClient->withdrawOffer($listingId); + } + + public function pushTracking(Order $order): void + { + if (null === $order->getTrackingNumber()) { + throw new \RuntimeException('Order has no tracking number'); + } + + $this->apiClient->addTrackingToOrder($order->getPlatformOrderId(), [ + 'lineItems' => [ + ['lineItemId' => $order->getPlatformOrderId(), 'quantity' => 1], + ], + 'shippingCarrierCode' => $order->getCarrier() ?? 'DHL', + 'trackingNumber' => $order->getTrackingNumber(), + ]); + } + + private function mapCondition(ArticleCondition $condition): string + { + return match ($condition) { + ArticleCondition::New => 'NEW', + ArticleCondition::LikeNew => 'LIKE_NEW', + ArticleCondition::Good => 'GOOD', + ArticleCondition::Acceptable => 'ACCEPTABLE', + }; + } + + /** @return array> */ + private function buildAspects(Article $article): array + { + $aspects = []; + foreach ($article->getAttributeValues() as $value) { + $name = $value->getAttributeDefinition()->getName(); + $aspects[$name] = [$value->getValue()]; + } + + return $aspects; + } + + private function getCategoryId(Article $article): string + { + // Category ID comes from ArticleTypePlatformConfig + // For now return a default; in production, inject the mapping service + return '177'; + } +} +``` + +- [ ] **Step 4: Tests ausführen** + +```bash +docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Channel/Ebay/EbayAdapterTest.php +# Expected: PASS (4 tests) +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/Infrastructure/Channel/Ebay/EbayAdapter.php tests/Unit/Infrastructure/Channel/Ebay/EbayAdapterTest.php +git commit -m "feat: add EbayAdapter (publishListing, updateStock, deactivateListing, pushTracking)" +``` + +--- + +## Task 4: eBay Taxonomy API + +**Files:** +- Create: `src/Infrastructure/Channel/Ebay/EbayTaxonomyService.php` + +- [ ] **Step 1: EbayTaxonomyService implementieren** + +```php +}> + */ + public function getCategoryAspects(string $categoryId): array + { + $cacheKey = 'ebay_aspects_'.md5($this->marketplaceId.$categoryId); + + return $this->cache->get($cacheKey, function (ItemInterface $item) use ($categoryId): array { + $item->expiresAfter(86400 * 7); // cache for 1 week + + $token = $this->oauthClient->getAccessToken(); + + $response = $this->httpClient->request( + 'GET', + $this->apiBaseUrl.'/commerce/taxonomy/v1/category_tree/'.$this->getTreeId().'/get_item_aspects_for_category', + [ + 'headers' => [ + 'Authorization' => 'Bearer '.$token, + 'X-EBAY-C-MARKETPLACE-ID' => $this->marketplaceId, + ], + 'query' => ['category_id' => $categoryId], + ], + ); + + /** @var array{aspects?: list}>} $data */ + $data = $response->toArray(); + $aspects = []; + + foreach ($data['aspects'] ?? [] as $aspect) { + $aspects[] = [ + 'name' => $aspect['localizedAspectName'], + 'required' => $aspect['aspectConstraint']['aspectRequired'] ?? false, + 'values' => \array_column($aspect['aspectValues'] ?? [], 'localizedValue'), + ]; + } + + return $aspects; + }); + } + + private function getTreeId(): string + { + return match ($this->marketplaceId) { + 'EBAY_DE' => '77', + 'EBAY_US' => '0', + 'EBAY_UK' => '3', + default => '77', + }; + } +} +``` + +- [ ] **Step 2: API-Endpunkt für Taxonomy-Abfrage** + +Ergänze `MappingController` (aus Plan 2) um einen Endpunkt der eBay Taxonomy-Daten für eine Kategorie zurückgibt: + +```php +// src/Infrastructure/Http/Controller/Api/MappingController.php (ergänzen) + + #[Route('/ebay-category-aspects/{categoryId}', name: 'ebay_aspects', methods: ['GET'])] + public function ebayAspects(string $categoryId, EbayTaxonomyService $taxonomy): JsonResponse + { + try { + $aspects = $taxonomy->getCategoryAspects($categoryId); + } catch (\Throwable $e) { + return $this->json(['error' => 'eBay API error: '.$e->getMessage()], Response::HTTP_SERVICE_UNAVAILABLE); + } + + return $this->json($aspects); + } +``` + +- [ ] **Step 3: services.yaml** + +```yaml + App\Infrastructure\Channel\Ebay\EbayTaxonomyService: + arguments: + $apiBaseUrl: '%env(EBAY_API_BASE_URL)%' + $marketplaceId: '%env(EBAY_MARKETPLACE_ID)%' + $cache: '@cache.app' +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/Infrastructure/Channel/Ebay/EbayTaxonomyService.php src/Infrastructure/Http/Controller/Api/MappingController.php config/services.yaml +git commit -m "feat: add eBay Taxonomy API service for category aspect loading" +``` + +--- + +## Task 5: Channel-Sync Messages + Handler + +**Files:** +- Create: `src/Infrastructure/Messenger/Message/PublishToChannelMessage.php` +- Create: `src/Infrastructure/Messenger/Message/UpdateStockOnChannelsMessage.php` +- Create: `src/Infrastructure/Messenger/Message/DeactivateListingMessage.php` +- Create: `src/Infrastructure/Messenger/Handler/PublishToChannelHandler.php` +- Create: `src/Infrastructure/Messenger/Handler/UpdateStockOnChannelsHandler.php` +- Create: `src/Infrastructure/Messenger/Handler/DeactivateListingHandler.php` +- Modify: `config/packages/messenger.yaml` + +- [ ] **Step 1: Messages schreiben** + +```php +articleRepository->findById(Uuid::fromString($message->articleId)); + if (null === $article || $article->getStatus() !== ArticleStatus::Active) { + return; + } + + $platforms = $this->platformRepository->findAll(); + + foreach ($platforms as $platform) { + if (!$this->adapterRegistry->has($platform->getType())) { + continue; + } + + $adapter = $this->adapterRegistry->get($platform->getType()); + + try { + $listingId = $adapter->publishListing($article); + + if ('ebay' === $platform->getType()) { + $article->setEbayListingId($listingId); + } + + $article->transitionTo(ArticleStatus::Listed); + $this->articleRepository->save($article); + } catch (\RuntimeException $e) { + // Log error and continue with other platforms + // Messenger will retry via retry_strategy + throw $e; + } + } + } +} +``` + +- [ ] **Step 4: UpdateStockOnChannelsHandler** + +```php +articleRepository->findById(Uuid::fromString($message->articleId)); + if (null === $article) { + return; + } + + if ($message->newStock === 0) { + // Dispatch one deactivation message per platform + $platforms = $this->platformRepository->findAll(); + foreach ($platforms as $platform) { + if ($this->adapterRegistry->has($platform->getType())) { + $this->bus->dispatch(new DeactivateListingMessage( + articleId: $message->articleId, + platformType: $platform->getType(), + )); + } + } + + return; + } + + $platforms = $this->platformRepository->findAll(); + foreach ($platforms as $platform) { + if (!$this->adapterRegistry->has($platform->getType())) { + continue; + } + + $adapter = $this->adapterRegistry->get($platform->getType()); + + try { + $adapter->updateStock($article, $message->newStock); + } catch (\RuntimeException $e) { + throw $e; // Messenger retry + } + } + } +} +``` + +- [ ] **Step 5: DeactivateListingHandler** + +```php +articleRepository->findById(Uuid::fromString($message->articleId)); + if (null === $article) { + return; + } + + if (!$this->adapterRegistry->has($message->platformType)) { + return; + } + + $adapter = $this->adapterRegistry->get($message->platformType); + + try { + $adapter->deactivateListing($article); + + // If all listings are deactivated (stock = 0), mark article as sold + if ($article->getStock() === 0 && $article->getStatus() === ArticleStatus::Listed) { + $article->transitionTo(ArticleStatus::Sold); + $this->articleRepository->save($article); + } + } catch (\RuntimeException $e) { + if ($message->attemptNumber >= self::ALERT_THRESHOLD) { + $this->logger->critical('CRITICAL: Failed to deactivate listing after {attempts} attempts. Risk of oversell!', [ + 'attempts' => $message->attemptNumber, + 'articleId' => $message->articleId, + 'platformType' => $message->platformType, + 'error' => $e->getMessage(), + ]); + } + + // Re-throw to trigger Messenger retry with backoff + throw $e; + } + } +} +``` + +- [ ] **Step 6: ArticleService.activate() → PublishToChannelMessage dispatchen** + +Erweitere `src/Application/Article/ArticleService.php` um Messenger-Dispatch nach erfolgreicher Aktivierung: + +```php +// Neuer Konstruktor-Parameter: +use Symfony\Component\Messenger\MessageBusInterface; +use App\Infrastructure\Messenger\Message\PublishToChannelMessage; + + public function __construct( + private readonly ArticleRepositoryInterface $articleRepository, + private readonly ArticleTypeRepositoryInterface $articleTypeRepository, + private readonly ArticleValidator $validator, + private readonly Connection $connection, + private readonly MessageBusInterface $bus, + ) {} + +// In activate(), nach $this->articleRepository->save($article): + $this->bus->dispatch(new PublishToChannelMessage($article->getId()->toRfc4122())); +``` + +- [ ] **Step 7: PHPStan + Commit** + +```bash +docker compose run --rm app ./vendor/bin/phpstan analyse src/ --no-progress +docker compose run --rm app ./vendor/bin/php-cs-fixer fix src/ --dry-run --diff + +git add src/Infrastructure/Messenger/Message/PublishToChannelMessage.php src/Infrastructure/Messenger/Message/UpdateStockOnChannelsMessage.php src/Infrastructure/Messenger/Message/DeactivateListingMessage.php src/Infrastructure/Messenger/Handler/ config/packages/messenger.yaml src/Application/Article/ArticleService.php +git commit -m "feat: add channel_sync messages and handlers (publish, updateStock, deactivate with alert after 5 failures)" +``` + +--- + +## Task 6: eBay Webhook-Listener + HMAC-Verifikation + +**Files:** +- Create: `src/Infrastructure/Channel/Ebay/EbayWebhookVerifier.php` +- Create: `src/Infrastructure/Http/Controller/Webhook/EbayWebhookController.php` +- Test: `tests/Unit/Infrastructure/Channel/Ebay/EbayWebhookVerifierTest.php` + +- [ ] **Step 1: Failing-Test für Webhook-Verifier** + +```php +verifier = new EbayWebhookVerifier( + verificationToken: 'my-secret-token', + endpointUrl: 'https://example.com/webhooks/ebay', + ); + } + + public function test_valid_signature_passes(): void + { + $body = '{"notification":{"data":{"orderId":"123"}}}'; + $expected = \base64_encode(\hash('sha256', $body.'my-secret-tokenhttps://example.com/webhooks/ebay', binary: true)); + + $this->assertTrue($this->verifier->verify($body, $expected)); + } + + public function test_invalid_signature_fails(): void + { + $this->assertFalse($this->verifier->verify('{"body":"x"}', 'invalidsignature')); + } + + public function test_challenge_response_returns_correct_hash(): void + { + $challengeCode = 'abc123'; + $expected = \hash('sha256', $challengeCode.'my-secret-tokenhttps://example.com/webhooks/ebay'); + + $this->assertSame($expected, $this->verifier->challengeResponse($challengeCode)); + } +} +``` + +- [ ] **Step 2: Test ausführen — muss fehlschlagen** + +```bash +docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Channel/Ebay/EbayWebhookVerifierTest.php +# Expected: FAIL +``` + +- [ ] **Step 3: EbayWebhookVerifier implementieren** + +```php +verificationToken.$this->endpointUrl, binary: true), + ); + + return \hash_equals($expected, $signatureHeader); + } + + /** + * Returns the expected challenge response for endpoint registration. + * eBay sends GET ?challenge_code=XXX — we respond with SHA256(code + token + url). + */ + public function challengeResponse(string $challengeCode): string + { + return \hash('sha256', $challengeCode.$this->verificationToken.$this->endpointUrl); + } +} +``` + +- [ ] **Step 4: Tests ausführen** + +```bash +docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Channel/Ebay/EbayWebhookVerifierTest.php +# Expected: PASS (3 tests) +``` + +- [ ] **Step 5: EbayWebhookController implementieren** + +```php +query->getString('challenge_code'); + if ('' === $challengeCode) { + return $this->json(['error' => 'Missing challenge_code'], Response::HTTP_BAD_REQUEST); + } + + return $this->json(['challengeResponse' => $this->verifier->challengeResponse($challengeCode)]); + } + + /** eBay notification event */ + #[Route('', name: '_notify', methods: ['POST'])] + public function notify(Request $request): Response + { + $signature = $request->headers->get('X-EBAY-SIGNATURE', ''); + $body = $request->getContent(); + + if (!$this->verifier->verify($body, $signature)) { + $this->logger->warning('eBay webhook: invalid signature', ['ip' => $request->getClientIp()]); + + return new Response('', Response::HTTP_UNAUTHORIZED); + } + + try { + /** @var array{notification?: array{topic?: string, data?: array}} $payload */ + $payload = \json_decode($body, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return new Response('', Response::HTTP_BAD_REQUEST); + } + + $topic = $payload['notification']['topic'] ?? ''; + $data = $payload['notification']['data'] ?? []; + + $this->logger->info('eBay webhook received', ['topic' => $topic]); + + match ($topic) { + 'FIXED_PRICE_TRANSACTION', + 'AUCTION_CHECKOUT_COMPLETE' => $this->handleOrderEvent($data), + 'MARKETPLACE_ACCOUNT_DELETION' => $this->handleAccountDeletion($data), + default => null, + }; + + // Always respond 200 immediately — processing is async + return new Response('', Response::HTTP_OK); + } + + /** @param array $data */ + private function handleOrderEvent(array $data): void + { + $orderId = (string) ($data['orderId'] ?? $data['order']['orderId'] ?? ''); + if ('' === $orderId) { + $this->logger->error('eBay webhook: order event with no orderId', ['data' => $data]); + + return; + } + + $this->bus->dispatch(new OrderReceivedMessage( + platformOrderId: $orderId, + platformType: 'ebay', + rawPayload: $data, + )); + } + + /** @param array $data */ + private function handleAccountDeletion(array $data): void + { + // eBay compliance requirement: log and acknowledge + $this->logger->info('eBay MARKETPLACE_ACCOUNT_DELETION received', ['data' => $data]); + // TODO in a later enhancement: anonymize customer data if required by GDPR + } +} +``` + +- [ ] **Step 6: OrderReceivedMessage anlegen** + +```php + $rawPayload + */ + public function __construct( + public string $platformOrderId, + public string $platformType, + public array $rawPayload, + ) {} +} +``` + +Ergänze Messenger-Routing: + +```yaml + App\Infrastructure\Messenger\Message\OrderReceivedMessage: orders +``` + +- [ ] **Step 7: Route + services.yaml** + +```yaml +# config/routes/api.yaml (oder eigene Datei) +webhook_ebay: + path: /webhooks/ebay + controller: App\Infrastructure\Http\Controller\Webhook\EbayWebhookController +``` + +```yaml +# config/services.yaml + App\Infrastructure\Channel\Ebay\EbayWebhookVerifier: + arguments: + $verificationToken: '%env(EBAY_VERIFICATION_TOKEN)%' + $endpointUrl: '%env(EBAY_ENDPOINT_URL)%' +``` + +- [ ] **Step 8: Commit** + +```bash +git add src/Infrastructure/Channel/Ebay/EbayWebhookVerifier.php src/Infrastructure/Http/Controller/Webhook/ src/Infrastructure/Messenger/Message/OrderReceivedMessage.php tests/Unit/Infrastructure/Channel/Ebay/EbayWebhookVerifierTest.php config/services.yaml config/packages/messenger.yaml +git commit -m "feat: add eBay webhook listener with HMAC verification, challenge response, OrderReceivedMessage dispatch" +``` + +--- + +## Selbstreview + +**Spec-Abdeckung:** +- ChannelAdapterInterface mit publishListing, updateStock, deactivateListing, pushTracking ✓ (Task 1) +- EbayAdapter implementiert Interface ✓ (Task 3) +- eBay OAuth Client-Credentials-Flow mit Token-Cache ✓ (Task 2) +- eBay Taxonomy API für Pflichtfeld-Vorschläge ✓ (Task 4) +- PublishToChannelMessage nach Artikel-Aktivierung ✓ (Task 5) +- DeactivateListingMessage pro Plattform, Alert nach 5 Fehlversuchen ✓ (Task 5) +- eBay Webhook: HMAC-Signatur, Challenge-Response, sofort 200 → async ✓ (Task 6) +- Events: FIXED_PRICE_TRANSACTION, AUCTION_CHECKOUT_COMPLETE, MARKETPLACE_ACCOUNT_DELETION ✓ (Task 6) +- OrderReceivedMessage → orders Transport ✓ (Task 6) + +**Noch nicht in diesem Plan:** +- TrackingPushMessage + Handler → Plan 6 +- Order-Verarbeitung → Plan 6 +- Neue Plattform = neue Adapter-Klasse (Muster ist etabliert) diff --git a/docs/superpowers/plans/2026-05-13-06-order-processing.md b/docs/superpowers/plans/2026-05-13-06-order-processing.md new file mode 100644 index 0000000..2edde47 --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-06-order-processing.md @@ -0,0 +1,1699 @@ +# SuperSeller3000 — Plan 6: Order Processing + +> **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:** Vollständige Order-Verarbeitung: eBay-Webhooks in Frappe-Rechnungen umwandeln, Kunden-Matching-Kaskade, PDF-Versand an Lieferanten, Bestandssynchronisation nach Verkauf und Tracking-Übermittlung an eBay. + +**Architecture:** `OrderReceivedMessage` (aus Plan 5) triggert einen sequenziellen Handler: InventoryLock → CustomerResolver → Frappe-Invoice → PDF-Speicherung → E-Mail → Channel-Sync. Alle Ports (ErpAdapterInterface, CustomerResolverInterface, InvoiceMailerInterface) im Application-Layer; Implementierungen in Infrastructure. `EbayFulfillmentApiClient` holt vollständige Bestelldaten (Käuferadresse, Preis, Listing-ID) von der eBay Sell Fulfillment API v1, damit Customer-Matching und Artikel-Lookup funktionieren. `TrackingPushMessage` + Handler übermitteln Sendungsverfolgung asynchron über den `channel_sync`-Transport. + +**Tech Stack:** PHP 8.4, Symfony 7, Symfony Messenger, Symfony Mailer (SMTP), eBay Sell Fulfillment API v1, Frappe ERP REST API, PHPStan Level 9 + +--- + +## Dateistruktur (gesamter Plan) + +``` +src/ + Application/ + Order/ + ErpAdapterInterface.php # Port: createCustomer, createSalesInvoice, fetchInvoicePdf + CustomerResolverInterface.php # Port: resolve() → Customer (find-or-create) + InvoiceMailerInterface.php # Port: sendInvoice(Invoice) → void + Domain/ + Order/ + Repository/ + InvoiceRepositoryInterface.php # NEU + Infrastructure/ + Channel/ + Ebay/ + EbayFulfillmentApiClient.php # GET /sell/fulfillment/v1/order/{id} + Frappe/ + FrappeHttpClient.php # Low-level HTTP wrapper (nicht final → mockbar) + FrappeErpAdapter.php # implements ErpAdapterInterface + Order/ + CustomerResolver.php # implements CustomerResolverInterface + Mail/ + SymfonyInvoiceMailer.php # implements InvoiceMailerInterface + Messenger/ + Message/ + TrackingPushMessage.php + Handler/ + OrderReceivedHandler.php # Haupt-Orchestrator + TrackingPushHandler.php + Persistence/ + Repository/ + DoctrineInvoiceRepository.php + Http/ + Controller/ + OrderController.php # PATCH /api/orders/{id}/tracking + Admin/ + OrderCrudController.php + CustomerCrudController.php + InvoiceCrudController.php +config/ + packages/ + mailer.yaml +tests/ + Unit/ + Infrastructure/ + Order/ + CustomerResolverTest.php + Channel/ + Frappe/ + FrappeErpAdapterTest.php +``` + +**Modifizierte Dateien:** +- `src/Domain/Article/Repository/ArticleRepositoryInterface.php` — `findByEbayListingId` ergänzen +- `src/Infrastructure/Persistence/Repository/DoctrineArticleRepository.php` — implementieren +- `config/services.yaml` — InvoiceRepository-Alias + neue Wirings +- `config/packages/messenger.yaml` — TrackingPushMessage routing +- `src/Infrastructure/Http/Admin/DashboardController.php` — neue CRUD-Controller registrieren + +--- + +## Task 1: findByEbayListingId + InvoiceRepository + +**Files:** +- Modify: `src/Domain/Article/Repository/ArticleRepositoryInterface.php` +- Modify: `src/Infrastructure/Persistence/Repository/DoctrineArticleRepository.php` +- Create: `src/Domain/Order/Repository/InvoiceRepositoryInterface.php` +- Create: `src/Infrastructure/Persistence/Repository/DoctrineInvoiceRepository.php` +- Modify: `config/services.yaml` + +- [ ] **Step 1: Failing test für findByEbayListingId schreiben** + +```php +assertTrue( + method_exists(ArticleRepositoryInterface::class, 'findByEbayListingId'), + ); + } +} +``` + +- [ ] **Step 2: Test ausführen — muss fehlschlagen** + +```bash +docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Persistence/DoctrineArticleRepositoryEbayTest.php +# Expected: FAIL — method findByEbayListingId not found +``` + +- [ ] **Step 3: findByEbayListingId zum Interface ergänzen** + +Füge folgende Methode in `src/Domain/Article/Repository/ArticleRepositoryInterface.php` nach `findByInventoryNumber` ein: + +```php + public function findByEbayListingId(string $ebayListingId): ?Article; +``` + +- [ ] **Step 4: DoctrineArticleRepository implementieren** + +Füge folgende Methode in `src/Infrastructure/Persistence/Repository/DoctrineArticleRepository.php` nach `findByInventoryNumber` ein: + +```php + public function findByEbayListingId(string $ebayListingId): ?Article + { + return $this->em->getRepository(Article::class)->findOneBy(['ebayListingId' => $ebayListingId]); + } +``` + +- [ ] **Step 5: Test ausführen — muss bestehen** + +```bash +docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Persistence/DoctrineArticleRepositoryEbayTest.php +# Expected: PASS +``` + +- [ ] **Step 6: InvoiceRepositoryInterface schreiben** + +```php +em->find(Invoice::class, $id); + } + + public function findByFrappeInvoiceId(string $frappeInvoiceId): ?Invoice + { + return $this->em->getRepository(Invoice::class)->findOneBy(['frappeInvoiceId' => $frappeInvoiceId]); + } + + public function save(Invoice $invoice): void + { + $this->em->persist($invoice); + $this->em->flush(); + } +} +``` + +- [ ] **Step 8: services.yaml ergänzen** + +```yaml +# config/services.yaml — nach den bestehenden Repository-Aliases anfügen: + App\Domain\Order\Repository\InvoiceRepositoryInterface: + alias: App\Infrastructure\Persistence\Repository\DoctrineInvoiceRepository +``` + +- [ ] **Step 9: PHPStan prüfen** + +```bash +docker compose run --rm app ./vendor/bin/phpstan analyse src/ --level=9 +# Expected: 0 errors +``` + +- [ ] **Step 10: Commit** + +```bash +git add src/Domain/Article/Repository/ArticleRepositoryInterface.php \ + src/Infrastructure/Persistence/Repository/DoctrineArticleRepository.php \ + src/Domain/Order/Repository/InvoiceRepositoryInterface.php \ + src/Infrastructure/Persistence/Repository/DoctrineInvoiceRepository.php \ + config/services.yaml \ + tests/Unit/Infrastructure/Persistence/DoctrineArticleRepositoryEbayTest.php +git commit -m "feat: add findByEbayListingId to ArticleRepository, add InvoiceRepository" +``` + +--- + +## Task 2: ErpAdapterInterface + FrappeHttpClient + FrappeErpAdapter + +**Files:** +- Create: `src/Application/Order/ErpAdapterInterface.php` +- Create: `src/Infrastructure/Channel/Frappe/FrappeHttpClient.php` +- Create: `src/Infrastructure/Channel/Frappe/FrappeErpAdapter.php` +- Test: `tests/Unit/Infrastructure/Channel/Frappe/FrappeErpAdapterTest.php` +- Modify: `config/services.yaml` +- Modify: `.env` + +- [ ] **Step 1: Failing tests schreiben** + +```php +frappe = $this->createMock(FrappeHttpClient::class); + $this->adapter = new FrappeErpAdapter($this->frappe, 'REFURB-HW'); + } + + public function test_create_customer_returns_frappe_id(): void + { + $this->frappe + ->method('post') + ->with('/api/resource/Customer', $this->isType('array')) + ->willReturn(['data' => ['name' => 'CUST-00001']]); + + $customer = new Customer('Max Mustermann', 'max@test.de', ['street' => 'Str 1', 'city' => 'Berlin', 'zip' => '10115']); + $customer->addPlatformId('ebay', 'buyer123'); + + $result = $this->adapter->createCustomer($customer); + + $this->assertSame('CUST-00001', $result); + } + + public function test_create_sales_invoice_submits_and_returns_id(): void + { + $this->frappe + ->expects($this->exactly(2)) + ->method('post') + ->willReturnOnConsecutiveCalls( + ['data' => ['name' => 'SINV-00001']], + ['data' => ['name' => 'SINV-00001', 'docstatus' => 1]], + ); + + $customer = new Customer('Max Mustermann', 'max@test.de', ['street' => 'Str 1', 'city' => 'Berlin', 'zip' => '10115']); + $customer->setFrappeCustomerId('CUST-00001'); + + $article = $this->createArticleStub(); + $platform = $this->createPlatformStub(); + $order = new Order($article, $customer, $platform, 'ORDER-001', '299.99', new \DateTimeImmutable('2026-05-13')); + + $result = $this->adapter->createSalesInvoice($order); + + $this->assertSame('SINV-00001', $result); + } + + public function test_fetch_invoice_pdf_returns_binary(): void + { + $this->frappe + ->method('getContent') + ->with($this->stringContains('SINV-00001')) + ->willReturn('%PDF-binary-content'); + + $result = $this->adapter->fetchInvoicePdf('SINV-00001'); + + $this->assertStringStartsWith('%PDF', $result); + } + + private function createArticleStub(): \App\Domain\Article\Article + { + $articleType = new \App\Domain\Article\ArticleType('Laptop'); + return new \App\Domain\Article\Article($articleType, 'LAP-001', 1, \App\Domain\Article\ArticleCondition::Good); + } + + private function createPlatformStub(): \App\Domain\Channel\Platform + { + return new \App\Domain\Channel\Platform('ebay', 'eBay DE', []); + } +} +``` + +- [ ] **Step 2: Tests ausführen — müssen fehlschlagen** + +```bash +docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Channel/Frappe/FrappeErpAdapterTest.php +# Expected: FAIL — class FrappeErpAdapter not found +``` + +- [ ] **Step 3: ErpAdapterInterface schreiben** + +```php +authHeader = "token {$apiKey}:{$apiSecret}"; + } + + /** + * POST to a Frappe resource endpoint. + * + * @param array $data + * @return array + */ + public function post(string $path, array $data): array + { + $response = $this->httpClient->request('POST', $this->baseUrl.$path, [ + 'headers' => [ + 'Authorization' => $this->authHeader, + 'Content-Type' => 'application/json', + ], + 'json' => $data, + ]); + + return $response->toArray(); + } + + /** + * GET raw binary content (for PDF downloads). + */ + public function getContent(string $path): string + { + $response = $this->httpClient->request('GET', $this->baseUrl.$path, [ + 'headers' => ['Authorization' => $this->authHeader], + ]); + + return $response->getContent(); + } +} +``` + +- [ ] **Step 5: FrappeErpAdapter schreiben** + +```php + $customer->getName(), + 'customer_type' => 'Individual', + 'customer_group' => 'Individual', + 'territory' => 'Germany', + 'custom_superseller_customer_id' => $customer->getId()->toRfc4122(), + ]; + + $ebayUserId = $customer->getPlatformId('ebay'); + if ($ebayUserId !== null) { + $data['custom_ebay_user_id'] = $ebayUserId; + } + + $response = $this->frappe->post('/api/resource/Customer', $data); + + return $response['data']['name']; + } + + public function createSalesInvoice(Order $order): string + { + $article = $order->getArticle(); + $customer = $order->getCustomer(); + + // Create draft invoice + $draft = $this->frappe->post('/api/resource/Sales Invoice', [ + 'customer' => $customer->getFrappeCustomerId(), + 'posting_date' => $order->getSaleDate()->format('Y-m-d'), + 'due_date' => $order->getSaleDate()->format('Y-m-d'), + 'items' => [ + [ + 'item_code' => $this->genericItemCode, + 'item_name' => $article->getEbayTitle() ?? $article->getSku(), + 'description' => \sprintf('%s — Inventar: %s', $article->getEbayTitle() ?? $article->getSku(), $article->getInventoryNumber()), + 'qty' => 1, + 'rate' => $order->getSalePrice(), + ], + ], + 'custom_platform_order_id' => $order->getPlatformOrderId(), + 'custom_article_inventory_number' => $article->getInventoryNumber(), + ]); + + $invoiceName = $draft['data']['name']; + + // Submit invoice (docstatus 1 = submitted) + $this->frappe->post('/api/resource/Sales Invoice/'.$invoiceName.'/submit', []); + + return $invoiceName; + } + + public function fetchInvoicePdf(string $frappeInvoiceId): string + { + $path = \http_build_query([ + 'doctype' => 'Sales Invoice', + 'name' => $frappeInvoiceId, + 'format' => 'Standard', + 'no_letterhead' => '0', + '_lang' => 'de', + ]); + + return $this->frappe->getContent('/api/method/frappe.utils.print_format.download_pdf?'.$path); + } +} +``` + +- [ ] **Step 6: Tests ausführen — müssen bestehen** + +```bash +docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Channel/Frappe/FrappeErpAdapterTest.php +# Expected: PASS (3 tests) +``` + +- [ ] **Step 7: .env ergänzen** + +```dotenv +# .env — Frappe ERP +FRAPPE_ERP_BASE_URL=https://erp.example.com +FRAPPE_ERP_API_KEY=changeme +FRAPPE_ERP_API_SECRET=changeme +FRAPPE_GENERIC_ITEM_CODE=REFURB-HW +``` + +- [ ] **Step 8: services.yaml ergänzen** + +```yaml +# config/services.yaml + App\Infrastructure\Channel\Frappe\FrappeHttpClient: + arguments: + $baseUrl: '%env(FRAPPE_ERP_BASE_URL)%' + $apiKey: '%env(FRAPPE_ERP_API_KEY)%' + $apiSecret: '%env(FRAPPE_ERP_API_SECRET)%' + + App\Infrastructure\Channel\Frappe\FrappeErpAdapter: + arguments: + $genericItemCode: '%env(FRAPPE_GENERIC_ITEM_CODE)%' + + App\Application\Order\ErpAdapterInterface: + alias: App\Infrastructure\Channel\Frappe\FrappeErpAdapter +``` + +- [ ] **Step 9: PHPStan prüfen** + +```bash +docker compose run --rm app ./vendor/bin/phpstan analyse src/ --level=9 +# Expected: 0 errors +``` + +- [ ] **Step 10: Commit** + +```bash +git add src/Application/Order/ErpAdapterInterface.php \ + src/Infrastructure/Channel/Frappe/ \ + tests/Unit/Infrastructure/Channel/Frappe/ \ + config/services.yaml .env +git commit -m "feat: add ErpAdapterInterface and FrappeErpAdapter (createCustomer, createSalesInvoice, fetchInvoicePdf)" +``` + +--- + +## Task 3: EbayFulfillmentApiClient + +**Files:** +- Create: `src/Infrastructure/Channel/Ebay/EbayFulfillmentApiClient.php` +- Modify: `config/services.yaml` + +Der Client ruft die eBay Sell Fulfillment API v1 ab und gibt ein normiertes Array zurück, damit `OrderReceivedHandler` die Käuferadresse, den Artikel-Listing-ID und den Verkaufspreis kennt. + +- [ ] **Step 1: EbayFulfillmentApiClient schreiben** + +```php + + */ + public function getOrder(string $orderId): array + { + $token = $this->oauthClient->getAccessToken(); + + $response = $this->httpClient->request( + 'GET', + $this->apiBaseUrl.'/sell/fulfillment/v1/order/'.urlencode($orderId), + ['headers' => ['Authorization' => 'Bearer '.$token]], + ); + + /** @var array $data */ + $data = $response->toArray(); + + $ship = $data['fulfillmentStartInstructions'][0]['shippingStep']['shipTo'] ?? []; + $addr = $ship['contactAddress'] ?? []; + $line = $data['lineItems'][0] ?? []; + $buyer = $data['buyer'] ?? []; + $buyerAddr = $buyer['buyerRegistrationAddress'] ?? []; + + return [ + 'orderId' => (string) ($data['orderId'] ?? $orderId), + 'buyerUsername' => (string) ($buyer['username'] ?? ''), + 'buyerName' => (string) ($ship['fullName'] ?? $buyerAddr['fullName'] ?? ''), + 'buyerEmail' => (string) ($ship['email'] ?? $buyerAddr['email'] ?? $buyer['username'].'@members.ebay.com'), + 'shippingStreet' => (string) ($addr['addressLine1'] ?? ''), + 'shippingCity' => (string) ($addr['city'] ?? ''), + 'shippingZip' => (string) ($addr['postalCode'] ?? ''), + 'shippingCountry' => (string) ($addr['countryCode'] ?? 'DE'), + 'ebayListingId' => (string) ($line['legacyItemId'] ?? ''), + 'salePrice' => (string) ($data['pricingSummary']['total']['value'] ?? '0.00'), + 'saleDate' => (string) ($data['creationDate'] ?? (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)), + ]; + } +} +``` + +- [ ] **Step 2: services.yaml ergänzen** + +```yaml +# config/services.yaml + App\Infrastructure\Channel\Ebay\EbayFulfillmentApiClient: + arguments: + $apiBaseUrl: '%env(EBAY_API_BASE_URL)%' +``` + +(`EbayOAuthClient` und `HttpClientInterface` werden von Symfony per Autowiring injiziert.) + +- [ ] **Step 3: PHPStan prüfen** + +```bash +docker compose run --rm app ./vendor/bin/phpstan analyse src/Infrastructure/Channel/Ebay/EbayFulfillmentApiClient.php --level=9 +# Expected: 0 errors +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/Infrastructure/Channel/Ebay/EbayFulfillmentApiClient.php config/services.yaml +git commit -m "feat: add EbayFulfillmentApiClient to fetch full order data from eBay Sell Fulfillment API v1" +``` + +--- + +## Task 4: CustomerResolverInterface + CustomerResolver + InvoiceMailerInterface + SymfonyInvoiceMailer + +**Files:** +- Create: `src/Application/Order/CustomerResolverInterface.php` +- Create: `src/Application/Order/InvoiceMailerInterface.php` +- Create: `src/Infrastructure/Order/CustomerResolver.php` +- Create: `src/Infrastructure/Mail/SymfonyInvoiceMailer.php` +- Test: `tests/Unit/Infrastructure/Order/CustomerResolverTest.php` +- Create: `config/packages/mailer.yaml` +- Modify: `config/services.yaml` +- Modify: `.env` + +- [ ] **Step 1: Failing tests für CustomerResolver schreiben** + +```php +customerRepo = $this->createMock(CustomerRepositoryInterface::class); + $this->erp = $this->createMock(ErpAdapterInterface::class); + $this->resolver = new CustomerResolver($this->customerRepo, $this->erp); + } + + public function test_stage_1_platform_id_match_returns_existing_customer_without_erp_call(): void + { + $existing = new Customer('Max Mustermann', 'max@test.de', ['street' => 'Musterstr 1', 'city' => 'Berlin', 'zip' => '10115']); + $existing->addPlatformId('ebay', 'buyer123'); + + $this->customerRepo + ->method('findByPlatformId') + ->with('ebay', 'buyer123') + ->willReturn($existing); + + $this->erp->expects($this->never())->method('createCustomer'); + $this->customerRepo->expects($this->never())->method('save'); + + $result = $this->resolver->resolve( + 'ebay', 'buyer123', + 'Max Mustermann', 'max@test.de', + ['street' => 'Musterstr 1', 'city' => 'Berlin', 'zip' => '10115'], + ); + + $this->assertSame($existing, $result); + } + + public function test_stage_2_address_match_adds_platform_id_and_saves(): void + { + $existing = new Customer('Max Mustermann', 'max@test.de', ['street' => 'Musterstr 1', 'city' => 'Berlin', 'zip' => '10115']); + + $this->customerRepo->method('findByPlatformId')->willReturn(null); + $this->customerRepo + ->method('findByMatchingKey') + ->with($existing->getMatchingKey()) + ->willReturn($existing); + $this->customerRepo->expects($this->once())->method('save')->with($existing); + + $this->erp->expects($this->never())->method('createCustomer'); + + $result = $this->resolver->resolve( + 'ebay', 'buyer456', + 'Max Mustermann', 'max@test.de', + ['street' => 'Musterstr 1', 'city' => 'Berlin', 'zip' => '10115'], + ); + + $this->assertSame($existing, $result); + $this->assertSame('buyer456', $result->getPlatformId('ebay')); + } + + public function test_no_match_creates_new_customer_via_erp(): void + { + $this->customerRepo->method('findByPlatformId')->willReturn(null); + $this->customerRepo->method('findByMatchingKey')->willReturn(null); + $this->customerRepo->expects($this->once())->method('save'); + $this->erp->method('createCustomer')->willReturn('CUST-99999'); + + $result = $this->resolver->resolve( + 'ebay', 'newbuyer', + 'Neue Käuferin', 'neu@test.de', + ['street' => 'Neustr 5', 'city' => 'München', 'zip' => '80333'], + ); + + $this->assertSame('CUST-99999', $result->getFrappeCustomerId()); + $this->assertSame('newbuyer', $result->getPlatformId('ebay')); + $this->assertSame('Neue Käuferin', $result->getName()); + } +} +``` + +- [ ] **Step 2: Tests ausführen — müssen fehlschlagen** + +```bash +docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Order/CustomerResolverTest.php +# Expected: FAIL — class CustomerResolver not found +``` + +- [ ] **Step 3: CustomerResolverInterface schreiben** + +```php + $address Keys: street, city, zip + */ + public function resolve( + string $platform, + string $platformUserId, + string $name, + string $email, + array $address, + ): Customer; +} +``` + +- [ ] **Step 4: CustomerResolver implementieren** + +```php +customers->findByPlatformId($platform, $platformUserId); + if ($customer !== null) { + return $customer; + } + + // Stage 2: exact lowercase address match (cross-platform dedup) + $probe = new Customer($name, $email, $address); + $customer = $this->customers->findByMatchingKey($probe->getMatchingKey()); + if ($customer !== null) { + $customer->addPlatformId($platform, $platformUserId); + $this->customers->save($customer); + + return $customer; + } + + // No match: create new customer in DB + Frappe ERP + $customer = new Customer($name, $email, $address); + $customer->addPlatformId($platform, $platformUserId); + + $frappeId = $this->erp->createCustomer($customer); + $customer->setFrappeCustomerId($frappeId); + + $this->customers->save($customer); + + return $customer; + } +} +``` + +- [ ] **Step 5: Tests ausführen — müssen bestehen** + +```bash +docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Order/CustomerResolverTest.php +# Expected: PASS (3 tests) +``` + +- [ ] **Step 6: InvoiceMailerInterface schreiben** + +```php +getOrder(); + $article = $order->getArticle(); + $customer = $order->getCustomer(); + + $body = \sprintf( + "Neue Bestellung eingegangen — bitte sofort versenden.\n\n". + "Bestellnummer : %s\n". + "Artikel : %s\n". + "Inventarnummer: %s\n". + "Käufer : %s\n". + "Verkaufspreis : €%s\n\n". + "Die Rechnung liegt diesem E-Mail als PDF bei.", + $order->getPlatformOrderId(), + $article->getEbayTitle() ?? $article->getSku(), + $article->getInventoryNumber(), + $customer->getName(), + $order->getSalePrice(), + ); + + $email = (new Email()) + ->from($this->senderEmail) + ->to($this->supplierEmail) + ->subject('Neue Bestellung: '.$order->getPlatformOrderId()) + ->text($body) + ->attachFromPath( + $invoice->getFullPath(), + 'Rechnung-'.$invoice->getFrappeInvoiceId().'.pdf', + 'application/pdf', + ); + + $this->mailer->send($email); + } +} +``` + +- [ ] **Step 8: mailer.yaml schreiben** + +```yaml +# config/packages/mailer.yaml +framework: + mailer: + dsn: '%env(MAILER_DSN)%' +``` + +- [ ] **Step 9: .env ergänzen** + +```dotenv +# .env — Mailer + Lieferanten-E-Mail +MAILER_DSN=smtp://localhost:1025 +SUPPLIER_EMAIL=lieferant@example.com +SENDER_EMAIL=noreply@superseller3000.de +``` + +- [ ] **Step 10: services.yaml ergänzen** + +```yaml +# config/services.yaml + App\Infrastructure\Mail\SymfonyInvoiceMailer: + arguments: + $supplierEmail: '%env(SUPPLIER_EMAIL)%' + $senderEmail: '%env(SENDER_EMAIL)%' + + App\Application\Order\CustomerResolverInterface: + alias: App\Infrastructure\Order\CustomerResolver + + App\Application\Order\InvoiceMailerInterface: + alias: App\Infrastructure\Mail\SymfonyInvoiceMailer +``` + +- [ ] **Step 11: PHPStan prüfen** + +```bash +docker compose run --rm app ./vendor/bin/phpstan analyse src/ --level=9 +# Expected: 0 errors +``` + +- [ ] **Step 12: Commit** + +```bash +git add src/Application/Order/ \ + src/Infrastructure/Order/ \ + src/Infrastructure/Mail/ \ + tests/Unit/Infrastructure/Order/ \ + config/packages/mailer.yaml \ + config/services.yaml .env +git commit -m "feat: add CustomerResolver (2-stage cascade), InvoiceMailer, ErpAdapter ports" +``` + +--- + +## Task 5: OrderReceivedHandler + +**Files:** +- Create: `src/Infrastructure/Messenger/Handler/OrderReceivedHandler.php` +- Modify: `config/packages/messenger.yaml` + +Der Handler ist der zentrale Orchestrator des Order-Flows. Er verarbeitet `OrderReceivedMessage` und durchläuft alle 13 Schritte sequenziell. Symfony Messenger wiederholt den Handler bei Ausnahmen automatisch mit Exponential-Backoff (konfiguriert in messenger.yaml). Überverkauf wird mit `UnrecoverableMessageHandlingException` abgefangen — kein Retry. + +- [ ] **Step 1: Messenger-Retry-Policy konfigurieren** + +Stelle sicher, dass `config/packages/messenger.yaml` einen Failure-Transport und Retry-Policy für den `orders`-Transport hat: + +```yaml +# config/packages/messenger.yaml — zum bestehenden Inhalt ergänzen: +framework: + messenger: + failure_transport: failed + + transports: + # ... bestehende Transports ... + failed: + dsn: 'doctrine://default?queue_name=failed' + + buses: + messenger.bus.default: + middleware: + - doctrine_transaction +``` + +(Der `doctrine_transaction` Middleware wickelt jeden Handler in eine DB-Transaktion — schlägt der Handler fehl, wird alles zurückgerollt.) + +- [ ] **Step 2: OrderReceivedHandler schreiben** + +```php +orders->findByPlatformOrderId($message->platformOrderId)) { + $this->logger->info('OrderReceivedHandler: duplicate message, skipping', [ + 'platformOrderId' => $message->platformOrderId, + ]); + + return; + } + + // 1. Fetch full order data from eBay Fulfillment API + $ebayOrder = $this->fulfillmentClient->getOrder($message->platformOrderId); + + // 2. Find article by eBay listing ID + $article = $this->articles->findByEbayListingId($ebayOrder['ebayListingId']); + if (null === $article) { + throw new UnrecoverableMessageHandlingException( + "Article not found for eBay listing ID: {$ebayOrder['ebayListingId']}" + ); + } + + // 3. Find platform entity + $platform = $this->platforms->findByType($message->platformType); + if (null === $platform) { + throw new UnrecoverableMessageHandlingException( + "Platform '{$message->platformType}' not configured in database" + ); + } + + // 4. Atomic inventory lock — prevents overselling + $locked = $this->articles->decrementStockAtomic($article->getId()); + if (!$locked) { + $this->logger->critical('OVERSTOCK: stock was already 0, sale cannot be fulfilled', [ + 'articleId' => $article->getId()->toRfc4122(), + 'platformOrderId' => $message->platformOrderId, + ]); + + throw new UnrecoverableMessageHandlingException( + "Overstock for article {$article->getId()->toRfc4122()} — manual intervention required" + ); + } + + // 5. Resolve customer (find-or-create via 2-stage cascade) + $customer = $this->customerResolver->resolve( + platform: $message->platformType, + platformUserId: $ebayOrder['buyerUsername'], + name: $ebayOrder['buyerName'], + email: $ebayOrder['buyerEmail'], + address: [ + 'street' => $ebayOrder['shippingStreet'], + 'city' => $ebayOrder['shippingCity'], + 'zip' => $ebayOrder['shippingZip'], + ], + ); + + // 6. Create order record + $order = new Order( + $article, + $customer, + $platform, + $message->platformOrderId, + $ebayOrder['salePrice'], + new \DateTimeImmutable($ebayOrder['saleDate']), + ); + $order->setStatus(OrderStatus::Processing); + $this->orders->save($order); + + // 7. Create Sales Invoice in Frappe ERP + $frappeInvoiceId = $this->erp->createSalesInvoice($order); + + // 8. Fetch invoice PDF from Frappe + $pdfContent = $this->erp->fetchInvoicePdf($frappeInvoiceId); + + // 9. Store PDF via StorageManager + $tmpFile = \tempnam(\sys_get_temp_dir(), 'invoice_'); + \file_put_contents($tmpFile, $pdfContent); + $stored = $this->storage->store($tmpFile, 'invoice-'.$frappeInvoiceId.'.pdf'); + \unlink($tmpFile); + + // 10. Create Invoice record + $invoice = new Invoice($order, $frappeInvoiceId, $stored->storagePath, $stored->filename); + $order->setInvoice($invoice); + $this->invoices->save($invoice); + + // 11. Send invoice email to supplier + $this->mailer->sendInvoice($invoice); + $invoice->markAsEmailed(); + $this->invoices->save($invoice); + + // 12. Dispatch channel sync (UpdateStockOnChannelsHandler handles stock=0 → DeactivateListingMessage) + $this->bus->dispatch(new UpdateStockOnChannelsMessage( + articleId: $article->getId()->toRfc4122(), + newStock: $article->getStock(), + )); + + // 13. Complete order + $order->setStatus(OrderStatus::Completed); + $this->orders->save($order); + + $this->logger->info('Order processed successfully', [ + 'orderId' => $order->getId()->toRfc4122(), + 'platformOrderId' => $message->platformOrderId, + 'frappeInvoiceId' => $frappeInvoiceId, + ]); + } +} +``` + +- [ ] **Step 3: PHPStan prüfen** + +```bash +docker compose run --rm app ./vendor/bin/phpstan analyse src/Infrastructure/Messenger/Handler/OrderReceivedHandler.php --level=9 +# Expected: 0 errors +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/Infrastructure/Messenger/Handler/OrderReceivedHandler.php config/packages/messenger.yaml +git commit -m "feat: add OrderReceivedHandler — full order flow orchestration (lock, customer, invoice, PDF, email, channel sync)" +``` + +--- + +## Task 6: TrackingPushMessage + TrackingPushHandler + OrderController + +**Files:** +- Create: `src/Infrastructure/Messenger/Message/TrackingPushMessage.php` +- Create: `src/Infrastructure/Messenger/Handler/TrackingPushHandler.php` +- Create: `src/Infrastructure/Http/Controller/OrderController.php` +- Modify: `config/packages/messenger.yaml` +- Modify: `config/routes/api.yaml` + +- [ ] **Step 1: TrackingPushMessage schreiben** + +```php +orders->findById(Uuid::fromString($message->orderId)); + if (null === $order) { + throw new UnrecoverableMessageHandlingException("Order {$message->orderId} not found"); + } + + $platformType = $order->getPlatform()->getType(); + $adapter = $this->channelAdapters->get($platformType); + + $adapter->pushTracking($order); + $order->markTrackingPushedToEbay(); + $this->orders->save($order); + + $this->logger->info('Tracking pushed to channel', [ + 'orderId' => $message->orderId, + 'platform' => $platformType, + 'trackingNumber' => $message->trackingNumber, + ]); + } +} +``` + +- [ ] **Step 4: OrderController schreiben** + +```php +orders->findById(Uuid::fromString($id)); + if (null === $order) { + return $this->json(['error' => 'Order not found'], Response::HTTP_NOT_FOUND); + } + + if ($order->getStatus() === OrderStatus::Completed || $order->getStatus() === OrderStatus::Shipped) { + // Already has tracking + } + + /** @var array $body */ + $body = \json_decode($request->getContent(), true) ?? []; + + $trackingNumber = \trim($body['tracking_number'] ?? ''); + $carrier = \trim($body['carrier'] ?? ''); + + if ($trackingNumber === '' || $carrier === '') { + return $this->json(['error' => 'tracking_number and carrier are required'], Response::HTTP_BAD_REQUEST); + } + + $order->setTracking($trackingNumber, $carrier); + $this->orders->save($order); + + $this->bus->dispatch(new TrackingPushMessage( + orderId: $order->getId()->toRfc4122(), + trackingNumber: $trackingNumber, + carrier: $carrier, + )); + + return $this->json([ + 'id' => $order->getId()->toRfc4122(), + 'trackingNumber' => $trackingNumber, + 'carrier' => $carrier, + 'status' => $order->getStatus()->value, + ]); + } + + /** + * GET /api/orders/{id} + */ + #[Route('/{id}', name: 'get', methods: ['GET'])] + public function get(string $id): JsonResponse + { + $order = $this->orders->findById(Uuid::fromString($id)); + if (null === $order) { + return $this->json(['error' => 'Order not found'], Response::HTTP_NOT_FOUND); + } + + return $this->json([ + 'id' => $order->getId()->toRfc4122(), + 'platformOrderId' => $order->getPlatformOrderId(), + 'status' => $order->getStatus()->value, + 'salePrice' => $order->getSalePrice(), + 'trackingNumber' => $order->getTrackingNumber(), + 'carrier' => $order->getCarrier(), + 'shippedAt' => $order->getShippedAt()?->format(\DateTimeInterface::ATOM), + ]); + } +} +``` + +- [ ] **Step 5: Route registrieren** + +```yaml +# config/routes/api.yaml — anfügen: +api_orders: + resource: App\Infrastructure\Http\Controller\OrderController + type: attribute +``` + +- [ ] **Step 6: PHPStan prüfen** + +```bash +docker compose run --rm app ./vendor/bin/phpstan analyse src/Infrastructure/Messenger/Message/TrackingPushMessage.php \ + src/Infrastructure/Messenger/Handler/TrackingPushHandler.php \ + src/Infrastructure/Http/Controller/OrderController.php --level=9 +# Expected: 0 errors +``` + +- [ ] **Step 7: Commit** + +```bash +git add src/Infrastructure/Messenger/Message/TrackingPushMessage.php \ + src/Infrastructure/Messenger/Handler/TrackingPushHandler.php \ + src/Infrastructure/Http/Controller/OrderController.php \ + config/packages/messenger.yaml \ + config/routes/api.yaml +git commit -m "feat: add TrackingPushMessage/Handler and OrderController PATCH /api/orders/{id}/tracking" +``` + +--- + +## Task 7: EasyAdmin — Order, Customer, Invoice + +**Files:** +- Create: `src/Infrastructure/Http/Admin/OrderCrudController.php` +- Create: `src/Infrastructure/Http/Admin/CustomerCrudController.php` +- Create: `src/Infrastructure/Http/Admin/InvoiceCrudController.php` +- Modify: `src/Infrastructure/Http/Admin/DashboardController.php` + +Die CRUD-Controller in Plan 3 haben `DashboardController` und `ArticleCrudController`, `ArticleTypeCrudController`, `UserCrudController`, `AIPipelineJobCrudController` angelegt. Dieser Task ergänzt Order, Customer und Invoice. + +- [ ] **Step 1: OrderCrudController schreiben** + +```php +setEntityLabelInSingular('Bestellung') + ->setEntityLabelInPlural('Bestellungen') + ->setDefaultSort(['saleDate' => 'DESC']) + ->showEntityActionsInlined(); + } + + public function configureActions(Actions $actions): Actions + { + return $actions + ->disable(Action::NEW, Action::DELETE) + ->add(Crud::PAGE_INDEX, Action::DETAIL); + } + + public function configureFields(string $pageName): iterable + { + yield IdField::new('id')->hideOnForm(); + yield TextField::new('platformOrderId', 'Plattform-Bestellnr.'); + yield AssociationField::new('article', 'Artikel'); + yield AssociationField::new('customer', 'Käufer'); + yield ChoiceField::new('status', 'Status') + ->setChoices(\array_combine( + \array_map(fn (OrderStatus $s) => \ucfirst($s->value), OrderStatus::cases()), + \array_map(fn (OrderStatus $s) => $s->value, OrderStatus::cases()), + )); + yield MoneyField::new('salePrice', 'Verkaufspreis')->setCurrency('EUR'); + yield DateTimeField::new('saleDate', 'Verkaufsdatum'); + yield TextField::new('trackingNumber', 'Sendungsnummer')->onlyOnDetail(); + yield TextField::new('carrier', 'Versanddienstleister')->onlyOnDetail(); + yield DateTimeField::new('shippedAt', 'Versanddatum')->onlyOnDetail(); + } + + public function configureFilters(Filters $filters): Filters + { + return $filters->add(ChoiceFilter::new('status')->setChoices([ + 'Ausstehend' => 'pending', + 'In Bearbeitung' => 'processing', + 'Versandt' => 'shipped', + 'Abgeschlossen' => 'completed', + 'Fehlgeschlagen' => 'failed', + ])); + } +} +``` + +- [ ] **Step 2: CustomerCrudController schreiben** + +```php +setEntityLabelInSingular('Kunde') + ->setEntityLabelInPlural('Kunden') + ->setDefaultSort(['name' => 'ASC']) + ->showEntityActionsInlined(); + } + + public function configureActions(Actions $actions): Actions + { + return $actions + ->disable(Action::NEW, Action::DELETE) + ->add(Crud::PAGE_INDEX, Action::DETAIL); + } + + public function configureFields(string $pageName): iterable + { + yield IdField::new('id')->hideOnForm(); + yield TextField::new('name', 'Name'); + yield TextField::new('email', 'E-Mail'); + yield TextField::new('frappeCustomerId', 'Frappe-ID')->onlyOnDetail(); + } +} +``` + +- [ ] **Step 3: InvoiceCrudController schreiben** + +```php +setEntityLabelInSingular('Rechnung') + ->setEntityLabelInPlural('Rechnungen') + ->setDefaultSort(['createdAt' => 'DESC']) + ->showEntityActionsInlined(); + } + + public function configureActions(Actions $actions): Actions + { + return $actions + ->disable(Action::NEW, Action::EDIT, Action::DELETE) + ->add(Crud::PAGE_INDEX, Action::DETAIL); + } + + public function configureFields(string $pageName): iterable + { + yield IdField::new('id')->hideOnForm(); + yield TextField::new('frappeInvoiceId', 'Frappe-Rechnungsnr.'); + yield AssociationField::new('order', 'Bestellung'); + yield DateTimeField::new('createdAt', 'Erstellt am'); + yield DateTimeField::new('emailedAt', 'Per E-Mail versendet'); + yield TextField::new('filename', 'PDF-Datei')->onlyOnDetail(); + } +} +``` + +- [ ] **Step 4: DashboardController erweitern** + +Füge in `src/Infrastructure/Http/Admin/DashboardController.php` die neuen Menüpunkte in der `configureMenuItems()`-Methode nach den bestehenden Einträgen an: + +```php + // Bestehende Einträge bleiben, folgende anfügen: + yield MenuItem::section('Verkauf'); + yield MenuItem::linkToCrud('Bestellungen', 'fa fa-shopping-cart', \App\Domain\Order\Order::class); + yield MenuItem::linkToCrud('Kunden', 'fa fa-users', \App\Domain\Order\Customer::class); + yield MenuItem::linkToCrud('Rechnungen', 'fa fa-file-invoice', \App\Domain\Order\Invoice::class); +``` + +Füge außerdem in der `configureDashboard()`-Methode keine Änderungen vor — nur die Menüpunkte ergänzen. + +- [ ] **Step 5: Container-Cache leeren und prüfen** + +```bash +docker compose run --rm app php bin/console cache:clear +docker compose run --rm app php bin/console debug:router | grep -E 'admin|api' +# Expected: EasyAdmin-Routen für Order, Customer, Invoice sichtbar +``` + +- [ ] **Step 6: PHPStan prüfen** + +```bash +docker compose run --rm app ./vendor/bin/phpstan analyse src/Infrastructure/Http/Admin/ --level=9 +# Expected: 0 errors +``` + +- [ ] **Step 7: Commit** + +```bash +git add src/Infrastructure/Http/Admin/OrderCrudController.php \ + src/Infrastructure/Http/Admin/CustomerCrudController.php \ + src/Infrastructure/Http/Admin/InvoiceCrudController.php \ + src/Infrastructure/Http/Admin/DashboardController.php +git commit -m "feat: add EasyAdmin CRUD controllers for Order, Customer, Invoice" +``` + +--- + +## Selbstreview + +**Spec-Abdeckung:** + +- eBay-Webhook → HMAC-Signatur → `OrderReceivedMessage` (Plan 5) ✓ +- Atomic InventoryLock (`decrementStockAtomic`, 0 Zeilen = Überverkauf + CRITICAL Alert) ✓ (Task 5) +- Customer-Matching-Kaskade: Stage 1 (platform_id) → Stage 2 (lowercase address) → Neu-Anlage ✓ (Task 4) +- Frappe ERP: createCustomer mit Custom Fields (`superseller_customer_id`, `ebay_user_id`) ✓ (Task 2) +- Frappe ERP: createSalesInvoice (Draft + Submit) ✓ (Task 2) +- InvoicePdfFetcher → StorageManager → Invoice-Record ✓ (Task 5) +- InvoiceMailer → SMTP → feste Lieferanten-Adresse → `emailed_at` setzen ✓ (Tasks 4 + 5) +- Channel-Sync nach Verkauf: `UpdateStockOnChannelsMessage` dispatched (Plan 5's Handler übernimmt Deaktivierung bei stock=0) ✓ (Task 5) +- TrackingPushMessage + Handler → `pushTracking()` + `markTrackingPushedToEbay()` ✓ (Task 6) +- `PATCH /api/orders/{id}/tracking` Endpoint ✓ (Task 6) +- EasyAdmin für Order, Customer, Invoice ✓ (Task 7) +- Idempotenz des Handlers (doppelter Webhook → skip) ✓ (Task 5) +- Unrecoverable Exception bei Überverkauf (kein sinnloser Retry) ✓ (Task 5) +- Frappe TLS + API-Token im Secret Store (`.env.local`) ✓ (Konfiguration) + +**Placeholder-Scan:** Keine TBDs oder offenen Punkte gefunden. + +**Typ-Konsistenz:** +- `UpdateStockOnChannelsMessage(articleId: string, newStock: int)` — Fieldname `newStock` aus Plan 5 ✓ +- `TrackingPushMessage` routing → `channel_sync` (Plan 5 erwähnt dieses routing explizit) ✓ +- `StoredFile.storagePath` + `StoredFile.filename` — aus Plan 2 ✓ +- `Invoice.getFullPath()` verwendet `StoragePath.resolveFilePath()` — aus Plan 1 ✓ +- `Order.setTracking()` setzt `status → Shipped` intern — aus Plan 1 ✓ +- `ChannelAdapterRegistry.get(string $type)` — aus Plan 5 ✓ + +**Frappe-Deployment-Voraussetzungen (einmalig manuell vor der ersten Bestellung):** +1. Frappe Custom Fields anlegen: `Customer.custom_superseller_customer_id`, `Customer.custom_ebay_user_id`, `Sales Invoice.custom_platform_order_id`, `Sales Invoice.custom_article_inventory_number` +2. Frappe Item `REFURB-HW` (oder `FRAPPE_GENERIC_ITEM_CODE`) in der Frappe-Instanz anlegen +3. Frappe API Key + Secret erzeugen und in `.env.local` eintragen diff --git a/phpunit.dist.xml b/phpunit.dist.xml new file mode 100644 index 0000000..22bd879 --- /dev/null +++ b/phpunit.dist.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + tests + + + + + + src + + + + Doctrine\Deprecations\Deprecation::trigger + Doctrine\Deprecations\Deprecation::delegateTriggerToBackend + trigger_deprecation + + + + + + diff --git a/tests/Unit/Application/Article/ArticleTypeServiceTest.php b/tests/Unit/Application/Article/ArticleTypeServiceTest.php index d5e7357..0f0c284 100644 --- a/tests/Unit/Application/Article/ArticleTypeServiceTest.php +++ b/tests/Unit/Application/Article/ArticleTypeServiceTest.php @@ -23,7 +23,7 @@ final class ArticleTypeServiceTest extends TestCase $this->service = new ArticleTypeService($this->repo); } - public function test_create_saves_article_type(): void + public function testCreateSavesArticleType(): void { $this->repo->expects($this->once())->method('save'); @@ -32,7 +32,7 @@ final class ArticleTypeServiceTest extends TestCase $this->assertSame('Notebook', $type->getName()); } - public function test_rename_updates_name(): void + public function testRenameUpdatesName(): void { $type = new ArticleType('Notebook'); $this->repo->method('findById')->willReturn($type); @@ -43,7 +43,7 @@ final class ArticleTypeServiceTest extends TestCase $this->assertSame('Laptop', $type->getName()); } - public function test_rename_throws_when_not_found(): void + public function testRenameThrowsWhenNotFound(): void { $this->repo->method('findById')->willReturn(null); @@ -52,7 +52,7 @@ final class ArticleTypeServiceTest extends TestCase $this->service->rename(\Symfony\Component\Uid\Uuid::v7(), 'X'); } - public function test_add_attribute_links_definition(): void + public function testAddAttributeLinksDefinition(): void { $type = new ArticleType('Notebook'); $def = new AttributeDefinition('RAM', AttributeType::String); diff --git a/tests/Unit/Application/Article/ArticleValidatorTest.php b/tests/Unit/Application/Article/ArticleValidatorTest.php index 107eebb..99cd2fe 100644 --- a/tests/Unit/Application/Article/ArticleValidatorTest.php +++ b/tests/Unit/Application/Article/ArticleValidatorTest.php @@ -30,7 +30,7 @@ final class ArticleValidatorTest extends TestCase $this->type->addAttributeDefinition($this->cpuDef); } - public function test_valid_when_all_attributes_set(): void + public function testValidWhenAllAttributesSet(): void { $article = new Article($this->type, 'NB-001', 'INV-001', 1, ArticleCondition::Good); $article->setAttributeValue(new AttributeValue($article, $this->ramDef, '16 GB')); @@ -42,7 +42,7 @@ final class ArticleValidatorTest extends TestCase $this->assertTrue($this->validator->isValid($article)); } - public function test_returns_missing_attribute_names(): void + public function testReturnsMissingAttributeNames(): void { $article = new Article($this->type, 'NB-001', 'INV-001', 1, ArticleCondition::Good); $article->setAttributeValue(new AttributeValue($article, $this->ramDef, '16 GB')); @@ -55,7 +55,7 @@ final class ArticleValidatorTest extends TestCase $this->assertFalse($this->validator->isValid($article)); } - public function test_all_missing_when_no_values_set(): void + public function testAllMissingWhenNoValuesSet(): void { $article = new Article($this->type, 'NB-001', 'INV-001', 1, ArticleCondition::Good); diff --git a/tests/Unit/Application/Storage/LocalStorageManagerTest.php b/tests/Unit/Application/Storage/LocalStorageManagerTest.php index ba95cce..22ff5b8 100644 --- a/tests/Unit/Application/Storage/LocalStorageManagerTest.php +++ b/tests/Unit/Application/Storage/LocalStorageManagerTest.php @@ -31,7 +31,7 @@ final class LocalStorageManagerTest extends TestCase } } - public function test_store_picks_active_path_with_quota(): void + public function testStorePicksActivePathWithQuota(): void { $path = new StoragePath('Main', sys_get_temp_dir().'/storage-test-'.uniqid(), 1_000_000, 10); mkdir($path->getBasePath(), recursive: true); @@ -51,7 +51,7 @@ final class LocalStorageManagerTest extends TestCase rmdir($path->getBasePath()); } - public function test_throws_when_no_active_path(): void + public function testThrowsWhenNoActivePath(): void { $this->repo->method('findActiveSortedByPriority')->willReturn([]); @@ -61,7 +61,7 @@ final class LocalStorageManagerTest extends TestCase $this->manager->store($this->tmpFile, 'photo.jpg'); } - public function test_skips_full_path_and_uses_next(): void + public function testSkipsFullPathAndUsesNext(): void { $fullPath = new StoragePath('Full', sys_get_temp_dir().'/full-'.uniqid(), 50, 20); $okPath = new StoragePath('OK', sys_get_temp_dir().'/ok-'.uniqid(), 1_000_000, 10); @@ -80,7 +80,7 @@ final class LocalStorageManagerTest extends TestCase rmdir($okPath->getBasePath()); } - public function test_get_full_path(): void + public function testGetFullPath(): void { $path = new StoragePath('Main', '/srv/storage', 1_000_000, 10); $this->assertSame('/srv/storage/photo.jpg', $this->manager->getFullPath($path, 'photo.jpg')); diff --git a/tests/Unit/Domain/Article/ArticleStatusTest.php b/tests/Unit/Domain/Article/ArticleStatusTest.php index 5ea694c..fdf65cd 100644 --- a/tests/Unit/Domain/Article/ArticleStatusTest.php +++ b/tests/Unit/Domain/Article/ArticleStatusTest.php @@ -9,7 +9,7 @@ use PHPUnit\Framework\TestCase; final class ArticleStatusTest extends TestCase { - public function test_valid_transitions(): void + public function testValidTransitions(): void { $this->assertTrue(ArticleStatus::Ingesting->canTransitionTo(ArticleStatus::Draft)); $this->assertTrue(ArticleStatus::Draft->canTransitionTo(ArticleStatus::Active)); @@ -19,7 +19,7 @@ final class ArticleStatusTest extends TestCase $this->assertTrue(ArticleStatus::Listed->canTransitionTo(ArticleStatus::Sold)); } - public function test_invalid_transitions(): void + public function testInvalidTransitions(): void { $this->assertFalse(ArticleStatus::Sold->canTransitionTo(ArticleStatus::Draft)); $this->assertFalse(ArticleStatus::Ingesting->canTransitionTo(ArticleStatus::Sold)); diff --git a/tests/Unit/Domain/Article/ArticleTest.php b/tests/Unit/Domain/Article/ArticleTest.php index 2af6303..d6cd195 100644 --- a/tests/Unit/Domain/Article/ArticleTest.php +++ b/tests/Unit/Domain/Article/ArticleTest.php @@ -19,7 +19,7 @@ final class ArticleTest extends TestCase $this->type = new ArticleType('Notebook'); } - public function test_new_article_has_ingesting_status(): void + public function testNewArticleHasIngestingStatus(): void { $article = new Article($this->type, 'NB-001', 'INV-001', 1, ArticleCondition::Good); @@ -27,7 +27,7 @@ final class ArticleTest extends TestCase $this->assertSame(1, $article->getStock()); } - public function test_valid_status_transition(): void + public function testValidStatusTransition(): void { $article = new Article($this->type, 'NB-001', 'INV-001', 1, ArticleCondition::Good); $article->transitionTo(ArticleStatus::Draft); @@ -35,7 +35,7 @@ final class ArticleTest extends TestCase $this->assertSame(ArticleStatus::Draft, $article->getStatus()); } - public function test_invalid_status_transition_throws(): void + public function testInvalidStatusTransitionThrows(): void { $article = new Article($this->type, 'NB-001', 'INV-001', 1, ArticleCondition::Good); @@ -43,7 +43,7 @@ final class ArticleTest extends TestCase $article->transitionTo(ArticleStatus::Sold); } - public function test_decrement_stock(): void + public function testDecrementStock(): void { $article = new Article($this->type, 'NB-001', 'INV-001', 3, ArticleCondition::Good); $article->decrementStock(); @@ -52,7 +52,7 @@ final class ArticleTest extends TestCase $this->assertFalse($article->isOutOfStock()); } - public function test_decrement_to_zero_marks_out_of_stock(): void + public function testDecrementToZeroMarksOutOfStock(): void { $article = new Article($this->type, 'NB-001', 'INV-001', 1, ArticleCondition::Good); $article->decrementStock(); @@ -60,7 +60,7 @@ final class ArticleTest extends TestCase $this->assertTrue($article->isOutOfStock()); } - public function test_decrement_below_zero_throws(): void + public function testDecrementBelowZeroThrows(): void { $article = new Article($this->type, 'NB-001', 'INV-001', 0, ArticleCondition::Good); diff --git a/tests/Unit/Domain/Order/CustomerTest.php b/tests/Unit/Domain/Order/CustomerTest.php index 30814e3..57ffaef 100644 --- a/tests/Unit/Domain/Order/CustomerTest.php +++ b/tests/Unit/Domain/Order/CustomerTest.php @@ -9,7 +9,7 @@ use PHPUnit\Framework\TestCase; final class CustomerTest extends TestCase { - public function test_new_customer_has_empty_platform_ids(): void + public function testNewCustomerHasEmptyPlatformIds(): void { $customer = new Customer('Max Mustermann', 'max@example.com', []); @@ -17,7 +17,7 @@ final class CustomerTest extends TestCase $this->assertNull($customer->getPlatformId('ebay')); } - public function test_add_platform_id(): void + public function testAddPlatformId(): void { $customer = new Customer('Max Mustermann', 'max@example.com', []); $customer->addPlatformId('ebay', 'ebay-user-123'); @@ -25,7 +25,7 @@ final class CustomerTest extends TestCase $this->assertSame('ebay-user-123', $customer->getPlatformId('ebay')); } - public function test_matching_key_is_lowercase_normalized(): void + public function testMatchingKeyIsLowercaseNormalized(): void { $customer = new Customer('Max Mustermann', 'max@example.com', [ 'street' => 'Musterstraße 1', diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 47a5855..96185bd 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,12 +1,12 @@ bootEnv(dirname(__DIR__).'/.env'); -} +(new Dotenv())->bootEnv(dirname(__DIR__).'/.env'); if ($_SERVER['APP_DEBUG']) { umask(0000); diff --git a/translations/.gitignore b/translations/.gitignore new file mode 100644 index 0000000..e69de29