SuperSeller3000/docs/superpowers/plans/2026-05-13-02-article-api.md
Simon Kuehn f55e96b094 chore: add tooling config, test bootstrap, env templates and docs
PHPUnit config (phpunit.dist.xml, bin/phpunit, bootstrap.php), PHP CS
Fixer config, .editorconfig. Separate .env.dev/.env.test templates.
Ollama tunnel setup script. Architecture and plan docs. Updated
application-layer unit tests to match current service signatures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 22:44:16 +00:00

82 KiB

SuperSeller3000 — Plan 2: Artikel-Management API

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: REST-API für vollständiges Artikel-Management: ArticleType/AttributeDefinition-CRUD, Platform/ChannelField/Mapping-Konfiguration, StorageManager mit Multi-Path-Quota-Support, Foto-Upload, Artikel-CRUD mit Pflichtfeld-Validierung.

Architecture: Application-Layer plain Service-Klassen (keine Bus-Abhängigkeit für synchrone Operationen). Controller in Infrastructure/Http validieren JSON-Input, delegieren an Application-Services, antworten mit JsonResponse. StorageManager wählt aktiven StoragePath mit höchster Priorität, der noch Quota hat.

Tech Stack: PHP 8.4, Symfony 7, Doctrine ORM, PHPUnit 11 + Pest 3, PHPStan Level 9


Dateistruktur (gesamter Plan)

src/
  Domain/
    Article/
      Repository/
        ArticlePhotoRepositoryInterface.php        # new
        AttributeValueRepositoryInterface.php      # new
    Storage/
      Repository/
        StoragePathRepositoryInterface.php         # new
    Channel/
      Repository/
        ChannelFieldRepositoryInterface.php                  # new
        ArticleTypePlatformConfigRepositoryInterface.php     # new
        AttributeMappingRepositoryInterface.php              # new
  Application/
    Article/
      ArticleTypeService.php
      ArticleService.php
      ArticleValidator.php
      PhotoService.php
    Channel/
      PlatformService.php
      MappingService.php
    Storage/
      StorageManagerInterface.php
      StoredFile.php                               # value object
  Infrastructure/
    Persistence/
      Repository/
        DoctrineStoragePathRepository.php
        DoctrineArticlePhotoRepository.php
        DoctrineAttributeValueRepository.php
        DoctrineChannelFieldRepository.php
        DoctrineArticleTypePlatformConfigRepository.php
        DoctrineAttributeMappingRepository.php
    Http/
      Controller/
        Api/
          ArticleTypeController.php
          ArticleController.php
          PhotoController.php
          PlatformController.php
          MappingController.php
    Storage/
      LocalStorageManager.php
migrations/
  Version20260513000002.php                        # inventory_seq
config/
  routes/
    api.yaml                                       # route prefix /api
tests/
  Unit/
    Application/
      Article/
        ArticleValidatorTest.php
        ArticleTypeServiceTest.php
      Storage/
        LocalStorageManagerTest.php

Task 1: Fehlende Repository-Interfaces + Doctrine-Implementierungen

Files:

  • Create: src/Domain/Storage/Repository/StoragePathRepositoryInterface.php

  • Create: src/Domain/Article/Repository/ArticlePhotoRepositoryInterface.php

  • Create: src/Domain/Article/Repository/AttributeValueRepositoryInterface.php

  • Create: src/Domain/Channel/Repository/ChannelFieldRepositoryInterface.php

  • Create: src/Domain/Channel/Repository/ArticleTypePlatformConfigRepositoryInterface.php

  • Create: src/Domain/Channel/Repository/AttributeMappingRepositoryInterface.php

  • Create: src/Infrastructure/Persistence/Repository/DoctrineStoragePathRepository.php

  • Create: src/Infrastructure/Persistence/Repository/DoctrineArticlePhotoRepository.php

  • Create: src/Infrastructure/Persistence/Repository/DoctrineAttributeValueRepository.php

  • Create: src/Infrastructure/Persistence/Repository/DoctrineChannelFieldRepository.php

  • Create: src/Infrastructure/Persistence/Repository/DoctrineArticleTypePlatformConfigRepository.php

  • Create: src/Infrastructure/Persistence/Repository/DoctrineAttributeMappingRepository.php

  • Modify: config/services.yaml

  • Step 1: Domain-Interfaces schreiben

<?php
// src/Domain/Storage/Repository/StoragePathRepositoryInterface.php
declare(strict_types=1);

namespace App\Domain\Storage\Repository;

use App\Domain\Storage\StoragePath;
use Symfony\Component\Uid\Uuid;

interface StoragePathRepositoryInterface
{
    public function findById(Uuid $id): ?StoragePath;

    /** @return list<StoragePath> active paths ordered by priority DESC */
    public function findActiveSortedByPriority(): array;

    public function save(StoragePath $storagePath): void;
}
<?php
// src/Domain/Article/Repository/ArticlePhotoRepositoryInterface.php
declare(strict_types=1);

namespace App\Domain\Article\Repository;

use App\Domain\Article\ArticlePhoto;
use Symfony\Component\Uid\Uuid;

interface ArticlePhotoRepositoryInterface
{
    public function findById(Uuid $id): ?ArticlePhoto;

    /** @return list<ArticlePhoto> */
    public function findByArticle(Uuid $articleId): array;

    public function save(ArticlePhoto $photo): void;

    public function remove(ArticlePhoto $photo): void;
}
<?php
// src/Domain/Article/Repository/AttributeValueRepositoryInterface.php
declare(strict_types=1);

namespace App\Domain\Article\Repository;

use App\Domain\Article\AttributeValue;
use Symfony\Component\Uid\Uuid;

interface AttributeValueRepositoryInterface
{
    /** @return list<AttributeValue> */
    public function findByArticle(Uuid $articleId): array;

    public function findByArticleAndDefinition(Uuid $articleId, Uuid $definitionId): ?AttributeValue;

    public function save(AttributeValue $value): void;

    public function remove(AttributeValue $value): void;
}
<?php
// src/Domain/Channel/Repository/ChannelFieldRepositoryInterface.php
declare(strict_types=1);

namespace App\Domain\Channel\Repository;

use App\Domain\Channel\ChannelField;
use Symfony\Component\Uid\Uuid;

interface ChannelFieldRepositoryInterface
{
    public function findById(Uuid $id): ?ChannelField;

    /** @return list<ChannelField> */
    public function findByPlatform(Uuid $platformId): array;

    public function save(ChannelField $field): void;

    public function remove(ChannelField $field): void;
}
<?php
// src/Domain/Channel/Repository/ArticleTypePlatformConfigRepositoryInterface.php
declare(strict_types=1);

namespace App\Domain\Channel\Repository;

use App\Domain\Channel\ArticleTypePlatformConfig;
use Symfony\Component\Uid\Uuid;

interface ArticleTypePlatformConfigRepositoryInterface
{
    public function findById(Uuid $id): ?ArticleTypePlatformConfig;

    public function findByArticleTypeAndPlatform(Uuid $articleTypeId, Uuid $platformId): ?ArticleTypePlatformConfig;

    /** @return list<ArticleTypePlatformConfig> */
    public function findByArticleType(Uuid $articleTypeId): array;

    public function save(ArticleTypePlatformConfig $config): void;

    public function remove(ArticleTypePlatformConfig $config): void;
}
<?php
// src/Domain/Channel/Repository/AttributeMappingRepositoryInterface.php
declare(strict_types=1);

namespace App\Domain\Channel\Repository;

use App\Domain\Channel\AttributeMapping;
use Symfony\Component\Uid\Uuid;

interface AttributeMappingRepositoryInterface
{
    public function findById(Uuid $id): ?AttributeMapping;

    /** @return list<AttributeMapping> */
    public function findByPlatformConfig(Uuid $platformConfigId): array;

    public function save(AttributeMapping $mapping): void;

    public function remove(AttributeMapping $mapping): void;
}
  • Step 2: Doctrine-Repositories schreiben
<?php
// src/Infrastructure/Persistence/Repository/DoctrineStoragePathRepository.php
declare(strict_types=1);

namespace App\Infrastructure\Persistence\Repository;

use App\Domain\Storage\Repository\StoragePathRepositoryInterface;
use App\Domain\Storage\StoragePath;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Uid\Uuid;

final class DoctrineStoragePathRepository implements StoragePathRepositoryInterface
{
    public function __construct(private readonly EntityManagerInterface $em) {}

    public function findById(Uuid $id): ?StoragePath
    {
        return $this->em->find(StoragePath::class, $id);
    }

    /** @return list<StoragePath> */
    public function findActiveSortedByPriority(): array
    {
        /** @var list<StoragePath> */
        return $this->em->getRepository(StoragePath::class)
            ->createQueryBuilder('s')
            ->where('s.isActive = :active')
            ->setParameter('active', true)
            ->orderBy('s.priority', 'DESC')
            ->getQuery()
            ->getResult();
    }

    public function save(StoragePath $storagePath): void
    {
        $this->em->persist($storagePath);
        $this->em->flush();
    }
}
<?php
// src/Infrastructure/Persistence/Repository/DoctrineArticlePhotoRepository.php
declare(strict_types=1);

namespace App\Infrastructure\Persistence\Repository;

use App\Domain\Article\ArticlePhoto;
use App\Domain\Article\Repository\ArticlePhotoRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Uid\Uuid;

final class DoctrineArticlePhotoRepository implements ArticlePhotoRepositoryInterface
{
    public function __construct(private readonly EntityManagerInterface $em) {}

