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