diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index 1be2d39..5b7387b 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -14,6 +14,7 @@ return (new PhpCsFixer\Config()) 'no_unused_imports' => true, 'array_syntax' => ['syntax' => 'short'], 'phpdoc_align' => false, + 'phpdoc_to_comment' => false, ]) ->setRiskyAllowed(true) ->setFinder($finder); diff --git a/config/routes/api.yaml b/config/routes/api.yaml new file mode 100644 index 0000000..e09c54d --- /dev/null +++ b/config/routes/api.yaml @@ -0,0 +1,4 @@ +api: + resource: '../src/Infrastructure/Http/Controller/Api/' + type: attribute + prefix: /api diff --git a/config/services.yaml b/config/services.yaml index 0b54e56..1568a8b 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -25,3 +25,24 @@ services: App\Domain\Order\Repository\OrderRepositoryInterface: alias: App\Infrastructure\Persistence\Repository\DoctrineOrderRepository + + 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 + + App\Application\Storage\StorageManagerInterface: + alias: App\Infrastructure\Storage\LocalStorageManager diff --git a/migrations/Version20260514050204.php b/migrations/Version20260514050204.php new file mode 100644 index 0000000..05e8ea8 --- /dev/null +++ b/migrations/Version20260514050204.php @@ -0,0 +1,26 @@ +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'); + } +} diff --git a/phpstan.neon b/phpstan.neon index c63c22e..7c4c802 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,7 +1,3 @@ -includes: - - vendor/phpstan/phpstan-symfony/extension.neon - - vendor/phpstan/phpstan-doctrine/extension.neon - parameters: level: 9 paths: @@ -9,5 +5,3 @@ parameters: - tests symfony: containerXmlPath: var/cache/dev/App_KernelDevDebugContainer.xml - doctrine: - objectManagerLoader: tests/bootstrap.php diff --git a/src/Application/Article/ArticleService.php b/src/Application/Article/ArticleService.php new file mode 100644 index 0000000..8240789 --- /dev/null +++ b/src/Application/Article/ArticleService.php @@ -0,0 +1,139 @@ +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($price); + $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); + } +} diff --git a/src/Application/Article/ArticleTypeService.php b/src/Application/Article/ArticleTypeService.php new file mode 100644 index 0000000..50ad1bb --- /dev/null +++ b/src/Application/Article/ArticleTypeService.php @@ -0,0 +1,85 @@ +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; + } + + /** @param list|null $options */ + 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); + } +} diff --git a/src/Application/Article/ArticleValidator.php b/src/Application/Article/ArticleValidator.php new file mode 100644 index 0000000..81433b9 --- /dev/null +++ b/src/Application/Article/ArticleValidator.php @@ -0,0 +1,33 @@ + 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); + } +} diff --git a/src/Application/Article/PhotoService.php b/src/Application/Article/PhotoService.php new file mode 100644 index 0000000..74ba6b6 --- /dev/null +++ b/src/Application/Article/PhotoService.php @@ -0,0 +1,74 @@ +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, $isMain, $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); + } + } +} diff --git a/src/Application/Channel/MappingService.php b/src/Application/Channel/MappingService.php new file mode 100644 index 0000000..48c3956 --- /dev/null +++ b/src/Application/Channel/MappingService.php @@ -0,0 +1,86 @@ +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(); + $attributeDefinition = null; + foreach ($articleType->getAttributeDefinitions() as $def) { + if ($def->getId()->equals($attributeDefinitionId)) { + $attributeDefinition = $def; + break; + } + } + if (null === $attributeDefinition) { + 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); + } +} diff --git a/src/Application/Channel/PlatformService.php b/src/Application/Channel/PlatformService.php new file mode 100644 index 0000000..6263b05 --- /dev/null +++ b/src/Application/Channel/PlatformService.php @@ -0,0 +1,68 @@ + $config */ + public function create(string $type, string $label, array $config = []): Platform + { + $platform = new Platform($type, $label); + if ([] !== $config) { + $platform->setConfig($config); + } + $this->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); + } +} diff --git a/src/Application/Storage/StorageManagerInterface.php b/src/Application/Storage/StorageManagerInterface.php new file mode 100644 index 0000000..34d6d9e --- /dev/null +++ b/src/Application/Storage/StorageManagerInterface.php @@ -0,0 +1,20 @@ +status->canTransitionTo($newStatus)) { - throw new \DomainException(\sprintf( - 'Cannot transition from %s to %s', - $this->status->value, - $newStatus->value, - )); + throw new \DomainException(\sprintf('Cannot transition from %s to %s', $this->status->value, $newStatus->value)); } $this->status = $newStatus; } @@ -101,7 +97,10 @@ class Article --$this->stock; } - public function isOutOfStock(): bool { return 0 === $this->stock; } + public function isOutOfStock(): bool + { + return 0 === $this->stock; + } public function getMainPhoto(): ?ArticlePhoto { @@ -114,30 +113,121 @@ class Article return null; } - public function getId(): Uuid { return $this->id; } - public function getArticleType(): ArticleType { return $this->articleType; } - public function getSku(): string { return $this->sku; } - public function getInventoryNumber(): string { return $this->inventoryNumber; } - public function getStatus(): ArticleStatus { return $this->status; } - public function getStock(): int { return $this->stock; } - public function getCondition(): ArticleCondition { return $this->condition; } - public function getConditionNotes(): ?string { return $this->conditionNotes; } - public function getListingPrice(): ?string { return $this->listingPrice; } - public function getSerialNumber(): ?string { return $this->serialNumber; } - public function getEbayListingId(): ?string { return $this->ebayListingId; } - public function getEbayTitle(): ?string { return $this->ebayTitle; } - public function getEbayDescription(): ?string { return $this->ebayDescription; } + public function getId(): Uuid + { + return $this->id; + } + + public function getArticleType(): ArticleType + { + return $this->articleType; + } + + public function getSku(): string + { + return $this->sku; + } + + public function getInventoryNumber(): string + { + return $this->inventoryNumber; + } + + public function getStatus(): ArticleStatus + { + return $this->status; + } + + public function getStock(): int + { + return $this->stock; + } + + public function getCondition(): ArticleCondition + { + return $this->condition; + } + + public function getConditionNotes(): ?string + { + return $this->conditionNotes; + } + + public function getListingPrice(): ?string + { + return $this->listingPrice; + } + + public function getSerialNumber(): ?string + { + return $this->serialNumber; + } + + public function getEbayListingId(): ?string + { + return $this->ebayListingId; + } + + public function getEbayTitle(): ?string + { + return $this->ebayTitle; + } + + public function getEbayDescription(): ?string + { + return $this->ebayDescription; + } + + 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; } + public function getAttributeValues(): Collection + { + return $this->attributeValues; + } /** @return Collection */ - public function getPhotos(): Collection { return $this->photos; } + public function getPhotos(): Collection + { + return $this->photos; + } - public function setConditionNotes(?string $notes): void { $this->conditionNotes = $notes; } - public function setListingPrice(?string $price): void { $this->listingPrice = $price; } - public function setSerialNumber(?string $sn): void { $this->serialNumber = $sn; } - public function setEbayListingId(?string $id): void { $this->ebayListingId = $id; } - public function setEbayTitle(?string $title): void { $this->ebayTitle = $title; } - public function setEbayDescription(?string $desc): void { $this->ebayDescription = $desc; } + public function setConditionNotes(?string $notes): void + { + $this->conditionNotes = $notes; + } + + public function setListingPrice(?string $price): void + { + $this->listingPrice = $price; + } + + public function setSerialNumber(?string $sn): void + { + $this->serialNumber = $sn; + } + + public function setEbayListingId(?string $id): void + { + $this->ebayListingId = $id; + } + + public function setEbayTitle(?string $title): void + { + $this->ebayTitle = $title; + } + + public function setEbayDescription(?string $desc): void + { + $this->ebayDescription = $desc; + } } diff --git a/src/Domain/Article/ArticlePhoto.php b/src/Domain/Article/ArticlePhoto.php index 38f814b..1502d31 100644 --- a/src/Domain/Article/ArticlePhoto.php +++ b/src/Domain/Article/ArticlePhoto.php @@ -37,8 +37,8 @@ class ArticlePhoto Article $article, StoragePath $storagePath, string $filename, - bool $isMain, - int $sortOrder, + bool $isMain = false, + int $sortOrder = 0, ) { $this->id = Uuid::v7(); $this->article = $article; @@ -48,12 +48,45 @@ class ArticlePhoto $this->sortOrder = $sortOrder; } - public function getId(): Uuid { return $this->id; } - public function getStoragePath(): StoragePath { return $this->storagePath; } - public function getFilename(): string { return $this->filename; } - public function isMain(): bool { return $this->isMain; } - public function getSortOrder(): int { return $this->sortOrder; } - public function setSortOrder(int $sortOrder): void { $this->sortOrder = $sortOrder; } + public function getId(): Uuid + { + return $this->id; + } + + public function getArticle(): Article + { + return $this->article; + } + + public function getStoragePath(): StoragePath + { + return $this->storagePath; + } + + public function setIsMain(bool $isMain): void + { + $this->isMain = $isMain; + } + + public function getFilename(): string + { + return $this->filename; + } + + public function isMain(): bool + { + return $this->isMain; + } + + public function getSortOrder(): int + { + return $this->sortOrder; + } + + public function setSortOrder(int $sortOrder): void + { + $this->sortOrder = $sortOrder; + } public function getFullPath(): string { diff --git a/src/Domain/Article/ArticleType.php b/src/Domain/Article/ArticleType.php index dbbe57f..a25fa99 100644 --- a/src/Domain/Article/ArticleType.php +++ b/src/Domain/Article/ArticleType.php @@ -32,12 +32,26 @@ class ArticleType $this->attributeDefinitions = new ArrayCollection(); } - public function getId(): Uuid { return $this->id; } - public function getName(): string { return $this->name; } - public function setName(string $name): void { $this->name = $name; } + public function getId(): Uuid + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } /** @return Collection */ - public function getAttributeDefinitions(): Collection { return $this->attributeDefinitions; } + public function getAttributeDefinitions(): Collection + { + return $this->attributeDefinitions; + } public function addAttributeDefinition(AttributeDefinition $def): void { diff --git a/src/Domain/Article/AttributeDefinition.php b/src/Domain/Article/AttributeDefinition.php index 3f74345..7fc92c7 100644 --- a/src/Domain/Article/AttributeDefinition.php +++ b/src/Domain/Article/AttributeDefinition.php @@ -35,16 +35,45 @@ class AttributeDefinition $this->type = $type; } - public function getId(): Uuid { return $this->id; } - public function getName(): string { return $this->name; } - public function setName(string $name): void { $this->name = $name; } - public function getType(): AttributeType { return $this->type; } - public function getUnit(): ?string { return $this->unit; } - public function setUnit(?string $unit): void { $this->unit = $unit; } + public function getId(): Uuid + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getType(): AttributeType + { + return $this->type; + } + + public function getUnit(): ?string + { + return $this->unit; + } + + public function setUnit(?string $unit): void + { + $this->unit = $unit; + } /** @return list|null */ - public function getOptions(): ?array { return $this->options; } + public function getOptions(): ?array + { + return $this->options; + } /** @param list|null $options */ - public function setOptions(?array $options): void { $this->options = $options; } + public function setOptions(?array $options): void + { + $this->options = $options; + } } diff --git a/src/Domain/Article/AttributeValue.php b/src/Domain/Article/AttributeValue.php index 07b6eb5..5386983 100644 --- a/src/Domain/Article/AttributeValue.php +++ b/src/Domain/Article/AttributeValue.php @@ -38,10 +38,25 @@ class AttributeValue $this->value = $value; } - public function getId(): Uuid { return $this->id; } - public function getAttributeDefinition(): AttributeDefinition { return $this->attributeDefinition; } - public function getValue(): string { return $this->value; } - public function setValue(string $value): void { $this->value = $value; } + public function getId(): Uuid + { + return $this->id; + } + + public function getAttributeDefinition(): AttributeDefinition + { + return $this->attributeDefinition; + } + + public function getValue(): string + { + return $this->value; + } + + public function setValue(string $value): void + { + $this->value = $value; + } public function getCastValue(): mixed { @@ -49,7 +64,7 @@ class AttributeValue AttributeType::Int => (int) $this->value, AttributeType::Float => (float) $this->value, AttributeType::Bool => filter_var($this->value, \FILTER_VALIDATE_BOOLEAN), - AttributeType::MultiSelect => \json_decode($this->value, true), + AttributeType::MultiSelect => json_decode($this->value, true), default => $this->value, }; } diff --git a/src/Domain/Article/Repository/ArticlePhotoRepositoryInterface.php b/src/Domain/Article/Repository/ArticlePhotoRepositoryInterface.php new file mode 100644 index 0000000..496b12d --- /dev/null +++ b/src/Domain/Article/Repository/ArticlePhotoRepositoryInterface.php @@ -0,0 +1,20 @@ + */ + public function findByArticle(Uuid $articleId): array; + + public function save(ArticlePhoto $photo): void; + + public function remove(ArticlePhoto $photo): void; +} diff --git a/src/Domain/Article/Repository/AttributeValueRepositoryInterface.php b/src/Domain/Article/Repository/AttributeValueRepositoryInterface.php new file mode 100644 index 0000000..a95afbf --- /dev/null +++ b/src/Domain/Article/Repository/AttributeValueRepositoryInterface.php @@ -0,0 +1,20 @@ + */ + 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; +} diff --git a/src/Domain/Auth/ApiKey.php b/src/Domain/Auth/ApiKey.php index 0a6cd12..b7866c5 100644 --- a/src/Domain/Auth/ApiKey.php +++ b/src/Domain/Auth/ApiKey.php @@ -46,18 +46,56 @@ class ApiKey $this->keyHash = $keyHash; } - public function getId(): Uuid { return $this->id; } - public function getUser(): User { return $this->user; } - public function getLabel(): string { return $this->label; } - public function getKeyHash(): string { return $this->keyHash; } - public function isActive(): bool { return $this->isActive; } - public function setIsActive(bool $active): void { $this->isActive = $active; } - public function getLastUsedAt(): ?\DateTimeImmutable { return $this->lastUsedAt; } - public function getExpiresAt(): ?\DateTimeImmutable { return $this->expiresAt; } - public function setExpiresAt(?\DateTimeImmutable $expiresAt): void { $this->expiresAt = $expiresAt; } + public function getId(): Uuid + { + return $this->id; + } + + public function getUser(): User + { + return $this->user; + } + + public function getLabel(): string + { + return $this->label; + } + + public function getKeyHash(): string + { + return $this->keyHash; + } + + public function isActive(): bool + { + return $this->isActive; + } + + public function setIsActive(bool $active): void + { + $this->isActive = $active; + } + + public function getLastUsedAt(): ?\DateTimeImmutable + { + return $this->lastUsedAt; + } + + public function getExpiresAt(): ?\DateTimeImmutable + { + return $this->expiresAt; + } + + public function setExpiresAt(?\DateTimeImmutable $expiresAt): void + { + $this->expiresAt = $expiresAt; + } /** @return array */ - public function getPermissions(): array { return $this->permissions; } + public function getPermissions(): array + { + return $this->permissions; + } public function hasPermission(string $permission): bool { diff --git a/src/Domain/Auth/User.php b/src/Domain/Auth/User.php index a4cc8b9..33c8ba1 100644 --- a/src/Domain/Auth/User.php +++ b/src/Domain/Auth/User.php @@ -40,23 +40,64 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface $this->passwordHash = $passwordHash; } - public function getId(): Uuid { return $this->id; } - public function getEmail(): string { return $this->email; } - public function getPassword(): string { return $this->passwordHash; } - public function getUserIdentifier(): string { return $this->email; } + public function getId(): Uuid + { + return $this->id; + } + + public function getEmail(): string + { + return $this->email; + } + + public function getPassword(): string + { + return $this->passwordHash; + } + + /** @return non-empty-string */ + public function getUserIdentifier(): string + { + \assert('' !== $this->email); + + return $this->email; + } /** @return list */ - public function getRoles(): array { return ['ROLE_USER']; } + public function getRoles(): array + { + return ['ROLE_USER']; + } - public function eraseCredentials(): void {} + public function eraseCredentials(): void + { + } - public function getTotpSecret(): ?string { return $this->totpSecret; } - public function setTotpSecret(?string $secret): void { $this->totpSecret = $secret; } - public function isActive(): bool { return $this->isActive; } - public function setIsActive(bool $active): void { $this->isActive = $active; } + public function getTotpSecret(): ?string + { + return $this->totpSecret; + } + + public function setTotpSecret(?string $secret): void + { + $this->totpSecret = $secret; + } + + public function isActive(): bool + { + return $this->isActive; + } + + public function setIsActive(bool $active): void + { + $this->isActive = $active; + } /** @return array */ - public function getPermissions(): array { return $this->permissions; } + public function getPermissions(): array + { + return $this->permissions; + } public function hasPermission(string $permission): bool { diff --git a/src/Domain/Channel/ArticleTypePlatformConfig.php b/src/Domain/Channel/ArticleTypePlatformConfig.php index e5d209a..e65bd33 100644 --- a/src/Domain/Channel/ArticleTypePlatformConfig.php +++ b/src/Domain/Channel/ArticleTypePlatformConfig.php @@ -43,12 +43,34 @@ class ArticleTypePlatformConfig $this->attributeMappings = new ArrayCollection(); } - public function getId(): Uuid { return $this->id; } - public function getArticleType(): ArticleType { return $this->articleType; } - public function getPlatform(): Platform { return $this->platform; } - public function getCategoryId(): string { return $this->categoryId; } - public function setCategoryId(string $categoryId): void { $this->categoryId = $categoryId; } + public function getId(): Uuid + { + return $this->id; + } + + public function getArticleType(): ArticleType + { + return $this->articleType; + } + + public function getPlatform(): Platform + { + return $this->platform; + } + + public function getCategoryId(): string + { + return $this->categoryId; + } + + public function setCategoryId(string $categoryId): void + { + $this->categoryId = $categoryId; + } /** @return Collection */ - public function getAttributeMappings(): Collection { return $this->attributeMappings; } + public function getAttributeMappings(): Collection + { + return $this->attributeMappings; + } } diff --git a/src/Domain/Channel/AttributeMapping.php b/src/Domain/Channel/AttributeMapping.php index b5e96a9..70d44f0 100644 --- a/src/Domain/Channel/AttributeMapping.php +++ b/src/Domain/Channel/AttributeMapping.php @@ -43,10 +43,33 @@ class AttributeMapping $this->channelField = $channelField; } - public function getId(): Uuid { return $this->id; } - public function getPlatformConfig(): ArticleTypePlatformConfig { return $this->platformConfig; } - public function getAttributeDefinition(): AttributeDefinition { return $this->attributeDefinition; } - public function getChannelField(): ChannelField { return $this->channelField; } - public function getTransformer(): ?string { return $this->transformer; } - public function setTransformer(?string $transformer): void { $this->transformer = $transformer; } + public function getId(): Uuid + { + return $this->id; + } + + public function getPlatformConfig(): ArticleTypePlatformConfig + { + return $this->platformConfig; + } + + public function getAttributeDefinition(): AttributeDefinition + { + return $this->attributeDefinition; + } + + public function getChannelField(): ChannelField + { + return $this->channelField; + } + + public function getTransformer(): ?string + { + return $this->transformer; + } + + public function setTransformer(?string $transformer): void + { + $this->transformer = $transformer; + } } diff --git a/src/Domain/Channel/ChannelField.php b/src/Domain/Channel/ChannelField.php index d7e9b1a..8104756 100644 --- a/src/Domain/Channel/ChannelField.php +++ b/src/Domain/Channel/ChannelField.php @@ -33,10 +33,33 @@ class ChannelField $this->path = $path; } - public function getId(): Uuid { return $this->id; } - public function getPlatform(): Platform { return $this->platform; } - public function getLabel(): string { return $this->label; } - public function setLabel(string $label): void { $this->label = $label; } - public function getPath(): string { return $this->path; } - public function setPath(string $path): void { $this->path = $path; } + public function getId(): Uuid + { + return $this->id; + } + + public function getPlatform(): Platform + { + return $this->platform; + } + + public function getLabel(): string + { + return $this->label; + } + + public function setLabel(string $label): void + { + $this->label = $label; + } + + public function getPath(): string + { + return $this->path; + } + + public function setPath(string $path): void + { + $this->path = $path; + } } diff --git a/src/Domain/Channel/Platform.php b/src/Domain/Channel/Platform.php index a44708c..980619f 100644 --- a/src/Domain/Channel/Platform.php +++ b/src/Domain/Channel/Platform.php @@ -32,14 +32,35 @@ class Platform $this->label = $label; } - public function getId(): Uuid { return $this->id; } - public function getType(): string { return $this->type; } - public function getLabel(): string { return $this->label; } - public function setLabel(string $label): void { $this->label = $label; } + public function getId(): Uuid + { + return $this->id; + } + + public function getType(): string + { + return $this->type; + } + + public function getLabel(): string + { + return $this->label; + } + + public function setLabel(string $label): void + { + $this->label = $label; + } /** @return array */ - public function getConfig(): array { return $this->config; } + public function getConfig(): array + { + return $this->config; + } /** @param array $config */ - public function setConfig(array $config): void { $this->config = $config; } + public function setConfig(array $config): void + { + $this->config = $config; + } } diff --git a/src/Domain/Channel/Repository/ArticleTypePlatformConfigRepositoryInterface.php b/src/Domain/Channel/Repository/ArticleTypePlatformConfigRepositoryInterface.php new file mode 100644 index 0000000..e20f96f --- /dev/null +++ b/src/Domain/Channel/Repository/ArticleTypePlatformConfigRepositoryInterface.php @@ -0,0 +1,22 @@ + */ + public function findByArticleType(Uuid $articleTypeId): array; + + public function save(ArticleTypePlatformConfig $config): void; + + public function remove(ArticleTypePlatformConfig $config): void; +} diff --git a/src/Domain/Channel/Repository/AttributeMappingRepositoryInterface.php b/src/Domain/Channel/Repository/AttributeMappingRepositoryInterface.php new file mode 100644 index 0000000..c7f189c --- /dev/null +++ b/src/Domain/Channel/Repository/AttributeMappingRepositoryInterface.php @@ -0,0 +1,20 @@ + */ + public function findByPlatformConfig(Uuid $platformConfigId): array; + + public function save(AttributeMapping $mapping): void; + + public function remove(AttributeMapping $mapping): void; +} diff --git a/src/Domain/Channel/Repository/ChannelFieldRepositoryInterface.php b/src/Domain/Channel/Repository/ChannelFieldRepositoryInterface.php new file mode 100644 index 0000000..e9729f2 --- /dev/null +++ b/src/Domain/Channel/Repository/ChannelFieldRepositoryInterface.php @@ -0,0 +1,20 @@ + */ + public function findByPlatform(Uuid $platformId): array; + + public function save(ChannelField $field): void; + + public function remove(ChannelField $field): void; +} diff --git a/src/Domain/Order/Customer.php b/src/Domain/Order/Customer.php index a263b30..5c5713b 100644 --- a/src/Domain/Order/Customer.php +++ b/src/Domain/Order/Customer.php @@ -43,18 +43,42 @@ class Customer $this->address = $address; } - public function getId(): Uuid { return $this->id; } - public function getName(): string { return $this->name; } - public function getEmail(): string { return $this->email; } + public function getId(): Uuid + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function getEmail(): string + { + return $this->email; + } /** @return array */ - public function getAddress(): array { return $this->address; } + public function getAddress(): array + { + return $this->address; + } - public function getFrappeCustomerId(): ?string { return $this->frappeCustomerId; } - public function setFrappeCustomerId(?string $id): void { $this->frappeCustomerId = $id; } + public function getFrappeCustomerId(): ?string + { + return $this->frappeCustomerId; + } + + public function setFrappeCustomerId(?string $id): void + { + $this->frappeCustomerId = $id; + } /** @return array */ - public function getPlatformIds(): array { return $this->platformIds; } + public function getPlatformIds(): array + { + return $this->platformIds; + } public function getPlatformId(string $platform): ?string { @@ -68,7 +92,7 @@ class Customer public function getMatchingKey(): string { - return \mb_strtolower(\implode('|', [ + return mb_strtolower(implode('|', [ $this->name, $this->address['street'] ?? '', $this->address['city'] ?? '', diff --git a/src/Domain/Order/Invoice.php b/src/Domain/Order/Invoice.php index 9f5f344..108ec77 100644 --- a/src/Domain/Order/Invoice.php +++ b/src/Domain/Order/Invoice.php @@ -50,13 +50,40 @@ class Invoice $this->createdAt = new \DateTimeImmutable(); } - public function getId(): Uuid { return $this->id; } - public function getOrder(): Order { return $this->order; } - public function getFrappeInvoiceId(): string { return $this->frappeInvoiceId; } - public function getStoragePath(): StoragePath { return $this->storagePath; } - public function getFilename(): string { return $this->filename; } - public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; } - public function getEmailedAt(): ?\DateTimeImmutable { return $this->emailedAt; } + public function getId(): Uuid + { + return $this->id; + } + + public function getOrder(): Order + { + return $this->order; + } + + public function getFrappeInvoiceId(): string + { + return $this->frappeInvoiceId; + } + + public function getStoragePath(): StoragePath + { + return $this->storagePath; + } + + public function getFilename(): string + { + return $this->filename; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + public function getEmailedAt(): ?\DateTimeImmutable + { + return $this->emailedAt; + } public function markAsEmailed(): void { diff --git a/src/Domain/Order/Order.php b/src/Domain/Order/Order.php index 955a3bd..1ad1d98 100644 --- a/src/Domain/Order/Order.php +++ b/src/Domain/Order/Order.php @@ -87,19 +87,78 @@ class Order $this->trackingPushedToEbayAt = new \DateTimeImmutable(); } - public function getId(): Uuid { return $this->id; } - public function getArticle(): Article { return $this->article; } - public function getCustomer(): Customer { return $this->customer; } - public function getPlatform(): Platform { return $this->platform; } - public function getPlatformOrderId(): string { return $this->platformOrderId; } - public function getStatus(): OrderStatus { return $this->status; } - public function setStatus(OrderStatus $status): void { $this->status = $status; } - public function getSalePrice(): string { return $this->salePrice; } - public function getSaleDate(): \DateTimeImmutable { return $this->saleDate; } - public function getTrackingNumber(): ?string { return $this->trackingNumber; } - public function getCarrier(): ?string { return $this->carrier; } - public function getShippedAt(): ?\DateTimeImmutable { return $this->shippedAt; } - public function getTrackingPushedToEbayAt(): ?\DateTimeImmutable { return $this->trackingPushedToEbayAt; } - public function getInvoice(): ?Invoice { return $this->invoice; } - public function setInvoice(Invoice $invoice): void { $this->invoice = $invoice; } + public function getId(): Uuid + { + return $this->id; + } + + public function getArticle(): Article + { + return $this->article; + } + + public function getCustomer(): Customer + { + return $this->customer; + } + + public function getPlatform(): Platform + { + return $this->platform; + } + + public function getPlatformOrderId(): string + { + return $this->platformOrderId; + } + + public function getStatus(): OrderStatus + { + return $this->status; + } + + public function setStatus(OrderStatus $status): void + { + $this->status = $status; + } + + public function getSalePrice(): string + { + return $this->salePrice; + } + + public function getSaleDate(): \DateTimeImmutable + { + return $this->saleDate; + } + + public function getTrackingNumber(): ?string + { + return $this->trackingNumber; + } + + public function getCarrier(): ?string + { + return $this->carrier; + } + + public function getShippedAt(): ?\DateTimeImmutable + { + return $this->shippedAt; + } + + public function getTrackingPushedToEbayAt(): ?\DateTimeImmutable + { + return $this->trackingPushedToEbayAt; + } + + public function getInvoice(): ?Invoice + { + return $this->invoice; + } + + public function setInvoice(Invoice $invoice): void + { + $this->invoice = $invoice; + } } diff --git a/src/Domain/Pipeline/AIPipelineJob.php b/src/Domain/Pipeline/AIPipelineJob.php index 2e4a07c..5d2a043 100644 --- a/src/Domain/Pipeline/AIPipelineJob.php +++ b/src/Domain/Pipeline/AIPipelineJob.php @@ -37,9 +37,9 @@ class AIPipelineJob #[ORM\Column(type: 'json')] private array $outputData = []; - /** @var list */ + /** @var list|null */ #[ORM\Column(type: 'simple_array', nullable: true)] - private array $missingFields = []; + private ?array $missingFields = []; #[ORM\Column(type: 'text', nullable: true)] private ?string $errorMessage = null; @@ -62,7 +62,10 @@ class AIPipelineJob $this->createdAt = new \DateTimeImmutable(); } - public function incrementAttempt(): void { ++$this->attemptCount; } + public function incrementAttempt(): void + { + ++$this->attemptCount; + } public function markCompleted(): void { @@ -82,30 +85,83 @@ class AIPipelineJob $this->errorMessage = $reason; } - public function getId(): Uuid { return $this->id; } - public function getType(): AIPipelineJobType { return $this->type; } - public function getArticle(): ?Article { return $this->article; } - public function setArticle(Article $article): void { $this->article = $article; } - public function getStatus(): AIPipelineJobStatus { return $this->status; } - public function setStatus(AIPipelineJobStatus $status): void { $this->status = $status; } - public function getAttemptCount(): int { return $this->attemptCount; } + public function getId(): Uuid + { + return $this->id; + } + + public function getType(): AIPipelineJobType + { + return $this->type; + } + + public function getArticle(): ?Article + { + return $this->article; + } + + public function setArticle(Article $article): void + { + $this->article = $article; + } + + public function getStatus(): AIPipelineJobStatus + { + return $this->status; + } + + public function setStatus(AIPipelineJobStatus $status): void + { + $this->status = $status; + } + + public function getAttemptCount(): int + { + return $this->attemptCount; + } /** @return array */ - public function getInputData(): array { return $this->inputData; } + public function getInputData(): array + { + return $this->inputData; + } /** @return array */ - public function getOutputData(): array { return $this->outputData; } + public function getOutputData(): array + { + return $this->outputData; + } /** @param array $outputData */ - public function setOutputData(array $outputData): void { $this->outputData = $outputData; } + public function setOutputData(array $outputData): void + { + $this->outputData = $outputData; + } /** @return list */ - public function getMissingFields(): array { return $this->missingFields; } + public function getMissingFields(): array + { + return $this->missingFields ?? []; + } /** @param list $fields */ - public function setMissingFields(array $fields): void { $this->missingFields = $fields; } + public function setMissingFields(array $fields): void + { + $this->missingFields = $fields; + } - public function getErrorMessage(): ?string { return $this->errorMessage; } - public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; } - public function getCompletedAt(): ?\DateTimeImmutable { return $this->completedAt; } + public function getErrorMessage(): ?string + { + return $this->errorMessage; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + public function getCompletedAt(): ?\DateTimeImmutable + { + return $this->completedAt; + } } diff --git a/src/Domain/Storage/Repository/StoragePathRepositoryInterface.php b/src/Domain/Storage/Repository/StoragePathRepositoryInterface.php new file mode 100644 index 0000000..bd360e9 --- /dev/null +++ b/src/Domain/Storage/Repository/StoragePathRepositoryInterface.php @@ -0,0 +1,18 @@ + active paths ordered by priority DESC */ + public function findActiveSortedByPriority(): array; + + public function save(StoragePath $storagePath): void; +} diff --git a/src/Domain/Storage/StoragePath.php b/src/Domain/Storage/StoragePath.php index df082aa..0617aa3 100644 --- a/src/Domain/Storage/StoragePath.php +++ b/src/Domain/Storage/StoragePath.php @@ -45,17 +45,48 @@ class StoragePath $this->isActive = $isActive; } - public function getId(): Uuid { return $this->id; } - public function getLabel(): string { return $this->label; } - public function getBasePath(): string { return $this->basePath; } - public function setBasePath(string $basePath): void { $this->basePath = $basePath; } - public function getQuotaBytes(): int { return $this->quotaBytes; } - public function getPriority(): int { return $this->priority; } - public function isActive(): bool { return $this->isActive; } - public function setIsActive(bool $isActive): void { $this->isActive = $isActive; } + public function getId(): Uuid + { + return $this->id; + } + + public function getLabel(): string + { + return $this->label; + } + + public function getBasePath(): string + { + return $this->basePath; + } + + public function setBasePath(string $basePath): void + { + $this->basePath = $basePath; + } + + public function getQuotaBytes(): int + { + return $this->quotaBytes; + } + + public function getPriority(): int + { + return $this->priority; + } + + public function isActive(): bool + { + return $this->isActive; + } + + public function setIsActive(bool $isActive): void + { + $this->isActive = $isActive; + } public function resolveFilePath(string $filename): string { - return \rtrim($this->basePath, '/').'/'.$filename; + return rtrim($this->basePath, '/').'/'.$filename; } } diff --git a/src/Infrastructure/Http/Controller/Api/ArticleController.php b/src/Infrastructure/Http/Controller/Api/ArticleController.php new file mode 100644 index 0000000..70a54fc --- /dev/null +++ b/src/Infrastructure/Http/Controller/Api/ArticleController.php @@ -0,0 +1,176 @@ +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 + { + $rawValues = $request->toArray()['attributes'] ?? []; + if (!\is_array($rawValues)) { + return $this->json(['error' => 'attributes must be an object mapping definitionId => value'], Response::HTTP_BAD_REQUEST); + } + + /** @var array $values */ + $values = $rawValues; + + 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)) { + $price = null !== $data['listingPrice'] ? (string) $data['listingPrice'] : null; + $this->service->setListingPrice(Uuid::fromString($id), $price); + } + if (\array_key_exists('ebayTitle', $data) || \array_key_exists('ebayDescription', $data)) { + $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]); + } +} diff --git a/src/Infrastructure/Http/Controller/Api/ArticleTypeController.php b/src/Infrastructure/Http/Controller/Api/ArticleTypeController.php new file mode 100644 index 0000000..4cbe8ef --- /dev/null +++ b/src/Infrastructure/Http/Controller/Api/ArticleTypeController.php @@ -0,0 +1,135 @@ +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); + } +} diff --git a/src/Infrastructure/Http/Controller/Api/MappingController.php b/src/Infrastructure/Http/Controller/Api/MappingController.php new file mode 100644 index 0000000..5359d23 --- /dev/null +++ b/src/Infrastructure/Http/Controller/Api/MappingController.php @@ -0,0 +1,90 @@ +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); + } +} diff --git a/src/Infrastructure/Http/Controller/Api/PhotoController.php b/src/Infrastructure/Http/Controller/Api/PhotoController.php new file mode 100644 index 0000000..f6e5e28 --- /dev/null +++ b/src/Infrastructure/Http/Controller/Api/PhotoController.php @@ -0,0 +1,76 @@ +files->get('photo'); + if (!$file instanceof UploadedFile) { + 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); + } +} diff --git a/src/Infrastructure/Http/Controller/Api/PlatformController.php b/src/Infrastructure/Http/Controller/Api/PlatformController.php new file mode 100644 index 0000000..e9aa8e0 --- /dev/null +++ b/src/Infrastructure/Http/Controller/Api/PlatformController.php @@ -0,0 +1,85 @@ +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); + } +} diff --git a/src/Infrastructure/Persistence/Repository/DoctrineArticlePhotoRepository.php b/src/Infrastructure/Persistence/Repository/DoctrineArticlePhotoRepository.php new file mode 100644 index 0000000..b78e782 --- /dev/null +++ b/src/Infrastructure/Persistence/Repository/DoctrineArticlePhotoRepository.php @@ -0,0 +1,47 @@ +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(); + } +} diff --git a/src/Infrastructure/Persistence/Repository/DoctrineArticleRepository.php b/src/Infrastructure/Persistence/Repository/DoctrineArticleRepository.php index 13fe758..d4ce6de 100644 --- a/src/Infrastructure/Persistence/Repository/DoctrineArticleRepository.php +++ b/src/Infrastructure/Persistence/Repository/DoctrineArticleRepository.php @@ -12,7 +12,9 @@ use Symfony\Component\Uid\Uuid; final class DoctrineArticleRepository implements ArticleRepositoryInterface { - public function __construct(private readonly EntityManagerInterface $em) {} + public function __construct(private readonly EntityManagerInterface $em) + { + } public function findById(Uuid $id): ?Article { diff --git a/src/Infrastructure/Persistence/Repository/DoctrineArticleTypePlatformConfigRepository.php b/src/Infrastructure/Persistence/Repository/DoctrineArticleTypePlatformConfigRepository.php new file mode 100644 index 0000000..ddbd462 --- /dev/null +++ b/src/Infrastructure/Persistence/Repository/DoctrineArticleTypePlatformConfigRepository.php @@ -0,0 +1,60 @@ +em->find(ArticleTypePlatformConfig::class, $id); + } + + public function findByArticleTypeAndPlatform(Uuid $articleTypeId, Uuid $platformId): ?ArticleTypePlatformConfig + { + /** @var ArticleTypePlatformConfig|null */ + 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(); + } +} diff --git a/src/Infrastructure/Persistence/Repository/DoctrineArticleTypeRepository.php b/src/Infrastructure/Persistence/Repository/DoctrineArticleTypeRepository.php index ebe67d2..fc2dcc2 100644 --- a/src/Infrastructure/Persistence/Repository/DoctrineArticleTypeRepository.php +++ b/src/Infrastructure/Persistence/Repository/DoctrineArticleTypeRepository.php @@ -11,7 +11,9 @@ use Symfony\Component\Uid\Uuid; final class DoctrineArticleTypeRepository implements ArticleTypeRepositoryInterface { - public function __construct(private readonly EntityManagerInterface $em) {} + public function __construct(private readonly EntityManagerInterface $em) + { + } public function findById(Uuid $id): ?ArticleType { diff --git a/src/Infrastructure/Persistence/Repository/DoctrineAttributeMappingRepository.php b/src/Infrastructure/Persistence/Repository/DoctrineAttributeMappingRepository.php new file mode 100644 index 0000000..adba1c2 --- /dev/null +++ b/src/Infrastructure/Persistence/Repository/DoctrineAttributeMappingRepository.php @@ -0,0 +1,46 @@ +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(); + } +} diff --git a/src/Infrastructure/Persistence/Repository/DoctrineAttributeValueRepository.php b/src/Infrastructure/Persistence/Repository/DoctrineAttributeValueRepository.php new file mode 100644 index 0000000..4e4fa10 --- /dev/null +++ b/src/Infrastructure/Persistence/Repository/DoctrineAttributeValueRepository.php @@ -0,0 +1,55 @@ + */ + 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 + { + /** @var AttributeValue|null */ + 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(); + } +} diff --git a/src/Infrastructure/Persistence/Repository/DoctrineChannelFieldRepository.php b/src/Infrastructure/Persistence/Repository/DoctrineChannelFieldRepository.php new file mode 100644 index 0000000..d99518a --- /dev/null +++ b/src/Infrastructure/Persistence/Repository/DoctrineChannelFieldRepository.php @@ -0,0 +1,47 @@ +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(); + } +} diff --git a/src/Infrastructure/Persistence/Repository/DoctrineCustomerRepository.php b/src/Infrastructure/Persistence/Repository/DoctrineCustomerRepository.php index da26ccc..682da94 100644 --- a/src/Infrastructure/Persistence/Repository/DoctrineCustomerRepository.php +++ b/src/Infrastructure/Persistence/Repository/DoctrineCustomerRepository.php @@ -11,7 +11,9 @@ use Symfony\Component\Uid\Uuid; final class DoctrineCustomerRepository implements CustomerRepositoryInterface { - public function __construct(private readonly EntityManagerInterface $em) {} + public function __construct(private readonly EntityManagerInterface $em) + { + } public function findById(Uuid $id): ?Customer { @@ -20,9 +22,10 @@ final class DoctrineCustomerRepository implements CustomerRepositoryInterface public function findByPlatformId(string $platform, string $platformUserId): ?Customer { + /** @var Customer|null */ return $this->em->getRepository(Customer::class) ->createQueryBuilder('c') - ->where("JSON_UNQUOTE(JSON_EXTRACT(c.platformIds, :path)) = :id") + ->where('JSON_UNQUOTE(JSON_EXTRACT(c.platformIds, :path)) = :id') ->setParameter('path', '$."'.$platform.'"') ->setParameter('id', $platformUserId) ->setMaxResults(1) diff --git a/src/Infrastructure/Persistence/Repository/DoctrineOrderRepository.php b/src/Infrastructure/Persistence/Repository/DoctrineOrderRepository.php index 9340e0e..6b59d4d 100644 --- a/src/Infrastructure/Persistence/Repository/DoctrineOrderRepository.php +++ b/src/Infrastructure/Persistence/Repository/DoctrineOrderRepository.php @@ -11,7 +11,9 @@ use Symfony\Component\Uid\Uuid; final class DoctrineOrderRepository implements OrderRepositoryInterface { - public function __construct(private readonly EntityManagerInterface $em) {} + public function __construct(private readonly EntityManagerInterface $em) + { + } public function findById(Uuid $id): ?Order { diff --git a/src/Infrastructure/Persistence/Repository/DoctrinePlatformRepository.php b/src/Infrastructure/Persistence/Repository/DoctrinePlatformRepository.php index 30ab749..3fab6c1 100644 --- a/src/Infrastructure/Persistence/Repository/DoctrinePlatformRepository.php +++ b/src/Infrastructure/Persistence/Repository/DoctrinePlatformRepository.php @@ -11,7 +11,9 @@ use Symfony\Component\Uid\Uuid; final class DoctrinePlatformRepository implements PlatformRepositoryInterface { - public function __construct(private readonly EntityManagerInterface $em) {} + public function __construct(private readonly EntityManagerInterface $em) + { + } public function findById(Uuid $id): ?Platform { diff --git a/src/Infrastructure/Persistence/Repository/DoctrineStoragePathRepository.php b/src/Infrastructure/Persistence/Repository/DoctrineStoragePathRepository.php new file mode 100644 index 0000000..d2e5ee5 --- /dev/null +++ b/src/Infrastructure/Persistence/Repository/DoctrineStoragePathRepository.php @@ -0,0 +1,41 @@ +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(); + } +} diff --git a/src/Infrastructure/Storage/LocalStorageManager.php b/src/Infrastructure/Storage/LocalStorageManager.php new file mode 100644 index 0000000..6d112c2 --- /dev/null +++ b/src/Infrastructure/Storage/LocalStorageManager.php @@ -0,0 +1,76 @@ +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) { + if ($file instanceof \SplFileInfo) { + $total += $file->getSize(); + } + } + + return $total; + } +} diff --git a/tests/Unit/Application/Article/ArticleTypeServiceTest.php b/tests/Unit/Application/Article/ArticleTypeServiceTest.php new file mode 100644 index 0000000..d5e7357 --- /dev/null +++ b/tests/Unit/Application/Article/ArticleTypeServiceTest.php @@ -0,0 +1,66 @@ +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()); + } +} diff --git a/tests/Unit/Application/Article/ArticleValidatorTest.php b/tests/Unit/Application/Article/ArticleValidatorTest.php new file mode 100644 index 0000000..107eebb --- /dev/null +++ b/tests/Unit/Application/Article/ArticleValidatorTest.php @@ -0,0 +1,66 @@ +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); + } +} diff --git a/tests/Unit/Application/Storage/LocalStorageManagerTest.php b/tests/Unit/Application/Storage/LocalStorageManagerTest.php new file mode 100644 index 0000000..ba95cce --- /dev/null +++ b/tests/Unit/Application/Storage/LocalStorageManagerTest.php @@ -0,0 +1,88 @@ +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')); + } +}