    public function findById(Uuid $id): ?ArticlePhoto
    {
        return $this->em->find(ArticlePhoto::class, $id);
    }

    /** @return list<ArticlePhoto> */
    public function findByArticle(Uuid $articleId): array
    {
        /** @var list<ArticlePhoto> */
        return $this->em->getRepository(ArticlePhoto::class)
            ->createQueryBuilder('p')
            ->where('IDENTITY(p.article) = :articleId')
            ->setParameter('articleId', $articleId->toRfc4122())
            ->orderBy('p.sortOrder', 'ASC')
            ->getQuery()
            ->getResult();
    }

    public function save(ArticlePhoto $photo): void
    {
        $this->em->persist($photo);
        $this->em->flush();
    }

    public function remove(ArticlePhoto $photo): void
    {
        $this->em->remove($photo);
        $this->em->flush();
    }
}
<?php
// src/Infrastructure/Persistence/Repository/DoctrineAttributeValueRepository.php
declare(strict_types=1);

namespace App\Infrastructure\Persistence\Repository;

use App\Domain\Article\AttributeValue;
use App\Domain\Article\Repository\AttributeValueRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Uid\Uuid;

final class DoctrineAttributeValueRepository implements AttributeValueRepositoryInterface
{
    public function __construct(private readonly EntityManagerInterface $em) {}

    /** @return list<AttributeValue> */
    public function findByArticle(Uuid $articleId): array
    {
        /** @var list<AttributeValue> */
        return $this->em->getRepository(AttributeValue::class)
            ->createQueryBuilder('v')
            ->where('IDENTITY(v.article) = :articleId')
            ->setParameter('articleId', $articleId->toRfc4122())
            ->getQuery()
            ->getResult();
    }

    public function findByArticleAndDefinition(Uuid $articleId, Uuid $definitionId): ?AttributeValue
    {
        return $this->em->getRepository(AttributeValue::class)
            ->createQueryBuilder('v')
            ->where('IDENTITY(v.article) = :articleId')
            ->andWhere('IDENTITY(v.attributeDefinition) = :defId')
            ->setParameter('articleId', $articleId->toRfc4122())
            ->setParameter('defId', $definitionId->toRfc4122())
            ->setMaxResults(1)
            ->getQuery()
            ->getOneOrNullResult();
    }

    public function save(AttributeValue $value): void
    {
        $this->em->persist($value);
        $this->em->flush();
    }

    public function remove(AttributeValue $value): void
    {
        $this->em->remove($value);
        $this->em->flush();
    }
}
<?php
// src/Infrastructure/Persistence/Repository/DoctrineChannelFieldRepository.php
declare(strict_types=1);

namespace App\Infrastructure\Persistence\Repository;

use App\Domain\Channel\ChannelField;
use App\Domain\Channel\Repository\ChannelFieldRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Uid\Uuid;

final class DoctrineChannelFieldRepository implements ChannelFieldRepositoryInterface
{
    public function __construct(private readonly EntityManagerInterface $em) {}

    public function findById(Uuid $id): ?ChannelField
    {
        return $this->em->find(ChannelField::class, $id);
    }

    /** @return list<ChannelField> */
    public function findByPlatform(Uuid $platformId): array
    {
        /** @var list<ChannelField> */
        return $this->em->getRepository(ChannelField::class)
            ->createQueryBuilder('f')
            ->where('IDENTITY(f.platform) = :platformId')
            ->setParameter('platformId', $platformId->toRfc4122())
            ->orderBy('f.label', 'ASC')
            ->getQuery()
            ->getResult();
    }

    public function save(ChannelField $field): void
    {
        $this->em->persist($field);
        $this->em->flush();
    }

    public function remove(ChannelField $field): void
    {
        $this->em->remove($field);
        $this->em->flush();
    }
}
<?php
// src/Infrastructure/Persistence/Repository/DoctrineArticleTypePlatformConfigRepository.php
declare(strict_types=1);

namespace App\Infrastructure\Persistence\Repository;

use App\Domain\Channel\ArticleTypePlatformConfig;
use App\Domain\Channel\Repository\ArticleTypePlatformConfigRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Uid\Uuid;

final class DoctrineArticleTypePlatformConfigRepository implements ArticleTypePlatformConfigRepositoryInterface
{
    public function __construct(private readonly EntityManagerInterface $em) {}

    public function findById(Uuid $id): ?ArticleTypePlatformConfig
    {
        return $this->em->find(ArticleTypePlatformConfig::class, $id);
    }

    public function findByArticleTypeAndPlatform(Uuid $articleTypeId, Uuid $platformId): ?ArticleTypePlatformConfig
    {
        return $this->em->getRepository(ArticleTypePlatformConfig::class)
            ->createQueryBuilder('c')
            ->where('IDENTITY(c.articleType) = :typeId')
            ->andWhere('IDENTITY(c.platform) = :platformId')
            ->setParameter('typeId', $articleTypeId->toRfc4122())
            ->setParameter('platformId', $platformId->toRfc4122())
            ->setMaxResults(1)
            ->getQuery()
            ->getOneOrNullResult();
    }

    /** @return list<ArticleTypePlatformConfig> */
    public function findByArticleType(Uuid $articleTypeId): array
    {
        /** @var list<ArticleTypePlatformConfig> */
        return $this->em->getRepository(ArticleTypePlatformConfig::class)
            ->createQueryBuilder('c')
            ->where('IDENTITY(c.articleType) = :typeId')
            ->setParameter('typeId', $articleTypeId->toRfc4122())
            ->getQuery()
            ->getResult();
    }

    public function save(ArticleTypePlatformConfig $config): void
    {
        $this->em->persist($config);
        $this->em->flush();
    }

    public function remove(ArticleTypePlatformConfig $config): void
    {
        $this->em->remove($config);
        $this->em->flush();
    }
}
<?php
// src/Infrastructure/Persistence/Repository/DoctrineAttributeMappingRepository.php
declare(strict_types=1);

namespace App\Infrastructure\Persistence\Repository;

use App\Domain\Channel\AttributeMapping;
use App\Domain\Channel\Repository\AttributeMappingRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Uid\Uuid;

final class DoctrineAttributeMappingRepository implements AttributeMappingRepositoryInterface
{
    public function __construct(private readonly EntityManagerInterface $em) {}

    public function findById(Uuid $id): ?AttributeMapping
    {
        return $this->em->find(AttributeMapping::class, $id);
    }

    /** @return list<AttributeMapping> */
    public function findByPlatformConfig(Uuid $platformConfigId): array
    {
        /** @var list<AttributeMapping> */
        return $this->em->getRepository(AttributeMapping::class)
            ->createQueryBuilder('m')
            ->where('IDENTITY(m.platformConfig) = :configId')
            ->setParameter('configId', $platformConfigId->toRfc4122())
            ->getQuery()
            ->getResult();
    }

    public function save(AttributeMapping $mapping): void
    {
        $this->em->persist($mapping);
        $this->em->flush();
    }

    public function remove(AttributeMapping $mapping): void
    {
        $this->em->remove($mapping);
        $this->em->flush();
    }
}
  • Step 3: services.yaml mit neuen Aliases erweitern

Ergänze config/services.yaml:

    App\Domain\Storage\Repository\StoragePathRepositoryInterface:
        alias: App\Infrastructure\Persistence\Repository\DoctrineStoragePathRepository

    App\Domain\Article\Repository\ArticlePhotoRepositoryInterface:
        alias: App\Infrastructure\Persistence\Repository\DoctrineArticlePhotoRepository

    App\Domain\Article\Repository\AttributeValueRepositoryInterface:
        alias: App\Infrastructure\Persistence\Repository\DoctrineAttributeValueRepository

    App\Domain\Channel\Repository\ChannelFieldRepositoryInterface:
        alias: App\Infrastructure\Persistence\Repository\DoctrineChannelFieldRepository

    App\Domain\Channel\Repository\ArticleTypePlatformConfigRepositoryInterface:
        alias: App\Infrastructure\Persistence\Repository\DoctrineArticleTypePlatformConfigRepository

    App\Domain\Channel\Repository\AttributeMappingRepositoryInterface:
        alias: App\Infrastructure\Persistence\Repository\DoctrineAttributeMappingRepository
  • Step 4: PHPStan + CS Fixer
docker compose run --rm app ./vendor/bin/phpstan analyse src/ --no-progress
# Expected: No errors

docker compose run --rm app ./vendor/bin/php-cs-fixer fix src/ --dry-run --diff
# Expected: keine Änderungen
  • Step 5: Commit
git add src/Domain/ src/Infrastructure/Persistence/ config/services.yaml
git commit -m "feat: add missing repository interfaces and Doctrine implementations (StoragePath, ArticlePhoto, AttributeValue, ChannelField, ArticleTypePlatformConfig, AttributeMapping)"

Task 2: StorageManager

Files:

  • Create: src/Application/Storage/StorageManagerInterface.php

  • Create: src/Application/Storage/StoredFile.php

  • Create: src/Infrastructure/Storage/LocalStorageManager.php

  • Test: tests/Unit/Application/Storage/LocalStorageManagerTest.php

  • Step 1: Failing-Test schreiben

<?php
// tests/Unit/Application/Storage/LocalStorageManagerTest.php
declare(strict_types=1);

namespace App\Tests\Unit\Application\Storage;

use App\Domain\Storage\Repository\StoragePathRepositoryInterface;
use App\Domain\Storage\StoragePath;
use App\Infrastructure\Storage\LocalStorageManager;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

final class LocalStorageManagerTest extends TestCase
{
    private StoragePathRepositoryInterface&MockObject $repo;
    private LocalStorageManager $manager;
    private string $tmpFile;

