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