# 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 active paths ordered by priority DESC */ public function findActiveSortedByPriority(): array; public function save(StoragePath $storagePath): void; } ``` ```php */ public function findByArticle(Uuid $articleId): array; public function save(ArticlePhoto $photo): void; public function remove(ArticlePhoto $photo): void; } ``` ```php */ 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 */ public function findByPlatform(Uuid $platformId): array; public function save(ChannelField $field): void; public function remove(ChannelField $field): void; } ``` ```php */ public function findByArticleType(Uuid $articleTypeId): array; public function save(ArticleTypePlatformConfig $config): void; public function remove(ArticleTypePlatformConfig $config): void; } ``` ```php */ public function findByPlatformConfig(Uuid $platformConfigId): array; public function save(AttributeMapping $mapping): void; public function remove(AttributeMapping $mapping): void; } ``` - [ ] **Step 2: Doctrine-Repositories schreiben** ```php em->find(StoragePath::class, $id); } /** @return list */ public function findActiveSortedByPriority(): array { /** @var list */ 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 em->find(ArticlePhoto::class, $id); } /** @return list */ public function findByArticle(Uuid $articleId): array { /** @var list */ 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 */ public function findByArticle(Uuid $articleId): array { /** @var list */ 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 em->find(ChannelField::class, $id); } /** @return list */ public function findByPlatform(Uuid $platformId): array { /** @var list */ 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 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 */ public function findByArticleType(Uuid $articleTypeId): array { /** @var list */ 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 em->find(AttributeMapping::class, $id); } /** @return list */ public function findByPlatformConfig(Uuid $platformConfigId): array { /** @var list */ 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`: ```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** ```bash 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** ```bash 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 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** ```bash 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 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** ```yaml App\Application\Storage\StorageManagerInterface: alias: App\Infrastructure\Storage\LocalStorageManager ``` - [ ] **Step 6: Test ausführen — muss bestehen** ```bash docker compose run --rm app ./vendor/bin/pest tests/Unit/Application/Storage/LocalStorageManagerTest.php # Expected: PASS (4 tests) ``` - [ ] **Step 7: Commit** ```bash 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 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** ```bash 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 articleTypeRepository->save($type); return $type; } /** @return list */ 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** ```bash docker compose run --rm app ./vendor/bin/pest tests/Unit/Application/Article/ArticleTypeServiceTest.php # Expected: PASS (4 tests) ``` - [ ] **Step 5: Route-Konfiguration anlegen** ```yaml # config/routes/api.yaml api: resource: '../src/Infrastructure/Http/Controller/Api/' type: attribute prefix: /api ``` - [ ] **Step 6: ArticleTypeController implementieren** ```php 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** ```bash 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** ```bash 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 platformRepository->save($platform); return $platform; } /** @return list */ 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 */ 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 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** ```bash 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** ```bash 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 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 */ 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 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** ```bash 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** ```bash 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 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** ```bash 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: ```php /** @var Collection */ #[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 */ 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 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 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** ```bash docker compose run --rm app ./vendor/bin/pest tests/Unit/Application/Article/ArticleValidatorTest.php # Expected: PASS (3 tests) ``` - [ ] **Step 7: Commit** ```bash 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** ```bash docker compose run --rm app php bin/console doctrine:migrations:generate # Öffne die generierte Datei, ersetze den Inhalt: ``` ```php 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'); } } ``` ```bash docker compose run --rm app php bin/console doctrine:migrations:migrate --no-interaction # Expected: migration applied ``` - [ ] **Step 2: ArticleService implementieren** ```php 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
*/ public function findByStatus(ArticleStatus $status): array { return $this->articleRepository->findByStatus($status); } /** * @param array $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} */ 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): ```php 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 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 $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: ```php public function getId(): Uuid { return $this->id; } /** @var Collection */ #[ORM\OneToMany(mappedBy: 'article', targetEntity: ArticlePhoto::class, cascade: ['persist', 'remove'])] private Collection $photos; // In __construct: $this->photos = new ArrayCollection(); /** @return Collection */ public function getPhotos(): Collection { return $this->photos; } ``` - [ ] **Step 6: PHPStan + CS Fixer** ```bash 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** ```bash 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 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 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 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** ```bash 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** ```bash 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