    protected function setUp(): void
    {
        $this->repo = $this->createMock(StoragePathRepositoryInterface::class);
        $this->manager = new LocalStorageManager($this->repo);
        $this->tmpFile = sys_get_temp_dir().'/test-upload-'.uniqid().'.jpg';
        file_put_contents($this->tmpFile, str_repeat('x', 100));
    }

    protected function tearDown(): void
    {
        if (file_exists($this->tmpFile)) {
            unlink($this->tmpFile);
        }
    }

    public function test_store_picks_active_path_with_quota(): void
    {
        $path = new StoragePath('Main', sys_get_temp_dir().'/storage-test-'.uniqid(), 1_000_000, 10);
        mkdir($path->getBasePath(), recursive: true);

        $this->repo->expects($this->once())
            ->method('findActiveSortedByPriority')
            ->willReturn([$path]);

        $stored = $this->manager->store($this->tmpFile, 'photo.jpg');

        $this->assertSame($path->getId()->toRfc4122(), $stored->storagePath->getId()->toRfc4122());
        $this->assertStringEndsWith('.jpg', $stored->filename);
        $this->assertFileExists($path->getBasePath().'/'.$stored->filename);

        // cleanup
        unlink($path->getBasePath().'/'.$stored->filename);
        rmdir($path->getBasePath());
    }

    public function test_throws_when_no_active_path(): void
    {
        $this->repo->method('findActiveSortedByPriority')->willReturn([]);

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('No active storage path');

        $this->manager->store($this->tmpFile, 'photo.jpg');
    }

    public function test_skips_full_path_and_uses_next(): void
    {
        $fullPath = new StoragePath('Full', sys_get_temp_dir().'/full-'.uniqid(), 50, 20);
        $okPath   = new StoragePath('OK', sys_get_temp_dir().'/ok-'.uniqid(), 1_000_000, 10);
        mkdir($fullPath->getBasePath(), recursive: true);
        mkdir($okPath->getBasePath(), recursive: true);

        // fullPath quota (50 bytes) is less than file size (100 bytes) — skip it
        $this->repo->method('findActiveSortedByPriority')->willReturn([$fullPath, $okPath]);

        $stored = $this->manager->store($this->tmpFile, 'photo.jpg');

        $this->assertSame($okPath->getId()->toRfc4122(), $stored->storagePath->getId()->toRfc4122());

        unlink($okPath->getBasePath().'/'.$stored->filename);
        rmdir($fullPath->getBasePath());
        rmdir($okPath->getBasePath());
    }

    public function test_get_full_path(): void
    {
        $path = new StoragePath('Main', '/srv/storage', 1_000_000, 10);
        $this->assertSame('/srv/storage/photo.jpg', $this->manager->getFullPath($path, 'photo.jpg'));
    }
}
  • Step 2: Test ausführen — muss fehlschlagen
docker compose run --rm app ./vendor/bin/pest tests/Unit/Application/Storage/LocalStorageManagerTest.php
# Expected: FAIL — classes not found
  • Step 3: Interfaces und Value Object implementieren
<?php
// src/Application/Storage/StoredFile.php
declare(strict_types=1);

namespace App\Application\Storage;

use App\Domain\Storage\StoragePath;

final readonly class StoredFile
{
    public function __construct(
        public StoragePath $storagePath,
        public string $filename,
    ) {}
}
<?php
// src/Application/Storage/StorageManagerInterface.php
declare(strict_types=1);

namespace App\Application\Storage;

use App\Domain\Storage\StoragePath;

interface StorageManagerInterface
{
    /**
     * Moves $tempPath into the best available StoragePath.
     * $originalFilename is used for extension extraction only; actual filename is UUID-based.
     *
     * @throws \RuntimeException when no active storage path with quota available
     */
    public function store(string $tempPath, string $originalFilename): StoredFile;

    public function getFullPath(StoragePath $storagePath, string $filename): string;
}
  • Step 4: LocalStorageManager implementieren
<?php
// src/Infrastructure/Storage/LocalStorageManager.php
declare(strict_types=1);

namespace App\Infrastructure\Storage;

use App\Application\Storage\StorageManagerInterface;
use App\Application\Storage\StoredFile;
use App\Domain\Storage\Repository\StoragePathRepositoryInterface;
use App\Domain\Storage\StoragePath;
use Symfony\Component\Uid\Uuid;

final class LocalStorageManager implements StorageManagerInterface
{
    public function __construct(
        private readonly StoragePathRepositoryInterface $storagePathRepository,
    ) {}

    public function store(string $tempPath, string $originalFilename): StoredFile
    {
        $fileSize = filesize($tempPath);
        if (false === $fileSize) {
            throw new \RuntimeException("Cannot read file size for: {$tempPath}");
        }

        $paths = $this->storagePathRepository->findActiveSortedByPriority();

        foreach ($paths as $storagePath) {
            $used = $this->getUsedBytes($storagePath->getBasePath());
            $available = $storagePath->getQuotaBytes() - $used;

            if ($available < $fileSize) {
                continue;
            }

            $ext = \pathinfo($originalFilename, PATHINFO_EXTENSION);
            $filename = Uuid::v7()->toRfc4122().($ext !== '' ? '.'.$ext : '');
            $destination = $storagePath->getBasePath().'/'.$filename;

            if (!\is_dir($storagePath->getBasePath())) {
                \mkdir($storagePath->getBasePath(), 0755, true);
            }

            \rename($tempPath, $destination);

            return new StoredFile($storagePath, $filename);
        }

        throw new \RuntimeException('No active storage path with sufficient quota available');
    }

    public function getFullPath(StoragePath $storagePath, string $filename): string
    {
        return $storagePath->getBasePath().'/'.$filename;
    }

    private function getUsedBytes(string $directory): int
    {
        if (!\is_dir($directory)) {
            return 0;
        }

        $total = 0;
        $iterator = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS),
        );
        foreach ($iterator as $file) {
            $total += $file->getSize();
        }

        return $total;
    }
}
  • Step 5: services.yaml erweitern
    App\Application\Storage\StorageManagerInterface:
        alias: App\Infrastructure\Storage\LocalStorageManager
  • Step 6: Test ausführen — muss bestehen
docker compose run --rm app ./vendor/bin/pest tests/Unit/Application/Storage/LocalStorageManagerTest.php
# Expected: PASS (4 tests)
  • Step 7: Commit
git add src/Application/Storage/ src/Infrastructure/Storage/ config/services.yaml tests/Unit/Application/Storage/
git commit -m "feat: add StorageManager with multi-path quota-aware file storage"

Task 3: ArticleType & AttributeDefinition API

Files:

  • Create: src/Application/Article/ArticleTypeService.php

  • Create: src/Infrastructure/Http/Controller/Api/ArticleTypeController.php

  • Test: tests/Unit/Application/Article/ArticleTypeServiceTest.php

  • Step 1: Failing-Test schreiben

<?php
// tests/Unit/Application/Article/ArticleTypeServiceTest.php
declare(strict_types=1);

namespace App\Tests\Unit\Application\Article;

use App\Application\Article\ArticleTypeService;
use App\Domain\Article\ArticleType;
use App\Domain\Article\AttributeDefinition;
use App\Domain\Article\AttributeType;
use App\Domain\Article\Repository\ArticleTypeRepositoryInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

final class ArticleTypeServiceTest extends TestCase
{
    private ArticleTypeRepositoryInterface&MockObject $repo;
    private ArticleTypeService $service;

    protected function setUp(): void
    {
        $this->repo = $this->createMock(ArticleTypeRepositoryInterface::class);
        $this->service = new ArticleTypeService($this->repo);
    }

    public function test_create_saves_article_type(): void
    {
        $this->repo->expects($this->once())->method('save');

        $type = $this->service->create('Notebook');

        $this->assertSame('Notebook', $type->getName());
    }

    public function test_rename_updates_name(): void
    {
        $type = new ArticleType('Notebook');
        $this->repo->method('findById')->willReturn($type);
        $this->repo->expects($this->once())->method('save');

        $this->service->rename($type->getId(), 'Laptop');

        $this->assertSame('Laptop', $type->getName());
    }

    public function test_rename_throws_when_not_found(): void
    {
        $this->repo->method('findById')->willReturn(null);

        $this->expectException(\DomainException::class);

        $this->service->rename(\Symfony\Component\Uid\Uuid::v7(), 'X');
    }

    public function test_add_attribute_links_definition(): void
    {
        $type = new ArticleType('Notebook');
        $def  = new AttributeDefinition('RAM', AttributeType::String);
        $this->repo->method('findById')->willReturn($type);
        $this->repo->expects($this->once())->method('save');

        $this->service->addAttribute($type->getId(), $def);

        $this->assertCount(1, $type->getAttributeDefinitions());
    }
}
  • Step 2: Test ausführen — muss fehlschlagen
docker compose run --rm app ./vendor/bin/pest tests/Unit/Application/Article/ArticleTypeServiceTest.php
# Expected: FAIL — class ArticleTypeService not found
  • Step 3: ArticleTypeService implementieren
<?php
// src/Application/Article/ArticleTypeService.php
declare(strict_types=1);

namespace App\Application\Article;

use App\Domain\Article\ArticleType;
use App\Domain\Article\AttributeDefinition;
use App\Domain\Article\AttributeType;
use App\Domain\Article\Repository\ArticleTypeRepositoryInterface;
use Symfony\Component\Uid\Uuid;

final class ArticleTypeService
{
    public function __construct(
        private readonly ArticleTypeRepositoryInterface $articleTypeRepository,
    ) {}

