3095 lines
91 KiB
Markdown
3095 lines
91 KiB
Markdown
|
|
# 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 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)
|