SuperSeller3000/docs/superpowers/plans/2026-05-13-01-foundation.md
Simon Kuehn f55e96b094 chore: add tooling config, test bootstrap, env templates and docs
PHPUnit config (phpunit.dist.xml, bin/phpunit, bootstrap.php), PHP CS
Fixer config, .editorconfig. Separate .env.dev/.env.test templates.
Ollama tunnel setup script. Architecture and plan docs. Updated
application-layer unit tests to match current service signatures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 22:44:16 +00:00

91 KiB
Raw Permalink Blame History

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

# 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
; 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
# 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)
# 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)
# .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
/.env.local
/.env.*.local
/vendor/
/var/
/public/bundles/
/.php-cs-fixer.cache
/.phpunit.result.cache
  • Step 8: Commit
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

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
# 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
# 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
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

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
# 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
// .php-cs-fixer.php
$finder = PhpCsFixer\Finder::create()
    ->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
<!-- phpunit.xml.dist -->
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true"
         failOnWarning="true"
         failOnRisky="true"
>
    <testsuites>
        <testsuite name="Unit">
            <directory>tests/Unit</directory>
        </testsuite>
        <testsuite name="Integration">
            <directory>tests/Integration</directory>
        </testsuite>
    </testsuites>

    <coverage>
        <include>
            <directory suffix=".php">src</directory>
        </include>
    </coverage>

    <php>
        <ini name="error_reporting" value="-1"/>
        <server name="APP_ENV" value="test" force="true"/>
        <server name="SHELL_VERBOSITY" value="-1"/>
    </php>
</phpunit>
  • Step 5: Pest initialisieren
docker compose run --rm app ./vendor/bin/pest --init
  • Step 6: Verify tools laufen
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
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

# .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
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
// tests/Unit/Domain/Article/ArticleStatusTest.php
declare(strict_types=1);

namespace App\Tests\Unit\Domain\Article;

use App\Domain\Article\ArticleStatus;
use PHPUnit\Framework\TestCase;