    public function create(string $name): ArticleType
    {
        $type = new ArticleType($name);
        $this->articleTypeRepository->save($type);

        return $type;
    }

    /** @return list<ArticleType> */
    public function findAll(): array
    {
        return $this->articleTypeRepository->findAll();
    }

    public function findById(Uuid $id): ?ArticleType
    {
        return $this->articleTypeRepository->findById($id);
    }

    public function rename(Uuid $id, string $newName): ArticleType
    {
        $type = $this->articleTypeRepository->findById($id)
            ?? throw new \DomainException("ArticleType {$id->toRfc4122()} not found");

        $type->setName($newName);
        $this->articleTypeRepository->save($type);

        return $type;
    }

    public function createAttribute(string $name, AttributeType $type, ?string $unit = null, ?array $options = null): AttributeDefinition
    {
        $def = new AttributeDefinition($name, $type);
        $def->setUnit($unit);
        $def->setOptions($options);

        return $def;
    }

    public function addAttribute(Uuid $articleTypeId, AttributeDefinition $def): ArticleType
    {
        $type = $this->articleTypeRepository->findById($articleTypeId)
            ?? throw new \DomainException("ArticleType {$articleTypeId->toRfc4122()} not found");

        $type->addAttributeDefinition($def);
        $this->articleTypeRepository->save($type);

        return $type;
    }

    public function removeAttribute(Uuid $articleTypeId, Uuid $definitionId): void
    {
        $type = $this->articleTypeRepository->findById($articleTypeId)
            ?? throw new \DomainException("ArticleType {$articleTypeId->toRfc4122()} not found");

        foreach ($type->getAttributeDefinitions() as $def) {
            if ($def->getId()->equals($definitionId)) {
                $type->removeAttributeDefinition($def);
                break;
            }
        }

        $this->articleTypeRepository->save($type);
    }
}
  • Step 4: Test ausführen — muss bestehen
docker compose run --rm app ./vendor/bin/pest tests/Unit/Application/Article/ArticleTypeServiceTest.php
# Expected: PASS (4 tests)
  • Step 5: Route-Konfiguration anlegen
# config/routes/api.yaml
api:
    resource: '../src/Infrastructure/Http/Controller/Api/'
    type: attribute
    prefix: /api
  • Step 6: ArticleTypeController implementieren
<?php
// src/Infrastructure/Http/Controller/Api/ArticleTypeController.php
declare(strict_types=1);

namespace App\Infrastructure\Http\Controller\Api;

use App\Application\Article\ArticleTypeService;
use App\Domain\Article\AttributeType;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Uid\Uuid;

#[Route('/article-types', name: 'api_article_types_')]
final class ArticleTypeController extends AbstractController
{
    public function __construct(private readonly ArticleTypeService $service) {}

    #[Route('', name: 'list', methods: ['GET'])]
    public function list(): JsonResponse
    {
        $types = $this->service->findAll();

        return $this->json(\array_map(static fn ($t) => [
            'id'         => $t->getId()->toRfc4122(),
            'name'       => $t->getName(),
            'attributes' => \array_map(static fn ($a) => [
                'id'      => $a->getId()->toRfc4122(),
                'name'    => $a->getName(),
                'type'    => $a->getType()->value,
                'unit'    => $a->getUnit(),
                'options' => $a->getOptions(),
            ], $t->getAttributeDefinitions()->toArray()),
        ], $types));
    }

    #[Route('', name: 'create', methods: ['POST'])]
    public function create(Request $request): JsonResponse
    {
        $data = $request->toArray();
        if (empty($data['name'])) {
            return $this->json(['error' => 'name is required'], Response::HTTP_BAD_REQUEST);
        }

        $type = $this->service->create($data['name']);

        return $this->json(['id' => $type->getId()->toRfc4122(), 'name' => $type->getName()], Response::HTTP_CREATED);
    }

    #[Route('/{id}', name: 'get', methods: ['GET'])]
    public function get(string $id): JsonResponse
    {
        $type = $this->service->findById(Uuid::fromString($id));
        if (null === $type) {
            return $this->json(['error' => 'Not found'], Response::HTTP_NOT_FOUND);
        }

        return $this->json([
            'id'         => $type->getId()->toRfc4122(),
            'name'       => $type->getName(),
            'attributes' => \array_map(static fn ($a) => [
                'id'      => $a->getId()->toRfc4122(),
                'name'    => $a->getName(),
                'type'    => $a->getType()->value,
                'unit'    => $a->getUnit(),
                'options' => $a->getOptions(),
            ], $type->getAttributeDefinitions()->toArray()),
        ]);
    }

    #[Route('/{id}', name: 'rename', methods: ['PATCH'])]
    public function rename(string $id, Request $request): JsonResponse
    {
        $data = $request->toArray();
        if (empty($data['name'])) {
            return $this->json(['error' => 'name is required'], Response::HTTP_BAD_REQUEST);
        }

        try {
            $type = $this->service->rename(Uuid::fromString($id), $data['name']);
        } catch (\DomainException $e) {
            return $this->json(['error' => $e->getMessage()], Response::HTTP_NOT_FOUND);
        }

        return $this->json(['id' => $type->getId()->toRfc4122(), 'name' => $type->getName()]);
    }

    #[Route('/{id}/attributes', name: 'add_attribute', methods: ['POST'])]
    public function addAttribute(string $id, Request $request): JsonResponse
    {
        $data = $request->toArray();
        if (empty($data['name']) || empty($data['type'])) {
            return $this->json(['error' => 'name and type are required'], Response::HTTP_BAD_REQUEST);
        }

        $attrType = AttributeType::tryFrom($data['type']);
        if (null === $attrType) {
            $valid = \implode(', ', \array_column(AttributeType::cases(), 'value'));

            return $this->json(['error' => "type must be one of: {$valid}"], Response::HTTP_BAD_REQUEST);
        }

        try {
            $def  = $this->service->createAttribute($data['name'], $attrType, $data['unit'] ?? null, $data['options'] ?? null);
            $type = $this->service->addAttribute(Uuid::fromString($id), $def);
        } catch (\DomainException $e) {
            return $this->json(['error' => $e->getMessage()], Response::HTTP_NOT_FOUND);
        }

        return $this->json([
            'id'         => $type->getId()->toRfc4122(),
            'attributes' => \array_map(static fn ($a) => [
                'id'   => $a->getId()->toRfc4122(),
                'name' => $a->getName(),
                'type' => $a->getType()->value,
            ], $type->getAttributeDefinitions()->toArray()),
        ], Response::HTTP_CREATED);
    }

    #[Route('/{id}/attributes/{attrId}', name: 'remove_attribute', methods: ['DELETE'])]
    public function removeAttribute(string $id, string $attrId): JsonResponse
    {
        try {
            $this->service->removeAttribute(Uuid::fromString($id), Uuid::fromString($attrId));
        } catch (\DomainException $e) {
            return $this->json(['error' => $e->getMessage()], Response::HTTP_NOT_FOUND);
        }

        return $this->json(null, Response::HTTP_NO_CONTENT);
    }
}
  • Step 7: PHPStan + CS Fixer
docker compose run --rm app ./vendor/bin/phpstan analyse src/Application/Article/ src/Infrastructure/Http/ --no-progress
docker compose run --rm app ./vendor/bin/php-cs-fixer fix src/Application/Article/ src/Infrastructure/Http/ --dry-run --diff
  • Step 8: Commit
git add src/Application/Article/ArticleTypeService.php src/Infrastructure/Http/Controller/Api/ArticleTypeController.php config/routes/ tests/Unit/Application/Article/ArticleTypeServiceTest.php
git commit -m "feat: add ArticleType + AttributeDefinition REST API"

Task 4: Platform & ChannelField API

Files:

  • Create: src/Application/Channel/PlatformService.php

  • Create: src/Infrastructure/Http/Controller/Api/PlatformController.php

  • Step 1: PlatformService implementieren

<?php
// src/Application/Channel/PlatformService.php
declare(strict_types=1);

namespace App\Application\Channel;

use App\Domain\Channel\ChannelField;
use App\Domain\Channel\Platform;
use App\Domain\Channel\Repository\ChannelFieldRepositoryInterface;
use App\Domain\Channel\Repository\PlatformRepositoryInterface;
use Symfony\Component\Uid\Uuid;

final class PlatformService
{
    public function __construct(
        private readonly PlatformRepositoryInterface $platformRepository,
        private readonly ChannelFieldRepositoryInterface $channelFieldRepository,
    ) {}

    public function create(string $type, string $label, array $config = []): Platform
    {
        $platform = new Platform($type, $label, $config);
        $this->platformRepository->save($platform);

        return $platform;
    }

    /** @return list<Platform> */
    public function findAll(): array
    {
        return $this->platformRepository->findAll();
    }

    public function findById(Uuid $id): ?Platform
    {
        return $this->platformRepository->findById($id);
    }

    public function addChannelField(Uuid $platformId, string $label, string $path): ChannelField
    {
        $platform = $this->platformRepository->findById($platformId)
            ?? throw new \DomainException("Platform {$platformId->toRfc4122()} not found");

        $field = new ChannelField($platform, $label, $path);
        $this->channelFieldRepository->save($field);

        return $field;
    }

    /** @return list<ChannelField> */
    public function findChannelFields(Uuid $platformId): array
    {
        return $this->channelFieldRepository->findByPlatform($platformId);
    }

