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