final class ArticleStatusTest extends TestCase
{
    public function test_valid_transitions(): void
    {
        $this->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
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
// src/Domain/Article/ArticleStatus.php
declare(strict_types=1);

namespace App\Domain\Article;

enum ArticleStatus: string
{
    case Ingesting = 'ingesting';
    case Draft = 'draft';
    case NeedsReview = 'needs_review';
    case Active = 'active';
    case Listed = 'listed';
    case Sold = 'sold';

    /** @return list<self> */
    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
// src/Domain/Article/ArticleCondition.php
declare(strict_types=1);

namespace App\Domain\Article;

enum ArticleCondition: string
{
    case New = 'new';
    case LikeNew = 'like_new';
    case Good = 'good';
    case Acceptable = 'acceptable';
}
<?php
// src/Domain/Article/AttributeType.php
declare(strict_types=1);

namespace App\Domain\Article;

enum AttributeType: string
{
    case String = 'string';
    case Int = 'int';
    case Float = 'float';
    case Bool = 'bool';
    case Select = 'select';
    case MultiSelect = 'multi_select';
}
<?php
// src/Domain/Order/OrderStatus.php
declare(strict_types=1);

namespace App\Domain\Order;

enum OrderStatus: string
{
    case Pending = 'pending';
    case Processing = 'processing';
    case Shipped = 'shipped';
    case Completed = 'completed';
    case Failed = 'failed';
}
<?php
// src/Domain/Pipeline/AIPipelineJobType.php
declare(strict_types=1);

namespace App\Domain\Pipeline;

enum AIPipelineJobType: string
{
    case Photo = 'photo';
    case Pxe = 'pxe';
    case TextGeneration = 'text_gen';
}
<?php
// src/Domain/Pipeline/AIPipelineJobStatus.php
declare(strict_types=1);

namespace App\Domain\Pipeline;

enum AIPipelineJobStatus: string
{
    case Queued = 'queued';
    case Processing = 'processing';
    case Completed = 'completed';
    case Failed = 'failed';
    case NeedsReview = 'needs_review';
}
  • Step 5: Tests ausführen — müssen bestehen
docker compose run --rm app ./vendor/bin/pest tests/Unit/Domain/Article/ArticleStatusTest.php
# Expected: PASS (2 tests)
  • Step 6: Commit
git add src/Domain/ tests/Unit/Domain/Article/ArticleStatusTest.php
git commit -m "feat: add domain enums (ArticleStatus with transitions, ArticleCondition, AttributeType, OrderStatus, AIPipelineJob enums)"

Task 6: Domain-Entities — Artikel-Cluster

Files:

  • Create: src/Domain/Article/ArticleType.php

  • Create: src/Domain/Article/AttributeDefinition.php

  • Create: src/Domain/Storage/StoragePath.php

  • Create: src/Domain/Article/ArticlePhoto.php

  • Create: src/Domain/Article/AttributeValue.php

  • Create: src/Domain/Article/Article.php

  • Test: tests/Unit/Domain/Article/ArticleTest.php

  • Step 1: Failing test für Article schreiben

<?php
// tests/Unit/Domain/Article/ArticleTest.php
declare(strict_types=1);

namespace App\Tests\Unit\Domain\Article;

use App\Domain\Article\Article;
use App\Domain\Article\ArticleCondition;
use App\Domain\Article\ArticleStatus;
use App\Domain\Article\ArticleType;
use PHPUnit\Framework\TestCase;

final class ArticleTest extends TestCase
{
    private ArticleType $type;

    protected function setUp(): void
    {
        $this->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
docker compose run --rm app ./vendor/bin/pest tests/Unit/Domain/Article/ArticleTest.php
# Expected: FAIL — classes not found
  • Step 3: ArticleType implementieren
<?php
// src/Domain/Article/ArticleType.php
declare(strict_types=1);

namespace App\Domain\Article;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;

#[ORM\Entity]
#[ORM\Table(name: 'article_types', schema: 'app')]
class ArticleType
{
    #[ORM\Id]
    #[ORM\Column(type: 'uuid')]
    private Uuid $id;

    #[ORM\Column(type: 'string', length: 255, unique: true)]
    private string $name;

    /** @var Collection<int, AttributeDefinition> */
    #[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<int, AttributeDefinition> */
    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
// src/Domain/Article/AttributeDefinition.php
declare(strict_types=1);

namespace App\Domain\Article;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;

#[ORM\Entity]
#[ORM\Table(name: 'attribute_definitions', schema: 'app')]
class AttributeDefinition
{
    #[ORM\Id]
    #[ORM\Column(type: 'uuid')]
    private Uuid $id;

    #[ORM\Column(type: 'string', length: 255)]
    private string $name;

    #[ORM\Column(type: 'string', enumType: AttributeType::class)]
    private AttributeType $type;

    #[ORM\Column(type: 'string', length: 50, nullable: true)]
    private ?string $unit = null;

    /** @var list<string>|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<string>|null */
    public function getOptions(): ?array { return $this->options; }

    /** @param list<string>|null $options */
    public function setOptions(?array $options): void { $this->options = $options; }
}
  • Step 5: StoragePath implementieren
<?php
// src/Domain/Storage/StoragePath.php
declare(strict_types=1);

namespace App\Domain\Storage;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;

#[ORM\Entity]
#[ORM\Table(name: 'storage_paths', schema: 'app')]
class StoragePath
{
    #[ORM\Id]
    #[ORM\Column(type: 'uuid')]
    private Uuid $id;

    #[ORM\Column(type: 'string', length: 255)]
    private string $label;

    #[ORM\Column(type: 'string', length: 500)]
    private string $basePath;

    #[ORM\Column(type: 'bigint')]
    private int $quotaBytes;

    #[ORM\Column(type: 'integer')]
    private int $priority;

    #[ORM\Column(type: 'boolean')]
    private bool $isActive;

    public function __construct(
        string $label,
        string $basePath,
        int $quotaBytes,
        int $priority,
        bool $isActive = true,
    ) {
        $this->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
// src/Domain/Article/ArticlePhoto.php
declare(strict_types=1);

namespace App\Domain\Article;

use App\Domain\Storage\StoragePath;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;

#[ORM\Entity]
#[ORM\Table(name: 'article_photos', schema: 'app')]
class ArticlePhoto
{
    #[ORM\Id]
    #[ORM\Column(type: 'uuid')]
    private Uuid $id;

    #[ORM\ManyToOne(targetEntity: Article::class, inversedBy: 'photos')]
    #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
    private Article $article;

    #[ORM\ManyToOne(targetEntity: StoragePath::class)]
    #[ORM\JoinColumn(nullable: false)]
    private StoragePath $storagePath;

    #[ORM\Column(type: 'string', length: 500)]
    private string $filename;

    #[ORM\Column(type: 'boolean')]
    private bool $isMain;

    #[ORM\Column(type: 'integer')]
    private int $sortOrder;

    public function __construct(
        Article $article,
        StoragePath $storagePath,
        string $filename,
        bool $isMain,
        int $sortOrder,
    ) {
        $this->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
// src/Domain/Article/AttributeValue.php
declare(strict_types=1);

namespace App\Domain\Article;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;

#[ORM\Entity]
#[ORM\Table(name: 'attribute_values', schema: 'app')]
#[ORM\UniqueConstraint(columns: ['article_id', 'attribute_definition_id'])]
class AttributeValue
{
    #[ORM\Id]
    #[ORM\Column(type: 'uuid')]
    private Uuid $id;

    #[ORM\ManyToOne(targetEntity: Article::class, inversedBy: 'attributeValues')]
    #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
    private Article $article;

    #[ORM\ManyToOne(targetEntity: AttributeDefinition::class)]
    #[ORM\JoinColumn(nullable: false)]
    private AttributeDefinition $attributeDefinition;

    #[ORM\Column(type: 'text')]
    private string $value;

    public function __construct(
        Article $article,
        AttributeDefinition $attributeDefinition,
        string $value,
    ) {
        $this->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
// src/Domain/Article/Article.php
declare(strict_types=1);

namespace App\Domain\Article;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;

#[ORM\Entity]
#[ORM\Table(name: 'articles', schema: 'app')]
class Article
{
    #[ORM\Id]
    #[ORM\Column(type: 'uuid')]
    private Uuid $id;

    #[ORM\ManyToOne(targetEntity: ArticleType::class)]
    #[ORM\JoinColumn(nullable: false)]
    private ArticleType $articleType;

    #[ORM\Column(type: 'string', length: 255, unique: true)]
    private string $sku;

    #[ORM\Column(type: 'string', length: 100, unique: true)]
    private string $inventoryNumber;

    #[ORM\Column(type: 'string', enumType: ArticleStatus::class)]
    private ArticleStatus $status;

    #[ORM\Column(type: 'integer')]
    private int $stock;

    #[ORM\Column(type: 'string', enumType: ArticleCondition::class)]
    private ArticleCondition $condition;

    #[ORM\Column(type: 'text', nullable: true)]
    private ?string $conditionNotes = null;

    #[ORM\Column(type: 'decimal', precision: 10, scale: 2, nullable: true)]
    private ?string $listingPrice = null;

    #[ORM\Column(type: 'string', length: 255, nullable: true)]
    private ?string $serialNumber = null;

    #[ORM\Column(type: 'string', length: 255, nullable: true)]
    private ?string $ebayListingId = null;

    #[ORM\Column(type: 'text', nullable: true)]
    private ?string $ebayTitle = null;

    #[ORM\Column(type: 'text', nullable: true)]
    private ?string $ebayDescription = null;

    /** @var Collection<int, AttributeValue> */
    #[ORM\OneToMany(mappedBy: 'article', targetEntity: AttributeValue::class, cascade: ['persist', 'remove'])]
    private Collection $attributeValues;

    /** @var Collection<int, ArticlePhoto> */
    #[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<int, AttributeValue> */
    public function getAttributeValues(): Collection { return $this->attributeValues; }

    /** @return Collection<int, ArticlePhoto> */
    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
docker compose run --rm app ./vendor/bin/pest tests/Unit/Domain/Article/ArticleTest.php
# Expected: PASS (6 tests)
  • Step 10: PHPStan prüfen
docker compose run --rm app ./vendor/bin/phpstan analyse src/Domain/Article src/Domain/Storage --no-progress
# Expected: No errors
  • Step 11: Commit
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
// src/Domain/Channel/Platform.php
declare(strict_types=1);

namespace App\Domain\Channel;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;

#[ORM\Entity]
#[ORM\Table(name: 'platforms', schema: 'app')]
class Platform
{
    #[ORM\Id]
    #[ORM\Column(type: 'uuid')]
    private Uuid $id;

    #[ORM\Column(type: 'string', length: 100, unique: true)]
    private string $type;

    #[ORM\Column(type: 'string', length: 255)]
    private string $label;

    /** @var array<string, mixed> */
    #[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<string, mixed> */
    public function getConfig(): array { return $this->config; }

    /** @param array<string, mixed> $config */
    public function setConfig(array $config): void { $this->config = $config; }
}
  • Step 2: ChannelField implementieren
<?php
// src/Domain/Channel/ChannelField.php
declare(strict_types=1);

namespace App\Domain\Channel;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;

#[ORM\Entity]
#[ORM\Table(name: 'channel_fields', schema: 'app')]
class ChannelField
{
    #[ORM\Id]
    #[ORM\Column(type: 'uuid')]
    private Uuid $id;

    #[ORM\ManyToOne(targetEntity: Platform::class)]
    #[ORM\JoinColumn(nullable: false)]
    private Platform $platform;

    #[ORM\Column(type: 'string', length: 255)]
    private string $label;

    #[ORM\Column(type: 'string', length: 500)]
    private string $path;

    public function __construct(Platform $platform, string $label, string $path)
    {
        $this->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
// src/Domain/Channel/AttributeMapping.php
declare(strict_types=1);

namespace App\Domain\Channel;

use App\Domain\Article\AttributeDefinition;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;

#[ORM\Entity]
#[ORM\Table(name: 'attribute_mappings', schema: 'app')]
#[ORM\UniqueConstraint(columns: ['platform_config_id', 'attribute_definition_id'])]
class AttributeMapping
{
    #[ORM\Id]
    #[ORM\Column(type: 'uuid')]
    private Uuid $id;

    #[ORM\ManyToOne(targetEntity: ArticleTypePlatformConfig::class, inversedBy: 'attributeMappings')]
    #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
    private ArticleTypePlatformConfig $platformConfig;

    #[ORM\ManyToOne(targetEntity: AttributeDefinition::class)]
    #[ORM\JoinColumn(nullable: false)]
    private AttributeDefinition $attributeDefinition;

    #[ORM\ManyToOne(targetEntity: ChannelField::class)]
    #[ORM\JoinColumn(nullable: false)]
    private ChannelField $channelField;

    #[ORM\Column(type: 'string', length: 100, nullable: true)]
    private ?string $transformer = null;

    public function __construct(
        ArticleTypePlatformConfig $platformConfig,
        AttributeDefinition $attributeDefinition,
        ChannelField $channelField,
    ) {
        $this->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
// src/Domain/Channel/ArticleTypePlatformConfig.php
declare(strict_types=1);

namespace App\Domain\Channel;

use App\Domain\Article\ArticleType;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;

#[ORM\Entity]
#[ORM\Table(name: 'article_type_platform_configs', schema: 'app')]
#[ORM\UniqueConstraint(columns: ['article_type_id', 'platform_id'])]
class ArticleTypePlatformConfig
{
    #[ORM\Id]
    #[ORM\Column(type: 'uuid')]
    private Uuid $id;

    #[ORM\ManyToOne(targetEntity: ArticleType::class)]
    #[ORM\JoinColumn(nullable: false)]
    private ArticleType $articleType;

    #[ORM\ManyToOne(targetEntity: Platform::class)]
    #[ORM\JoinColumn(nullable: false)]
    private Platform $platform;

    #[ORM\Column(type: 'string', length: 255)]
    private string $categoryId;

    /** @var Collection<int, AttributeMapping> */
    #[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<int, AttributeMapping> */
    public function getAttributeMappings(): Collection { return $this->attributeMappings; }
}
  • Step 5: PHPStan + CS Fixer
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
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
// tests/Unit/Domain/Order/CustomerTest.php
declare(strict_types=1);

namespace App\Tests\Unit\Domain\Order;

use App\Domain\Order\Customer;
use PHPUnit\Framework\TestCase;

final class CustomerTest extends TestCase
{
    public function test_new_customer_has_empty_platform_ids(): void
    {
        $customer = new Customer('Max Mustermann', 'max@example.com', []);

        $this->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
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
// src/Domain/Order/Customer.php
declare(strict_types=1);

namespace App\Domain\Order;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;

#[ORM\Entity]
#[ORM\Table(name: 'customers', schema: 'app')]
class Customer
{
    #[ORM\Id]
    #[ORM\Column(type: 'uuid')]
    private Uuid $id;

    #[ORM\Column(type: 'string', length: 255)]
    private string $name;

    #[ORM\Column(type: 'string', length: 255)]
    private string $email;

    /** @var array<string, string> */
    #[ORM\Column(type: 'json')]
    private array $address;

    #[ORM\Column(type: 'string', length: 255, nullable: true)]
    private ?string $frappeCustomerId = null;

    /** @var array<string, string> platform => id, e.g. {"ebay": "user123"} */
    #[ORM\Column(type: 'json')]
    private array $platformIds = [];

    /**
     * @param array<string, string> $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<string, string> */
    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<string, string> */
    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
// src/Domain/Order/Invoice.php
declare(strict_types=1);

namespace App\Domain\Order;

use App\Domain\Storage\StoragePath;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;

#[ORM\Entity]
#[ORM\Table(name: 'invoices', schema: 'app')]
class Invoice
{
    #[ORM\Id]
    #[ORM\Column(type: 'uuid')]
    private Uuid $id;

    #[ORM\OneToOne(targetEntity: Order::class, inversedBy: 'invoice')]
    #[ORM\JoinColumn(nullable: false)]
    private Order $order;

    #[ORM\Column(type: 'string', length: 255)]
    private string $frappeInvoiceId;

    #[ORM\ManyToOne(targetEntity: StoragePath::class)]
    #[ORM\JoinColumn(nullable: false)]
    private StoragePath $storagePath;

    #[ORM\Column(type: 'string', length: 500)]
    private string $filename;

    #[ORM\Column(type: 'datetime_immutable')]
    private \DateTimeImmutable $createdAt;

    #[ORM\Column(type: 'datetime_immutable', nullable: true)]
    private ?\DateTimeImmutable $emailedAt = null;

    public function __construct(
        Order $order,
        string $frappeInvoiceId,
        StoragePath $storagePath,
        string $filename,
    ) {
        $this->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
// src/Domain/Order/Order.php
declare(strict_types=1);

namespace App\Domain\Order;

use App\Domain\Article\Article;
use App\Domain\Channel\Platform;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;

#[ORM\Entity]
#[ORM\Table(name: 'orders', schema: 'app')]
class Order
{
    #[ORM\Id]
    #[ORM\Column(type: 'uuid')]
    private Uuid $id;

    #[ORM\ManyToOne(targetEntity: Article::class)]
    #[ORM\JoinColumn(nullable: false)]
    private Article $article;

    #[ORM\ManyToOne(targetEntity: Customer::class)]
    #[ORM\JoinColumn(nullable: false)]
    private Customer $customer;

    #[ORM\ManyToOne(targetEntity: Platform::class)]
    #[ORM\JoinColumn(nullable: false)]
    private Platform $platform;

    #[ORM\Column(type: 'string', length: 255)]
    private string $platformOrderId;

    #[ORM\Column(type: 'string', enumType: OrderStatus::class)]
    private OrderStatus $status;

    #[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
    private string $salePrice;

    #[ORM\Column(type: 'datetime_immutable')]
    private \DateTimeImmutable $saleDate;

    #[ORM\Column(type: 'string', length: 255, nullable: true)]
    private ?string $trackingNumber = null;

    #[ORM\Column(type: 'string', length: 100, nullable: true)]
    private ?string $carrier = null;

    #[ORM\Column(type: 'datetime_immutable', nullable: true)]
    private ?\DateTimeImmutable $shippedAt = null;

    #[ORM\Column(type: 'datetime_immutable', nullable: true)]
    private ?\DateTimeImmutable $trackingPushedToEbayAt = null;

    #[ORM\OneToOne(mappedBy: 'order', targetEntity: Invoice::class, cascade: ['persist'])]
    private ?Invoice $invoice = null;

    public function __construct(
        Article $article,
        Customer $customer,
        Platform $platform,
        string $platformOrderId,
        string $salePrice,
        \DateTimeImmutable $saleDate,
    ) {
        $this->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
// src/Domain/Pipeline/AIPipelineJob.php
declare(strict_types=1);

namespace App\Domain\Pipeline;

use App\Domain\Article\Article;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;

#[ORM\Entity]
#[ORM\Table(name: 'ai_pipeline_jobs', schema: 'app')]
class AIPipelineJob
{
    #[ORM\Id]
    #[ORM\Column(type: 'uuid')]
    private Uuid $id;

    #[ORM\Column(type: 'string', enumType: AIPipelineJobType::class)]
    private AIPipelineJobType $type;

    #[ORM\ManyToOne(targetEntity: Article::class)]
    #[ORM\JoinColumn(nullable: true)]
    private ?Article $article = null;

    #[ORM\Column(type: 'string', enumType: AIPipelineJobStatus::class)]
    private AIPipelineJobStatus $status;

    #[ORM\Column(type: 'integer')]
    private int $attemptCount = 0;

    /** @var array<string, mixed> */
    #[ORM\Column(type: 'json')]
    private array $inputData;

    /** @var array<string, mixed> */
    #[ORM\Column(type: 'json')]
    private array $outputData = [];

    /** @var list<string> */
    #[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<string, mixed> $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<string, mixed> */
    public function getInputData(): array { return $this->inputData; }

    /** @return array<string, mixed> */
    public function getOutputData(): array { return $this->outputData; }

    /** @param array<string, mixed> $outputData */
    public function setOutputData(array $outputData): void { $this->outputData = $outputData; }

    /** @return list<string> */
    public function getMissingFields(): array { return $this->missingFields; }

    /** @param list<string> $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
// src/Domain/Auth/User.php
declare(strict_types=1);

namespace App\Domain\Auth;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Uid\Uuid;

#[ORM\Entity]
#[ORM\Table(name: 'users', schema: 'app')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
    #[ORM\Id]
    #[ORM\Column(type: 'uuid')]
    private Uuid $id;

    #[ORM\Column(type: 'string', length: 255, unique: true)]
    private string $email;

    #[ORM\Column(type: 'string')]
    private string $passwordHash;

    #[ORM\Column(type: 'string', nullable: true)]
    private ?string $totpSecret = null;

    /** @var array<string, bool> */
    #[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<string, bool> */
    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
// src/Domain/Auth/ApiKey.php
declare(strict_types=1);

namespace App\Domain\Auth;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;

#[ORM\Entity]
#[ORM\Table(name: 'api_keys', schema: 'app')]
class ApiKey
{
    #[ORM\Id]
    #[ORM\Column(type: 'uuid')]
    private Uuid $id;

    #[ORM\ManyToOne(targetEntity: User::class)]
    #[ORM\JoinColumn(nullable: false)]
    private User $user;

    #[ORM\Column(type: 'string', length: 255)]
    private string $label;

    #[ORM\Column(type: 'string', length: 255, unique: true)]
    private string $keyHash;

    /** @var array<string, bool> */
    #[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<string, bool> */
    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
docker compose run --rm app ./vendor/bin/pest tests/Unit/Domain/Order/CustomerTest.php
# Expected: PASS (3 tests)
  • Step 9: Commit
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
// src/Domain/Article/Repository/ArticleRepositoryInterface.php
declare(strict_types=1);

namespace App\Domain\Article\Repository;

use App\Domain\Article\Article;
use App\Domain\Article\ArticleStatus;
use Symfony\Component\Uid\Uuid;

interface ArticleRepositoryInterface
{
    public function findById(Uuid $id): ?Article;

    public function findBySku(string $sku): ?Article;

    public function findByInventoryNumber(string $inventoryNumber): ?Article;

    /** @return list<Article> */
    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
// src/Domain/Article/Repository/ArticleTypeRepositoryInterface.php
declare(strict_types=1);

namespace App\Domain\Article\Repository;

use App\Domain\Article\ArticleType;
use Symfony\Component\Uid\Uuid;

interface ArticleTypeRepositoryInterface
{
    public function findById(Uuid $id): ?ArticleType;

    public function findByName(string $name): ?ArticleType;

    /** @return list<ArticleType> */
    public function findAll(): array;

    public function save(ArticleType $articleType): void;
}
  • Step 3: PlatformRepositoryInterface schreiben
<?php
// src/Domain/Channel/Repository/PlatformRepositoryInterface.php
declare(strict_types=1);

namespace App\Domain\Channel\Repository;

use App\Domain\Channel\Platform;
use Symfony\Component\Uid\Uuid;

interface PlatformRepositoryInterface
{
    public function findById(Uuid $id): ?Platform;

    public function findByType(string $type): ?Platform;

    /** @return list<Platform> */
    public function findAll(): array;

    public function save(Platform $platform): void;
}
  • Step 4: CustomerRepositoryInterface schreiben
<?php
// src/Domain/Order/Repository/CustomerRepositoryInterface.php
declare(strict_types=1);

namespace App\Domain\Order\Repository;

use App\Domain\Order\Customer;
use Symfony\Component\Uid\Uuid;

interface CustomerRepositoryInterface
{
    public function findById(Uuid $id): ?Customer;

    public function findByPlatformId(string $platform, string $platformUserId): ?Customer;

    /**
     * Exact lowercase match on name + street + city + zip.
     * Used for cross-platform deduplication only.
     */
    public function findByMatchingKey(string $matchingKey): ?Customer;

    public function save(Customer $customer): void;
}
  • Step 5: OrderRepositoryInterface schreiben
<?php
// src/Domain/Order/Repository/OrderRepositoryInterface.php
declare(strict_types=1);

namespace App\Domain\Order\Repository;

use App\Domain\Order\Order;
use Symfony\Component\Uid\Uuid;

interface OrderRepositoryInterface
{
    public function findById(Uuid $id): ?Order;

    public function findByPlatformOrderId(string $platformOrderId): ?Order;

    public function save(Order $order): void;
}
  • Step 6: Commit
git add src/Domain/Article/Repository/ src/Domain/Channel/Repository/ src/Domain/Order/Repository/
git commit -m "feat: add repository interfaces (ports) for Article, ArticleType, Platform, Customer, Order"

Task 10: Doctrine-Repositories (Infrastructure)

Files:

  • Create: src/Infrastructure/Persistence/Repository/DoctrineArticleRepository.php

  • Create: src/Infrastructure/Persistence/Repository/DoctrineArticleTypeRepository.php

  • Create: src/Infrastructure/Persistence/Repository/DoctrinePlatformRepository.php

  • Create: src/Infrastructure/Persistence/Repository/DoctrineCustomerRepository.php

  • Create: src/Infrastructure/Persistence/Repository/DoctrineOrderRepository.php

  • Modify: config/services.yaml

  • Step 1: DoctrineArticleRepository schreiben

<?php
// src/Infrastructure/Persistence/Repository/DoctrineArticleRepository.php
declare(strict_types=1);

namespace App\Infrastructure\Persistence\Repository;

use App\Domain\Article\Article;
use App\Domain\Article\ArticleStatus;
use App\Domain\Article\Repository\ArticleRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Uid\Uuid;

final class DoctrineArticleRepository implements ArticleRepositoryInterface
{
    public function __construct(private readonly EntityManagerInterface $em) {}

    public function findById(Uuid $id): ?Article
    {
        return $this->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<Article> */
    public function findByStatus(ArticleStatus $status): array
    {
        /** @var list<Article> */
        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
// src/Infrastructure/Persistence/Repository/DoctrineArticleTypeRepository.php
declare(strict_types=1);

namespace App\Infrastructure\Persistence\Repository;

use App\Domain\Article\ArticleType;
use App\Domain\Article\Repository\ArticleTypeRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Uid\Uuid;

final class DoctrineArticleTypeRepository implements ArticleTypeRepositoryInterface
{
    public function __construct(private readonly EntityManagerInterface $em) {}

    public function findById(Uuid $id): ?ArticleType
    {
        return $this->em->find(ArticleType::class, $id);
    }

    public function findByName(string $name): ?ArticleType
    {
        return $this->em->getRepository(ArticleType::class)->findOneBy(['name' => $name]);
    }

    /** @return list<ArticleType> */
    public function findAll(): array
    {
        /** @var list<ArticleType> */
        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
// src/Infrastructure/Persistence/Repository/DoctrineCustomerRepository.php
declare(strict_types=1);

namespace App\Infrastructure\Persistence\Repository;

use App\Domain\Order\Customer;
use App\Domain\Order\Repository\CustomerRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Uid\Uuid;

final class DoctrineCustomerRepository implements CustomerRepositoryInterface
{
    public function __construct(private readonly EntityManagerInterface $em) {}

    public function findById(Uuid $id): ?Customer
    {
        return $this->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
// src/Infrastructure/Persistence/Repository/DoctrineOrderRepository.php
declare(strict_types=1);

namespace App\Infrastructure\Persistence\Repository;

use App\Domain\Order\Order;
use App\Domain\Order\Repository\OrderRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Uid\Uuid;

final class DoctrineOrderRepository implements OrderRepositoryInterface
{
    public function __construct(private readonly EntityManagerInterface $em) {}

    public function findById(Uuid $id): ?Order
    {
        return $this->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
// src/Infrastructure/Persistence/Repository/DoctrinePlatformRepository.php
declare(strict_types=1);

namespace App\Infrastructure\Persistence\Repository;

use App\Domain\Channel\Platform;
use App\Domain\Channel\Repository\PlatformRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Uid\Uuid;

final class DoctrinePlatformRepository implements PlatformRepositoryInterface
{
    public function __construct(private readonly EntityManagerInterface $em) {}

    public function findById(Uuid $id): ?Platform
    {
        return $this->em->find(Platform::class, $id);
    }

    public function findByType(string $type): ?Platform
    {
        return $this->em->getRepository(Platform::class)->findOneBy(['type' => $type]);
    }

    /** @return list<Platform> */
    public function findAll(): array
    {
        /** @var list<Platform> */
        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
# 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
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

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
// migrations/Version20260513000001.php
declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260513000001 extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Create app, logs, logs_archive schemas and all base tables';
    }

    public function up(Schema $schema): void
    {
        $this->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
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
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
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
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 26):

  • 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)