    public function removeChannelField(Uuid $fieldId): void
    {
        $field = $this->channelFieldRepository->findById($fieldId)
            ?? throw new \DomainException("ChannelField {$fieldId->toRfc4122()} not found");

        $this->channelFieldRepository->remove($field);
    }
}
  • Step 2: PlatformController implementieren
<?php
// src/Infrastructure/Http/Controller/Api/PlatformController.php
declare(strict_types=1);

namespace App\Infrastructure\Http\Controller\Api;

use App\Application\Channel\PlatformService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Uid\Uuid;

#[Route('/platforms', name: 'api_platforms_')]
final class PlatformController extends AbstractController
{
    public function __construct(private readonly PlatformService $service) {}

    #[Route('', name: 'list', methods: ['GET'])]
    public function list(): JsonResponse
    {
        return $this->json(\array_map(static fn ($p) => [
            'id'    => $p->getId()->toRfc4122(),
            'type'  => $p->getType(),
            'label' => $p->getLabel(),
        ], $this->service->findAll()));
    }

    #[Route('', name: 'create', methods: ['POST'])]
    public function create(Request $request): JsonResponse
    {
        $data = $request->toArray();
        if (empty($data['type']) || empty($data['label'])) {
            return $this->json(['error' => 'type and label are required'], Response::HTTP_BAD_REQUEST);
        }

        $platform = $this->service->create($data['type'], $data['label'], $data['config'] ?? []);

        return $this->json(['id' => $platform->getId()->toRfc4122(), 'type' => $platform->getType()], Response::HTTP_CREATED);
    }

    #[Route('/{id}/channel-fields', name: 'list_fields', methods: ['GET'])]
    public function listFields(string $id): JsonResponse
    {
        $fields = $this->service->findChannelFields(Uuid::fromString($id));

        return $this->json(\array_map(static fn ($f) => [
            'id'    => $f->getId()->toRfc4122(),
            'label' => $f->getLabel(),
            'path'  => $f->getPath(),
        ], $fields));
    }

    #[Route('/{id}/channel-fields', name: 'add_field', methods: ['POST'])]
    public function addField(string $id, Request $request): JsonResponse
    {
        $data = $request->toArray();
        if (empty($data['label']) || empty($data['path'])) {
            return $this->json(['error' => 'label and path are required'], Response::HTTP_BAD_REQUEST);
        }

        try {
            $field = $this->service->addChannelField(Uuid::fromString($id), $data['label'], $data['path']);
        } catch (\DomainException $e) {
            return $this->json(['error' => $e->getMessage()], Response::HTTP_NOT_FOUND);
        }

        return $this->json(['id' => $field->getId()->toRfc4122(), 'label' => $field->getLabel()], Response::HTTP_CREATED);
    }

    #[Route('/channel-fields/{fieldId}', name: 'remove_field', methods: ['DELETE'])]
    public function removeField(string $fieldId): JsonResponse
    {
        try {
            $this->service->removeChannelField(Uuid::fromString($fieldId));
        } catch (\DomainException $e) {
            return $this->json(['error' => $e->getMessage()], Response::HTTP_NOT_FOUND);
        }

        return $this->json(null, Response::HTTP_NO_CONTENT);
    }
}
  • Step 3: PHPStan + CS Fixer
docker compose run --rm app ./vendor/bin/phpstan analyse src/Application/Channel/ src/Infrastructure/Http/Controller/Api/PlatformController.php --no-progress
docker compose run --rm app ./vendor/bin/php-cs-fixer fix src/Application/Channel/ src/Infrastructure/Http/Controller/Api/PlatformController.php --dry-run --diff
  • Step 4: Commit
git add src/Application/Channel/PlatformService.php src/Infrastructure/Http/Controller/Api/PlatformController.php
git commit -m "feat: add Platform + ChannelField REST API"

Task 5: Mapping API (ArticleTypePlatformConfig + AttributeMapping)

Files:

  • Create: src/Application/Channel/MappingService.php

  • Create: src/Infrastructure/Http/Controller/Api/MappingController.php

  • Step 1: MappingService implementieren

<?php
// src/Application/Channel/MappingService.php
declare(strict_types=1);

namespace App\Application\Channel;

use App\Domain\Article\Repository\ArticleTypeRepositoryInterface;
use App\Domain\Channel\ArticleTypePlatformConfig;
use App\Domain\Channel\AttributeMapping;
use App\Domain\Channel\Repository\ArticleTypePlatformConfigRepositoryInterface;
use App\Domain\Channel\Repository\AttributeMappingRepositoryInterface;
use App\Domain\Channel\Repository\ChannelFieldRepositoryInterface;
use App\Domain\Channel\Repository\PlatformRepositoryInterface;
use Symfony\Component\Uid\Uuid;

final class MappingService
{
    public function __construct(
        private readonly ArticleTypeRepositoryInterface $articleTypeRepository,
        private readonly PlatformRepositoryInterface $platformRepository,
        private readonly ArticleTypePlatformConfigRepositoryInterface $configRepository,
        private readonly AttributeMappingRepositoryInterface $mappingRepository,
        private readonly ChannelFieldRepositoryInterface $channelFieldRepository,
    ) {}

    public function createConfig(Uuid $articleTypeId, Uuid $platformId, string $categoryId): ArticleTypePlatformConfig
    {
        $articleType = $this->articleTypeRepository->findById($articleTypeId)
            ?? throw new \DomainException("ArticleType {$articleTypeId->toRfc4122()} not found");

        $platform = $this->platformRepository->findById($platformId)
            ?? throw new \DomainException("Platform {$platformId->toRfc4122()} not found");

        $existing = $this->configRepository->findByArticleTypeAndPlatform($articleTypeId, $platformId);
        if (null !== $existing) {
            throw new \DomainException('Config for this ArticleType+Platform combination already exists');
        }

        $config = new ArticleTypePlatformConfig($articleType, $platform, $categoryId);
        $this->configRepository->save($config);

        return $config;
    }

    /** @return list<ArticleTypePlatformConfig> */
    public function findConfigsByArticleType(Uuid $articleTypeId): array
    {
        return $this->configRepository->findByArticleType($articleTypeId);
    }

    public function addMapping(Uuid $configId, Uuid $attributeDefinitionId, Uuid $channelFieldId, ?string $transformer = null): AttributeMapping
    {
        $config = $this->configRepository->findById($configId)
            ?? throw new \DomainException("Config {$configId->toRfc4122()} not found");

        $articleType = $config->getArticleType();
        $defFound = false;
        foreach ($articleType->getAttributeDefinitions() as $def) {
            if ($def->getId()->equals($attributeDefinitionId)) {
                $defFound = true;
                $attributeDefinition = $def;
                break;
            }
        }
        if (!$defFound) {
            throw new \DomainException("AttributeDefinition {$attributeDefinitionId->toRfc4122()} not linked to this ArticleType");
        }

        $channelField = $this->channelFieldRepository->findById($channelFieldId)
            ?? throw new \DomainException("ChannelField {$channelFieldId->toRfc4122()} not found");

        $mapping = new AttributeMapping($config, $attributeDefinition, $channelField);
        $mapping->setTransformer($transformer);
        $this->mappingRepository->save($mapping);

        return $mapping;
    }

    public function removeMapping(Uuid $mappingId): void
    {
        $mapping = $this->mappingRepository->findById($mappingId)
            ?? throw new \DomainException("Mapping {$mappingId->toRfc4122()} not found");

        $this->mappingRepository->remove($mapping);
    }
}
  • Step 2: MappingController implementieren
<?php
// src/Infrastructure/Http/Controller/Api/MappingController.php
declare(strict_types=1);

namespace App\Infrastructure\Http\Controller\Api;

use App\Application\Channel\MappingService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Uid\Uuid;

#[Route('/article-types/{typeId}/platform-configs', name: 'api_mapping_')]
final class MappingController extends AbstractController
{
    public function __construct(private readonly MappingService $service) {}

    #[Route('', name: 'list', methods: ['GET'])]
    public function list(string $typeId): JsonResponse
    {
        $configs = $this->service->findConfigsByArticleType(Uuid::fromString($typeId));

        return $this->json(\array_map(static fn ($c) => [
            'id'         => $c->getId()->toRfc4122(),
            'platform'   => ['id' => $c->getPlatform()->getId()->toRfc4122(), 'label' => $c->getPlatform()->getLabel()],
            'categoryId' => $c->getCategoryId(),
            'mappings'   => \array_map(static fn ($m) => [
                'id'          => $m->getId()->toRfc4122(),
                'attribute'   => ['id' => $m->getAttributeDefinition()->getId()->toRfc4122(), 'name' => $m->getAttributeDefinition()->getName()],
                'channelField' => ['id' => $m->getChannelField()->getId()->toRfc4122(), 'path' => $m->getChannelField()->getPath()],
                'transformer' => $m->getTransformer(),
            ], $c->getAttributeMappings()->toArray()),
        ], $configs));
    }

