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

3094 lines
91 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
<?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**
```xml
<!-- 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**
```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
<?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**
```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
<?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
<?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
<?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
<?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
<?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
<?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**
```bash
docker compose run --rm app ./vendor/bin/pest tests/Unit/Domain/Article/ArticleStatusTest.php
# Expected: PASS (2 tests)
```
- [ ] **Step 6: Commit**
```bash
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
<?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**
```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
<?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
<?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
<?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
<?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
<?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
<?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**
```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
<?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
<?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
<?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
<?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**
```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
<?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**
```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
<?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
<?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
<?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
<?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
<?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
<?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**
```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
<?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
<?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
<?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
<?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
<?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**
```bash
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
<?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
<?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
<?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
<?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
<?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**
```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
<?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**
```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 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)