PHPUnit config (phpunit.dist.xml, bin/phpunit, bootstrap.php), PHP CS Fixer config, .editorconfig. Separate .env.dev/.env.test templates. Ollama tunnel setup script. Architecture and plan docs. Updated application-layer unit tests to match current service signatures. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3094 lines
91 KiB
Markdown
3094 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)
|