    #[Route('', name: 'create_config', methods: ['POST'])]
    public function createConfig(string $typeId, Request $request): JsonResponse
    {
        $data = $request->toArray();
        if (empty($data['platformId']) || empty($data['categoryId'])) {
            return $this->json(['error' => 'platformId and categoryId are required'], Response::HTTP_BAD_REQUEST);
        }

        try {
            $config = $this->service->createConfig(Uuid::fromString($typeId), Uuid::fromString($data['platformId']), $data['categoryId']);
        } catch (\DomainException $e) {
            return $this->json(['error' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY);
        }

        return $this->json(['id' => $config->getId()->toRfc4122()], Response::HTTP_CREATED);
    }

    #[Route('/{configId}/mappings', name: 'add_mapping', methods: ['POST'])]
    public function addMapping(string $typeId, string $configId, Request $request): JsonResponse
    {
        $data = $request->toArray();
        if (empty($data['attributeDefinitionId']) || empty($data['channelFieldId'])) {
            return $this->json(['error' => 'attributeDefinitionId and channelFieldId are required'], Response::HTTP_BAD_REQUEST);
        }

        try {
            $mapping = $this->service->addMapping(
                Uuid::fromString($configId),
                Uuid::fromString($data['attributeDefinitionId']),
                Uuid::fromString($data['channelFieldId']),
                $data['transformer'] ?? null,
            );
        } catch (\DomainException $e) {
            return $this->json(['error' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY);
        }

        return $this->json(['id' => $mapping->getId()->toRfc4122()], Response::HTTP_CREATED);
    }

    #[Route('/mappings/{mappingId}', name: 'remove_mapping', methods: ['DELETE'])]
    public function removeMapping(string $typeId, string $mappingId): JsonResponse
    {
        try {
            $this->service->removeMapping(Uuid::fromString($mappingId));
        } catch (\DomainException $e) {
            return $this->json(['error' => $e->getMessage()], Response::HTTP_NOT_FOUND);
        }

        return $this->json(null, Response::HTTP_NO_CONTENT);
    }
}
  • Step 3: PHPStan + CS Fixer
docker compose run --rm app ./vendor/bin/phpstan analyse src/Application/Channel/MappingService.php src/Infrastructure/Http/Controller/Api/MappingController.php --no-progress
docker compose run --rm app ./vendor/bin/php-cs-fixer fix src/Application/Channel/MappingService.php src/Infrastructure/Http/Controller/Api/MappingController.php --dry-run --diff
  • Step 4: Commit
git add src/Application/Channel/MappingService.php src/Infrastructure/Http/Controller/Api/MappingController.php
git commit -m "feat: add ArticleTypePlatformConfig + AttributeMapping REST API"

Task 6: ArticleValidator

Files:

  • Create: src/Application/Article/ArticleValidator.php

  • Test: tests/Unit/Application/Article/ArticleValidatorTest.php

  • Step 1: Failing-Test schreiben

<?php
// tests/Unit/Application/Article/ArticleValidatorTest.php
declare(strict_types=1);

namespace App\Tests\Unit\Application\Article;

use App\Application\Article\ArticleValidator;
use App\Domain\Article\Article;
use App\Domain\Article\ArticleCondition;
use App\Domain\Article\ArticleType;
use App\Domain\Article\AttributeDefinition;
use App\Domain\Article\AttributeType;
use App\Domain\Article\AttributeValue;
use PHPUnit\Framework\TestCase;

final class ArticleValidatorTest extends TestCase
{
    private ArticleValidator $validator;
    private ArticleType $type;
    private AttributeDefinition $ramDef;
    private AttributeDefinition $cpuDef;

    protected function setUp(): void
    {
        $this->validator = new ArticleValidator();
        $this->type      = new ArticleType('Notebook');
        $this->ramDef    = new AttributeDefinition('RAM', AttributeType::String);
        $this->cpuDef    = new AttributeDefinition('CPU', AttributeType::String);
        $this->type->addAttributeDefinition($this->ramDef);
        $this->type->addAttributeDefinition($this->cpuDef);
    }

    public function test_valid_when_all_attributes_set(): void
    {
        $article = new Article($this->type, 'NB-001', 'INV-001', 1, ArticleCondition::Good);
        $article->setAttributeValue(new AttributeValue($article, $this->ramDef, '16 GB'));
        $article->setAttributeValue(new AttributeValue($article, $this->cpuDef, 'Intel i7'));

        $missing = $this->validator->getMissingAttributes($article);

        $this->assertEmpty($missing);
        $this->assertTrue($this->validator->isValid($article));
    }

    public function test_returns_missing_attribute_names(): void
    {
        $article = new Article($this->type, 'NB-001', 'INV-001', 1, ArticleCondition::Good);
        $article->setAttributeValue(new AttributeValue($article, $this->ramDef, '16 GB'));
        // cpuDef not set

        $missing = $this->validator->getMissingAttributes($article);

        $this->assertCount(1, $missing);
        $this->assertContains('CPU', $missing);
        $this->assertFalse($this->validator->isValid($article));
    }

    public function test_all_missing_when_no_values_set(): void
    {
        $article = new Article($this->type, 'NB-001', 'INV-001', 1, ArticleCondition::Good);

        $missing = $this->validator->getMissingAttributes($article);

        $this->assertCount(2, $missing);
    }
}
  • Step 2: Test ausführen — muss fehlschlagen
docker compose run --rm app ./vendor/bin/pest tests/Unit/Application/Article/ArticleValidatorTest.php
# Expected: FAIL
  • Step 3: Article-Entity um setAttributeValue erweitern

Ergänze src/Domain/Article/Article.php — füge Methoden für AttributeValues hinzu:

    /** @var Collection<int, AttributeValue> */
    #[ORM\OneToMany(mappedBy: 'article', targetEntity: AttributeValue::class, cascade: ['persist', 'remove'])]
    private Collection $attributeValues;

    // In __construct hinzufügen:
    $this->attributeValues = new ArrayCollection();

    // Methoden hinzufügen:
    public function setAttributeValue(AttributeValue $value): void
    {
        foreach ($this->attributeValues as $existing) {
            if ($existing->getAttributeDefinition()->getId()->equals($value->getAttributeDefinition()->getId())) {
                $this->attributeValues->removeElement($existing);
                break;
            }
        }
        $this->attributeValues->add($value);
    }

    /** @return Collection<int, AttributeValue> */
    public function getAttributeValues(): Collection
    {
        return $this->attributeValues;
    }
  • Step 4: AttributeValue-Entity prüfen

src/Domain/Article/AttributeValue.php benötigt eine Referenz zurück auf das Article-Objekt (für mappedBy). Stelle sicher, dass der Konstruktor das Article-Objekt akzeptiert:

<?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 getArticle(): Article { return $this->article; }
    public function getAttributeDefinition(): AttributeDefinition { return $this->attributeDefinition; }
    public function getValue(): string { return $this->value; }
    public function setValue(string $value): void { $this->value = $value; }
}
  • Step 5: ArticleValidator implementieren
<?php
// src/Application/Article/ArticleValidator.php
declare(strict_types=1);

namespace App\Application\Article;

use App\Domain\Article\Article;

final class ArticleValidator
{
    /** @return list<string> missing attribute names */
    public function getMissingAttributes(Article $article): array
    {
        $setValue = [];
        foreach ($article->getAttributeValues() as $value) {
            $setValue[] = $value->getAttributeDefinition()->getId()->toRfc4122();
        }

        $missing = [];
        foreach ($article->getArticleType()->getAttributeDefinitions() as $def) {
            if (!\in_array($def->getId()->toRfc4122(), $setValue, strict: true)) {
                $missing[] = $def->getName();
            }
        }

        return $missing;
    }

    public function isValid(Article $article): bool
    {
        return [] === $this->getMissingAttributes($article);
    }
}
  • Step 6: Tests ausführen
docker compose run --rm app ./vendor/bin/pest tests/Unit/Application/Article/ArticleValidatorTest.php
# Expected: PASS (3 tests)
  • Step 7: Commit
git add src/Application/Article/ArticleValidator.php src/Domain/Article/Article.php src/Domain/Article/AttributeValue.php tests/Unit/Application/Article/ArticleValidatorTest.php
git commit -m "feat: add ArticleValidator (required attribute check) + Article.setAttributeValue"

Task 7: Inventory-Sequenz + Article CRUD API

Files:

  • Create: migrations/Version20260513000002.php

  • Create: src/Application/Article/ArticleService.php

  • Create: src/Infrastructure/Http/Controller/Api/ArticleController.php

  • Step 1: Migration für Inventory-Sequenz

docker compose run --rm app php bin/console doctrine:migrations:generate
# Öffne die generierte Datei, ersetze den Inhalt:
<?php
// migrations/Version20260513000002.php
declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260513000002 extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Add inventory_number sequence';
    }

    public function up(Schema $schema): void
    {
        $this->addSql('CREATE SEQUENCE IF NOT EXISTS app.inventory_seq START 1 INCREMENT 1');
    }

    public function down(Schema $schema): void
    {
        $this->addSql('DROP SEQUENCE IF EXISTS app.inventory_seq');
    }
}
docker compose run --rm app php bin/console doctrine:migrations:migrate --no-interaction
# Expected: migration applied
  • Step 2: ArticleService implementieren
<?php
// src/Application/Article/ArticleService.php
declare(strict_types=1);

namespace App\Application\Article;

use App\Domain\Article\Article;
use App\Domain\Article\ArticleCondition;
use App\Domain\Article\ArticleStatus;
use App\Domain\Article\AttributeDefinition;
use App\Domain\Article\AttributeValue;
use App\Domain\Article\Repository\ArticleRepositoryInterface;
use App\Domain\Article\Repository\ArticleTypeRepositoryInterface;
use Doctrine\DBAL\Connection;
use Symfony\Component\Uid\Uuid;

final class ArticleService
{
    public function __construct(
        private readonly ArticleRepositoryInterface $articleRepository,
        private readonly ArticleTypeRepositoryInterface $articleTypeRepository,
        private readonly ArticleValidator $validator,
        private readonly Connection $connection,
    ) {}

    public function create(
        Uuid $articleTypeId,
        ArticleCondition $condition,
        int $stock = 1,
        ?string $conditionNotes = null,
    ): Article {
        $articleType = $this->articleTypeRepository->findById($articleTypeId)
            ?? throw new \DomainException("ArticleType {$articleTypeId->toRfc4122()} not found");

        $inventoryNumber = $this->nextInventoryNumber();
        $sku = 'ART-'.\mb_strtoupper(\substr(\str_replace('-', '', Uuid::v7()->toRfc4122()), 0, 8));

        $article = new Article($articleType, $sku, $inventoryNumber, $stock, $condition);
        $article->setConditionNotes($conditionNotes);
        $this->articleRepository->save($article);

        return $article;
    }

    public function findById(Uuid $id): ?Article
    {
        return $this->articleRepository->findById($id);
    }

    /** @return list<Article> */
    public function findByStatus(ArticleStatus $status): array
    {
        return $this->articleRepository->findByStatus($status);
    }

    /**
     * @param array<string, string> $values attribute_definition_id => value
     */
    public function updateAttributes(Uuid $articleId, array $values): Article
    {
        $article = $this->articleRepository->findById($articleId)
            ?? throw new \DomainException("Article {$articleId->toRfc4122()} not found");

        $articleType = $article->getArticleType();

        foreach ($values as $defIdStr => $value) {
            $defId = Uuid::fromString($defIdStr);
            $def = null;
            foreach ($articleType->getAttributeDefinitions() as $d) {
                if ($d->getId()->equals($defId)) {
                    $def = $d;
                    break;
                }
            }

            if (null === $def) {
                throw new \DomainException("AttributeDefinition {$defIdStr} not linked to this ArticleType");
            }

            $article->setAttributeValue(new AttributeValue($article, $def, $value));
        }

        $this->articleRepository->save($article);

        return $article;
    }

    public function setListingPrice(Uuid $articleId, ?string $price): Article
    {
        $article = $this->articleRepository->findById($articleId)
            ?? throw new \DomainException("Article {$articleId->toRfc4122()} not found");

        $article->setListingPrice(null !== $price ? (float) $price : null);
        $this->articleRepository->save($article);

        return $article;
    }

    public function setEbayTexts(Uuid $articleId, ?string $title, ?string $description): Article
    {
        $article = $this->articleRepository->findById($articleId)
            ?? throw new \DomainException("Article {$articleId->toRfc4122()} not found");

        $article->setEbayTitle($title);
        $article->setEbayDescription($description);
        $this->articleRepository->save($article);

        return $article;
    }

    /**
     * Validates all required attributes are set, then transitions draft → active.
     *
     * @return array{article: Article, missing: list<string>}
     */
    public function activate(Uuid $articleId): array
    {
        $article = $this->articleRepository->findById($articleId)
            ?? throw new \DomainException("Article {$articleId->toRfc4122()} not found");

        $missing = $this->validator->getMissingAttributes($article);
        if ([] !== $missing) {
            return ['article' => $article, 'missing' => $missing];
        }

        $article->transitionTo(ArticleStatus::Active);
        $this->articleRepository->save($article);

        return ['article' => $article, 'missing' => []];
    }

    private function nextInventoryNumber(): string
    {
        /** @var string $seq */
        $seq = $this->connection->fetchOne("SELECT nextval('app.inventory_seq')");

        return \sprintf('INV-%s-%05d', \date('Y'), (int) $seq);
    }
}
  • Step 3: Article-Entity um fehlende Setter erweitern

Füge in src/Domain/Article/Article.php fehlende Getter/Setter hinzu (falls noch nicht vorhanden aus Plan 1):

    public function setConditionNotes(?string $notes): void { $this->conditionNotes = $notes; }
    public function setListingPrice(?float $price): void { $this->listingPrice = $price; }
    public function setEbayTitle(?string $title): void { $this->ebayTitle = $title; }
    public function setEbayDescription(?string $description): void { $this->ebayDescription = $description; }
    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(): ?float { return $this->listingPrice; }
    public function getSerialNumber(): ?string { return $this->serialNumber; }
    public function setSerialNumber(?string $sn): void { $this->serialNumber = $sn; }
    public function getEbayListingId(): ?string { return $this->ebayListingId; }
    public function setEbayListingId(?string $id): void { $this->ebayListingId = $id; }
    public function getEbayTitle(): ?string { return $this->ebayTitle; }
    public function getEbayDescription(): ?string { return $this->ebayDescription; }
  • Step 4: ArticleController implementieren
<?php
// src/Infrastructure/Http/Controller/Api/ArticleController.php
declare(strict_types=1);

namespace App\Infrastructure\Http\Controller\Api;

use App\Application\Article\ArticleService;
use App\Domain\Article\ArticleCondition;
use App\Domain\Article\ArticleStatus;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Uid\Uuid;

#[Route('/articles', name: 'api_articles_')]
final class ArticleController extends AbstractController
{
    public function __construct(private readonly ArticleService $service) {}

    #[Route('', name: 'list', methods: ['GET'])]
    public function list(Request $request): JsonResponse
    {
        $statusParam = $request->query->getString('status', 'draft');
        $status = ArticleStatus::tryFrom($statusParam) ?? ArticleStatus::Draft;

        return $this->json(\array_map(
            static fn ($a) => [
                'id'              => $a->getId()->toRfc4122(),
                'sku'             => $a->getSku(),
                'inventoryNumber' => $a->getInventoryNumber(),
                'status'          => $a->getStatus()->value,
                'articleType'     => $a->getArticleType()->getName(),
                'condition'       => $a->getCondition()->value,
                'stock'           => $a->getStock(),
                'listingPrice'    => $a->getListingPrice(),
            ],
            $this->service->findByStatus($status),
        ));
    }

    #[Route('', name: 'create', methods: ['POST'])]
    public function create(Request $request): JsonResponse
    {
        $data = $request->toArray();
        if (empty($data['articleTypeId']) || empty($data['condition'])) {
            return $this->json(['error' => 'articleTypeId and condition are required'], Response::HTTP_BAD_REQUEST);
        }

        $condition = ArticleCondition::tryFrom($data['condition']);
        if (null === $condition) {
            $valid = \implode(', ', \array_column(ArticleCondition::cases(), 'value'));

            return $this->json(['error' => "condition must be one of: {$valid}"], Response::HTTP_BAD_REQUEST);
        }

        try {
            $article = $this->service->create(
                Uuid::fromString($data['articleTypeId']),
                $condition,
                (int) ($data['stock'] ?? 1),
                $data['conditionNotes'] ?? null,
            );
        } catch (\DomainException $e) {
            return $this->json(['error' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY);
        }

        return $this->json([
            'id'              => $article->getId()->toRfc4122(),
            'sku'             => $article->getSku(),
            'inventoryNumber' => $article->getInventoryNumber(),
            'status'          => $article->getStatus()->value,
        ], Response::HTTP_CREATED);
    }

    #[Route('/{id}', name: 'get', methods: ['GET'])]
    public function get(string $id): JsonResponse
    {
        $article = $this->service->findById(Uuid::fromString($id));
        if (null === $article) {
            return $this->json(['error' => 'Not found'], Response::HTTP_NOT_FOUND);
        }

        return $this->json([
            'id'               => $article->getId()->toRfc4122(),
            'sku'              => $article->getSku(),
            'inventoryNumber'  => $article->getInventoryNumber(),
            'status'           => $article->getStatus()->value,
            'articleType'      => ['id' => $article->getArticleType()->getId()->toRfc4122(), 'name' => $article->getArticleType()->getName()],
            'condition'        => $article->getCondition()->value,
            'conditionNotes'   => $article->getConditionNotes(),
            'stock'            => $article->getStock(),
            'listingPrice'     => $article->getListingPrice(),
            'serialNumber'     => $article->getSerialNumber(),
            'ebayListingId'    => $article->getEbayListingId(),
            'ebayTitle'        => $article->getEbayTitle(),
            'ebayDescription'  => $article->getEbayDescription(),
            'attributes'       => \array_map(static fn ($v) => [
                'definitionId' => $v->getAttributeDefinition()->getId()->toRfc4122(),
                'name'         => $v->getAttributeDefinition()->getName(),
                'value'        => $v->getValue(),
            ], $article->getAttributeValues()->toArray()),
            'photos'           => \array_map(static fn ($p) => [
                'id'      => $p->getId()->toRfc4122(),
                'isMain'  => $p->isMain(),
                'order'   => $p->getSortOrder(),
            ], $article->getPhotos()->toArray()),
        ]);
    }

    #[Route('/{id}/attributes', name: 'update_attributes', methods: ['PATCH'])]
    public function updateAttributes(string $id, Request $request): JsonResponse
    {
        /** @var array<string, string> $values */
        $values = $request->toArray()['attributes'] ?? [];
        if (!\is_array($values)) {
            return $this->json(['error' => 'attributes must be an object mapping definitionId => value'], Response::HTTP_BAD_REQUEST);
        }

        try {
            $article = $this->service->updateAttributes(Uuid::fromString($id), $values);
        } catch (\DomainException $e) {
            return $this->json(['error' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY);
        }

        return $this->json(['id' => $article->getId()->toRfc4122(), 'status' => $article->getStatus()->value]);
    }

    #[Route('/{id}', name: 'update', methods: ['PATCH'])]
    public function update(string $id, Request $request): JsonResponse
    {
        $data = $request->toArray();

        try {
            if (\array_key_exists('listingPrice', $data)) {
                $article = $this->service->setListingPrice(Uuid::fromString($id), $data['listingPrice']);
            }
            if (\array_key_exists('ebayTitle', $data) || \array_key_exists('ebayDescription', $data)) {
                $article = $this->service->setEbayTexts(
                    Uuid::fromString($id),
                    $data['ebayTitle'] ?? null,
                    $data['ebayDescription'] ?? null,
                );
            }
        } catch (\DomainException $e) {
            return $this->json(['error' => $e->getMessage()], Response::HTTP_NOT_FOUND);
        }

        return $this->json(['id' => $id]);
    }

    #[Route('/{id}/activate', name: 'activate', methods: ['POST'])]
    public function activate(string $id): JsonResponse
    {
        try {
            $result = $this->service->activate(Uuid::fromString($id));
        } catch (\DomainException $e) {
            return $this->json(['error' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY);
        }

        if ([] !== $result['missing']) {
            return $this->json([
                'error'   => 'Cannot activate: missing required attributes',
                'missing' => $result['missing'],
            ], Response::HTTP_UNPROCESSABLE_ENTITY);
        }

        return $this->json(['id' => $result['article']->getId()->toRfc4122(), 'status' => $result['article']->getStatus()->value]);
    }
}
  • Step 5: Article-Entity um getPhotos + getId ergänzen (falls fehlend)

Stelle sicher src/Domain/Article/Article.php hat:

    public function getId(): Uuid { return $this->id; }

    /** @var Collection<int, ArticlePhoto> */
    #[ORM\OneToMany(mappedBy: 'article', targetEntity: ArticlePhoto::class, cascade: ['persist', 'remove'])]
    private Collection $photos;

    // In __construct:
    $this->photos = new ArrayCollection();

    /** @return Collection<int, ArticlePhoto> */
    public function getPhotos(): Collection { return $this->photos; }
  • Step 6: PHPStan + CS Fixer
docker compose run --rm app ./vendor/bin/phpstan analyse src/ --no-progress
docker compose run --rm app ./vendor/bin/php-cs-fixer fix src/ --dry-run --diff
  • Step 7: Commit
git add migrations/ src/Application/Article/ArticleService.php src/Infrastructure/Http/Controller/Api/ArticleController.php src/Domain/Article/Article.php
git commit -m "feat: add Article CRUD API with inventory sequence and activation validation"

Task 8: Photo Upload API

Files:

  • Create: src/Application/Article/PhotoService.php

  • Create: src/Infrastructure/Http/Controller/Api/PhotoController.php

  • Step 1: ArticlePhoto-Entity prüfen

Stelle sicher, dass src/Domain/Article/ArticlePhoto.php diese Methoden hat:

<?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 = false;

    #[ORM\Column(type: 'integer')]
    private int $sortOrder = 0;

    public function __construct(Article $article, StoragePath $storagePath, string $filename)
    {
        $this->id = Uuid::v7();
        $this->article = $article;
        $this->storagePath = $storagePath;
        $this->filename = $filename;
    }

    public function getId(): Uuid { return $this->id; }
    public function getArticle(): Article { return $this->article; }
    public function getStoragePath(): StoragePath { return $this->storagePath; }
    public function getFilename(): string { return $this->filename; }
    public function isMain(): bool { return $this->isMain; }
    public function setIsMain(bool $isMain): void { $this->isMain = $isMain; }
    public function getSortOrder(): int { return $this->sortOrder; }
    public function setSortOrder(int $order): void { $this->sortOrder = $order; }
}
  • Step 2: PhotoService implementieren
<?php
// src/Application/Article/PhotoService.php
declare(strict_types=1);

namespace App\Application\Article;

use App\Application\Storage\StorageManagerInterface;
use App\Domain\Article\ArticlePhoto;
use App\Domain\Article\Repository\ArticlePhotoRepositoryInterface;
use App\Domain\Article\Repository\ArticleRepositoryInterface;
use Symfony\Component\Uid\Uuid;

final class PhotoService
{
    public function __construct(
        private readonly ArticleRepositoryInterface $articleRepository,
        private readonly ArticlePhotoRepositoryInterface $photoRepository,
        private readonly StorageManagerInterface $storageManager,
    ) {}

    public function upload(Uuid $articleId, string $tempPath, string $originalFilename): ArticlePhoto
    {
        $article = $this->articleRepository->findById($articleId)
            ?? throw new \DomainException("Article {$articleId->toRfc4122()} not found");

        $stored = $this->storageManager->store($tempPath, $originalFilename);

        $existingPhotos = $this->photoRepository->findByArticle($articleId);
        $isMain = 0 === \count($existingPhotos);
        $sortOrder = \count($existingPhotos);

        $photo = new ArticlePhoto($article, $stored->storagePath, $stored->filename);
        $photo->setIsMain($isMain);
        $photo->setSortOrder($sortOrder);

        $this->photoRepository->save($photo);

        return $photo;
    }

    public function setMain(Uuid $photoId): void
    {
        $photo = $this->photoRepository->findById($photoId)
            ?? throw new \DomainException("Photo {$photoId->toRfc4122()} not found");

        $allPhotos = $this->photoRepository->findByArticle($photo->getArticle()->getId());
        foreach ($allPhotos as $p) {
            $p->setIsMain($p->getId()->equals($photoId));
            $this->photoRepository->save($p);
        }
    }

    public function delete(Uuid $photoId): void
    {
        $photo = $this->photoRepository->findById($photoId)
            ?? throw new \DomainException("Photo {$photoId->toRfc4122()} not found");

        $fullPath = $this->storageManager->getFullPath($photo->getStoragePath(), $photo->getFilename());
        if (\file_exists($fullPath)) {
            \unlink($fullPath);
        }

        if ($photo->isMain()) {
            $articleId = $photo->getArticle()->getId();
            $this->photoRepository->remove($photo);
            $remaining = $this->photoRepository->findByArticle($articleId);
            if ([] !== $remaining) {
                $remaining[0]->setIsMain(true);
                $this->photoRepository->save($remaining[0]);
            }
        } else {
            $this->photoRepository->remove($photo);
        }
    }
}
  • Step 3: PhotoController implementieren
<?php
// src/Infrastructure/Http/Controller/Api/PhotoController.php
declare(strict_types=1);

namespace App\Infrastructure\Http\Controller\Api;

use App\Application\Article\PhotoService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Uid\Uuid;

#[Route('/articles/{articleId}/photos', name: 'api_photos_')]
final class PhotoController extends AbstractController
{
    public function __construct(private readonly PhotoService $service) {}

    #[Route('', name: 'upload', methods: ['POST'])]
    public function upload(string $articleId, Request $request): JsonResponse
    {
        $file = $request->files->get('photo');
        if (null === $file) {
            return $this->json(['error' => 'photo file is required (multipart/form-data, field: photo)'], Response::HTTP_BAD_REQUEST);
        }

        $allowedMimes = ['image/jpeg', 'image/png', 'image/webp'];
        if (!\in_array($file->getMimeType(), $allowedMimes, strict: true)) {
            return $this->json(['error' => 'Only JPEG, PNG and WebP images are allowed'], Response::HTTP_BAD_REQUEST);
        }

        try {
            $photo = $this->service->upload(
                Uuid::fromString($articleId),
                $file->getPathname(),
                $file->getClientOriginalName(),
            );
        } catch (\DomainException|\RuntimeException $e) {
            return $this->json(['error' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY);
        }

        return $this->json([
            'id'        => $photo->getId()->toRfc4122(),
            'isMain'    => $photo->isMain(),
            'sortOrder' => $photo->getSortOrder(),
        ], Response::HTTP_CREATED);
    }

    #[Route('/{photoId}/main', name: 'set_main', methods: ['PATCH'])]
    public function setMain(string $articleId, string $photoId): JsonResponse
    {
        try {
            $this->service->setMain(Uuid::fromString($photoId));
        } catch (\DomainException $e) {
            return $this->json(['error' => $e->getMessage()], Response::HTTP_NOT_FOUND);
        }

        return $this->json(null, Response::HTTP_NO_CONTENT);
    }

    #[Route('/{photoId}', name: 'delete', methods: ['DELETE'])]
    public function delete(string $articleId, string $photoId): JsonResponse
    {
        try {
            $this->service->delete(Uuid::fromString($photoId));
        } catch (\DomainException $e) {
            return $this->json(['error' => $e->getMessage()], Response::HTTP_NOT_FOUND);
        }

        return $this->json(null, Response::HTTP_NO_CONTENT);
    }
}
  • Step 4: PHPStan + CS Fixer
docker compose run --rm app ./vendor/bin/phpstan analyse src/ --no-progress
docker compose run --rm app ./vendor/bin/php-cs-fixer fix src/ --dry-run --diff
  • Step 5: Commit
git add src/Application/Article/PhotoService.php src/Infrastructure/Http/Controller/Api/PhotoController.php src/Domain/Article/ArticlePhoto.php
git commit -m "feat: add photo upload API (multipart, StorageManager-backed, auto-selects main)"

Selbstreview

Spec-Abdeckung:

  • ArticleType CRUD ✓ (Task 3)
  • AttributeDefinition CRUD ✓ (Task 3)
  • Platform + ChannelField ✓ (Task 4)
  • ArticleTypePlatformConfig + AttributeMapping ✓ (Task 5)
  • Pflichtfeld-Validierung ✓ (Task 6)
  • Artikel CRUD ✓ (Task 7)
  • StorageManager Multi-Path ✓ (Task 2)
  • Foto-Upload + Main-Foto ✓ (Task 8)

Noch nicht in diesem Plan:

  • Auth/ACL → Plan 3
  • AI-Pipeline-Trigger → Plan 4
  • eBay-Publish bei Aktivierung → Plan 5
  • EasyAdmin → Plan 3