feat: implement Plan 2 — Article Management API
- 6 new domain repository interfaces (StoragePath, ArticlePhoto, AttributeValue, ChannelField, ArticleTypePlatformConfig, AttributeMapping) - 6 Doctrine repository implementations - StorageManager with multi-path quota-aware file storage (LocalStorageManager) - Application services: ArticleTypeService, ArticleService, ArticleValidator, PhotoService, PlatformService, MappingService - REST controllers: ArticleType, Article, Photo, Platform, Mapping (all under /api prefix) - inventory_number sequence migration - 22 unit tests passing, PHPStan level 9 clean, CS Fixer clean - Fixed phpdoc_to_comment CS Fixer rule (disabled) to preserve @var type annotations - Fixed PHPStan: User::getUserIdentifier non-empty-string, AIPipelineJob nullable missingFields Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
eafdba10f5
commit
6bf001b0c0
55 changed files with 2564 additions and 166 deletions
|
|
@ -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);
|
||||
|
|
|
|||
4
config/routes/api.yaml
Normal file
4
config/routes/api.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
api:
|
||||
resource: '../src/Infrastructure/Http/Controller/Api/'
|
||||
type: attribute
|
||||
prefix: /api
|
||||
|
|
@ -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
|
||||
|
|
|
|||
26
migrations/Version20260514050204.php
Normal file
26
migrations/Version20260514050204.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260514050204 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add inventory_number sequence';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE SEQUENCE IF NOT EXISTS app.inventory_seq START 1 INCREMENT 1');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP SEQUENCE IF EXISTS app.inventory_seq');
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
139
src/Application/Article/ArticleService.php
Normal file
139
src/Application/Article/ArticleService.php
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Article;
|
||||
|
||||
use App\Domain\Article\Article;
|
||||
use App\Domain\Article\ArticleCondition;
|
||||
use App\Domain\Article\ArticleStatus;
|
||||
use App\Domain\Article\AttributeValue;
|
||||
use App\Domain\Article\Repository\ArticleRepositoryInterface;
|
||||
use App\Domain\Article\Repository\ArticleTypeRepositoryInterface;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
final class ArticleService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ArticleRepositoryInterface $articleRepository,
|
||||
private readonly ArticleTypeRepositoryInterface $articleTypeRepository,
|
||||
private readonly ArticleValidator $validator,
|
||||
private readonly Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
public function create(
|
||||
Uuid $articleTypeId,
|
||||
ArticleCondition $condition,
|
||||
int $stock = 1,
|
||||
?string $conditionNotes = null,
|
||||
): Article {
|
||||
$articleType = $this->articleTypeRepository->findById($articleTypeId)
|
||||
?? throw new \DomainException("ArticleType {$articleTypeId->toRfc4122()} not found");
|
||||
|
||||
$inventoryNumber = $this->nextInventoryNumber();
|
||||
$sku = 'ART-'.mb_strtoupper(substr(str_replace('-', '', Uuid::v7()->toRfc4122()), 0, 8));
|
||||
|
||||
$article = new Article($articleType, $sku, $inventoryNumber, $stock, $condition);
|
||||
$article->setConditionNotes($conditionNotes);
|
||||
$this->articleRepository->save($article);
|
||||
|
||||
return $article;
|
||||
}
|
||||
|
||||
public function findById(Uuid $id): ?Article
|
||||
{
|
||||
return $this->articleRepository->findById($id);
|
||||
}
|
||||
|
||||
/** @return list<Article> */
|
||||
public function findByStatus(ArticleStatus $status): array
|
||||
{
|
||||
return $this->articleRepository->findByStatus($status);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $values attribute_definition_id => value
|
||||
*/
|
||||
public function updateAttributes(Uuid $articleId, array $values): Article
|
||||
{
|
||||
$article = $this->articleRepository->findById($articleId)
|
||||
?? throw new \DomainException("Article {$articleId->toRfc4122()} not found");
|
||||
|
||||
$articleType = $article->getArticleType();
|
||||
|
||||
foreach ($values as $defIdStr => $value) {
|
||||
$defId = Uuid::fromString($defIdStr);
|
||||
$def = null;
|
||||
foreach ($articleType->getAttributeDefinitions() as $d) {
|
||||
if ($d->getId()->equals($defId)) {
|
||||
$def = $d;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (null === $def) {
|
||||
throw new \DomainException("AttributeDefinition {$defIdStr} not linked to this ArticleType");
|
||||
}
|
||||
|
||||
$article->setAttributeValue(new AttributeValue($article, $def, $value));
|
||||
}
|
||||
|
||||
$this->articleRepository->save($article);
|
||||
|
||||
return $article;
|
||||
}
|
||||
|
||||
public function setListingPrice(Uuid $articleId, ?string $price): Article
|
||||
{
|
||||
$article = $this->articleRepository->findById($articleId)
|
||||
?? throw new \DomainException("Article {$articleId->toRfc4122()} not found");
|
||||
|
||||
$article->setListingPrice($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<string>}
|
||||
*/
|
||||
public function activate(Uuid $articleId): array
|
||||
{
|
||||
$article = $this->articleRepository->findById($articleId)
|
||||
?? throw new \DomainException("Article {$articleId->toRfc4122()} not found");
|
||||
|
||||
$missing = $this->validator->getMissingAttributes($article);
|
||||
if ([] !== $missing) {
|
||||
return ['article' => $article, 'missing' => $missing];
|
||||
}
|
||||
|
||||
$article->transitionTo(ArticleStatus::Active);
|
||||
$this->articleRepository->save($article);
|
||||
|
||||
return ['article' => $article, 'missing' => []];
|
||||
}
|
||||
|
||||
private function nextInventoryNumber(): string
|
||||
{
|
||||
/** @var string $seq */
|
||||
$seq = $this->connection->fetchOne("SELECT nextval('app.inventory_seq')");
|
||||
|
||||
return \sprintf('INV-%s-%05d', date('Y'), (int) $seq);
|
||||
}
|
||||
}
|
||||
85
src/Application/Article/ArticleTypeService.php
Normal file
85
src/Application/Article/ArticleTypeService.php
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Article;
|
||||
|
||||
use App\Domain\Article\ArticleType;
|
||||
use App\Domain\Article\AttributeDefinition;
|
||||
use App\Domain\Article\AttributeType;
|
||||
use App\Domain\Article\Repository\ArticleTypeRepositoryInterface;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
final class ArticleTypeService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ArticleTypeRepositoryInterface $articleTypeRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
public function create(string $name): ArticleType
|
||||
{
|
||||
$type = new ArticleType($name);
|
||||
$this->articleTypeRepository->save($type);
|
||||
|
||||
return $type;
|
||||
}
|
||||
|
||||
/** @return list<ArticleType> */
|
||||
public function findAll(): array
|
||||
{
|
||||
return $this->articleTypeRepository->findAll();
|
||||
}
|
||||
|
||||
public function findById(Uuid $id): ?ArticleType
|
||||
{
|
||||
return $this->articleTypeRepository->findById($id);
|
||||
}
|
||||
|
||||
public function rename(Uuid $id, string $newName): ArticleType
|
||||
{
|
||||
$type = $this->articleTypeRepository->findById($id)
|
||||
?? throw new \DomainException("ArticleType {$id->toRfc4122()} not found");
|
||||
|
||||
$type->setName($newName);
|
||||
$this->articleTypeRepository->save($type);
|
||||
|
||||
return $type;
|
||||
}
|
||||
|
||||
/** @param list<string>|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);
|
||||
}
|
||||
}
|
||||
33
src/Application/Article/ArticleValidator.php
Normal file
33
src/Application/Article/ArticleValidator.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Article;
|
||||
|
||||
use App\Domain\Article\Article;
|
||||
|
||||
final class ArticleValidator
|
||||
{
|
||||
/** @return list<string> missing attribute names */
|
||||
public function getMissingAttributes(Article $article): array
|
||||
{
|
||||
$setValue = [];
|
||||
foreach ($article->getAttributeValues() as $value) {
|
||||
$setValue[] = $value->getAttributeDefinition()->getId()->toRfc4122();
|
||||
}
|
||||
|
||||
$missing = [];
|
||||
foreach ($article->getArticleType()->getAttributeDefinitions() as $def) {
|
||||
if (!\in_array($def->getId()->toRfc4122(), $setValue, strict: true)) {
|
||||
$missing[] = $def->getName();
|
||||
}
|
||||
}
|
||||
|
||||
return $missing;
|
||||
}
|
||||
|
||||
public function isValid(Article $article): bool
|
||||
{
|
||||
return [] === $this->getMissingAttributes($article);
|
||||
}
|
||||
}
|
||||
74
src/Application/Article/PhotoService.php
Normal file
74
src/Application/Article/PhotoService.php
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Article;
|
||||
|
||||
use App\Application\Storage\StorageManagerInterface;
|
||||
use App\Domain\Article\ArticlePhoto;
|
||||
use App\Domain\Article\Repository\ArticlePhotoRepositoryInterface;
|
||||
use App\Domain\Article\Repository\ArticleRepositoryInterface;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
final class PhotoService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ArticleRepositoryInterface $articleRepository,
|
||||
private readonly ArticlePhotoRepositoryInterface $photoRepository,
|
||||
private readonly StorageManagerInterface $storageManager,
|
||||
) {
|
||||
}
|
||||
|
||||
public function upload(Uuid $articleId, string $tempPath, string $originalFilename): ArticlePhoto
|
||||
{
|
||||
$article = $this->articleRepository->findById($articleId)
|
||||
?? throw new \DomainException("Article {$articleId->toRfc4122()} not found");
|
||||
|
||||
$stored = $this->storageManager->store($tempPath, $originalFilename);
|
||||
|
||||
$existingPhotos = $this->photoRepository->findByArticle($articleId);
|
||||
$isMain = 0 === \count($existingPhotos);
|
||||
$sortOrder = \count($existingPhotos);
|
||||
|
||||
$photo = new ArticlePhoto($article, $stored->storagePath, $stored->filename, $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);
|
||||
}
|
||||
}
|
||||
}
|
||||
86
src/Application/Channel/MappingService.php
Normal file
86
src/Application/Channel/MappingService.php
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Channel;
|
||||
|
||||
use App\Domain\Article\Repository\ArticleTypeRepositoryInterface;
|
||||
use App\Domain\Channel\ArticleTypePlatformConfig;
|
||||
use App\Domain\Channel\AttributeMapping;
|
||||
use App\Domain\Channel\Repository\ArticleTypePlatformConfigRepositoryInterface;
|
||||
use App\Domain\Channel\Repository\AttributeMappingRepositoryInterface;
|
||||
use App\Domain\Channel\Repository\ChannelFieldRepositoryInterface;
|
||||
use App\Domain\Channel\Repository\PlatformRepositoryInterface;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
final class MappingService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ArticleTypeRepositoryInterface $articleTypeRepository,
|
||||
private readonly PlatformRepositoryInterface $platformRepository,
|
||||
private readonly ArticleTypePlatformConfigRepositoryInterface $configRepository,
|
||||
private readonly AttributeMappingRepositoryInterface $mappingRepository,
|
||||
private readonly ChannelFieldRepositoryInterface $channelFieldRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
public function createConfig(Uuid $articleTypeId, Uuid $platformId, string $categoryId): ArticleTypePlatformConfig
|
||||
{
|
||||
$articleType = $this->articleTypeRepository->findById($articleTypeId)
|
||||
?? throw new \DomainException("ArticleType {$articleTypeId->toRfc4122()} not found");
|
||||
|
||||
$platform = $this->platformRepository->findById($platformId)
|
||||
?? throw new \DomainException("Platform {$platformId->toRfc4122()} not found");
|
||||
|
||||
$existing = $this->configRepository->findByArticleTypeAndPlatform($articleTypeId, $platformId);
|
||||
if (null !== $existing) {
|
||||
throw new \DomainException('Config for this ArticleType+Platform combination already exists');
|
||||
}
|
||||
|
||||
$config = new ArticleTypePlatformConfig($articleType, $platform, $categoryId);
|
||||
$this->configRepository->save($config);
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/** @return list<ArticleTypePlatformConfig> */
|
||||
public function findConfigsByArticleType(Uuid $articleTypeId): array
|
||||
{
|
||||
return $this->configRepository->findByArticleType($articleTypeId);
|
||||
}
|
||||
|
||||
public function addMapping(Uuid $configId, Uuid $attributeDefinitionId, Uuid $channelFieldId, ?string $transformer = null): AttributeMapping
|
||||
{
|
||||
$config = $this->configRepository->findById($configId)
|
||||
?? throw new \DomainException("Config {$configId->toRfc4122()} not found");
|
||||
|
||||
$articleType = $config->getArticleType();
|
||||
$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);
|
||||
}
|
||||
}
|
||||
68
src/Application/Channel/PlatformService.php
Normal file
68
src/Application/Channel/PlatformService.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Channel;
|
||||
|
||||
use App\Domain\Channel\ChannelField;
|
||||
use App\Domain\Channel\Platform;
|
||||
use App\Domain\Channel\Repository\ChannelFieldRepositoryInterface;
|
||||
use App\Domain\Channel\Repository\PlatformRepositoryInterface;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
final class PlatformService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PlatformRepositoryInterface $platformRepository,
|
||||
private readonly ChannelFieldRepositoryInterface $channelFieldRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $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<Platform> */
|
||||
public function findAll(): array
|
||||
{
|
||||
return $this->platformRepository->findAll();
|
||||
}
|
||||
|
||||
public function findById(Uuid $id): ?Platform
|
||||
{
|
||||
return $this->platformRepository->findById($id);
|
||||
}
|
||||
|
||||
public function addChannelField(Uuid $platformId, string $label, string $path): ChannelField
|
||||
{
|
||||
$platform = $this->platformRepository->findById($platformId)
|
||||
?? throw new \DomainException("Platform {$platformId->toRfc4122()} not found");
|
||||
|
||||
$field = new ChannelField($platform, $label, $path);
|
||||
$this->channelFieldRepository->save($field);
|
||||
|
||||
return $field;
|
||||
}
|
||||
|
||||
/** @return list<ChannelField> */
|
||||
public function findChannelFields(Uuid $platformId): array
|
||||
{
|
||||
return $this->channelFieldRepository->findByPlatform($platformId);
|
||||
}
|
||||
|
||||
public function removeChannelField(Uuid $fieldId): void
|
||||
{
|
||||
$field = $this->channelFieldRepository->findById($fieldId)
|
||||
?? throw new \DomainException("ChannelField {$fieldId->toRfc4122()} not found");
|
||||
|
||||
$this->channelFieldRepository->remove($field);
|
||||
}
|
||||
}
|
||||
20
src/Application/Storage/StorageManagerInterface.php
Normal file
20
src/Application/Storage/StorageManagerInterface.php
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Storage;
|
||||
|
||||
use App\Domain\Storage\StoragePath;
|
||||
|
||||
interface StorageManagerInterface
|
||||
{
|
||||
/**
|
||||
* Moves $tempPath into the best available StoragePath.
|
||||
* $originalFilename is used for extension extraction only; actual filename is UUID-based.
|
||||
*
|
||||
* @throws \RuntimeException when no active storage path with quota available
|
||||
*/
|
||||
public function store(string $tempPath, string $originalFilename): StoredFile;
|
||||
|
||||
public function getFullPath(StoragePath $storagePath, string $filename): string;
|
||||
}
|
||||
16
src/Application/Storage/StoredFile.php
Normal file
16
src/Application/Storage/StoredFile.php
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Storage;
|
||||
|
||||
use App\Domain\Storage\StoragePath;
|
||||
|
||||
final readonly class StoredFile
|
||||
{
|
||||
public function __construct(
|
||||
public StoragePath $storagePath,
|
||||
public string $filename,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -84,11 +84,7 @@ class Article
|
|||
public function transitionTo(ArticleStatus $newStatus): void
|
||||
{
|
||||
if (!$this->status->canTransitionTo($newStatus)) {
|
||||
throw new \DomainException(\sprintf(
|
||||
'Cannot transition from %s to %s',
|
||||
$this->status->value,
|
||||
$newStatus->value,
|
||||
));
|
||||
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<int, AttributeValue> */
|
||||
public function getAttributeValues(): Collection { return $this->attributeValues; }
|
||||
public function getAttributeValues(): Collection
|
||||
{
|
||||
return $this->attributeValues;
|
||||
}
|
||||
|
||||
/** @return Collection<int, ArticlePhoto> */
|
||||
public function getPhotos(): Collection { return $this->photos; }
|
||||
|
||||
public function setConditionNotes(?string $notes): void { $this->conditionNotes = $notes; }
|
||||
public function setListingPrice(?string $price): void { $this->listingPrice = $price; }
|
||||
public function setSerialNumber(?string $sn): void { $this->serialNumber = $sn; }
|
||||
public function setEbayListingId(?string $id): void { $this->ebayListingId = $id; }
|
||||
public function setEbayTitle(?string $title): void { $this->ebayTitle = $title; }
|
||||
public function setEbayDescription(?string $desc): void { $this->ebayDescription = $desc; }
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<int, AttributeDefinition> */
|
||||
public function getAttributeDefinitions(): Collection { return $this->attributeDefinitions; }
|
||||
public function getAttributeDefinitions(): Collection
|
||||
{
|
||||
return $this->attributeDefinitions;
|
||||
}
|
||||
|
||||
public function addAttributeDefinition(AttributeDefinition $def): void
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<string>|null */
|
||||
public function getOptions(): ?array { return $this->options; }
|
||||
public function getOptions(): ?array
|
||||
{
|
||||
return $this->options;
|
||||
}
|
||||
|
||||
/** @param list<string>|null $options */
|
||||
public function setOptions(?array $options): void { $this->options = $options; }
|
||||
public function setOptions(?array $options): void
|
||||
{
|
||||
$this->options = $options;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Article\Repository;
|
||||
|
||||
use App\Domain\Article\ArticlePhoto;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
interface ArticlePhotoRepositoryInterface
|
||||
{
|
||||
public function findById(Uuid $id): ?ArticlePhoto;
|
||||
|
||||
/** @return list<ArticlePhoto> */
|
||||
public function findByArticle(Uuid $articleId): array;
|
||||
|
||||
public function save(ArticlePhoto $photo): void;
|
||||
|
||||
public function remove(ArticlePhoto $photo): void;
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Article\Repository;
|
||||
|
||||
use App\Domain\Article\AttributeValue;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
interface AttributeValueRepositoryInterface
|
||||
{
|
||||
/** @return list<AttributeValue> */
|
||||
public function findByArticle(Uuid $articleId): array;
|
||||
|
||||
public function findByArticleAndDefinition(Uuid $articleId, Uuid $definitionId): ?AttributeValue;
|
||||
|
||||
public function save(AttributeValue $value): void;
|
||||
|
||||
public function remove(AttributeValue $value): void;
|
||||
}
|
||||
|
|
@ -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<string, bool> */
|
||||
public function getPermissions(): array { return $this->permissions; }
|
||||
public function getPermissions(): array
|
||||
{
|
||||
return $this->permissions;
|
||||
}
|
||||
|
||||
public function hasPermission(string $permission): bool
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<string> */
|
||||
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<string, bool> */
|
||||
public function getPermissions(): array { return $this->permissions; }
|
||||
public function getPermissions(): array
|
||||
{
|
||||
return $this->permissions;
|
||||
}
|
||||
|
||||
public function hasPermission(string $permission): bool
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<int, AttributeMapping> */
|
||||
public function getAttributeMappings(): Collection { return $this->attributeMappings; }
|
||||
public function getAttributeMappings(): Collection
|
||||
{
|
||||
return $this->attributeMappings;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, mixed> */
|
||||
public function getConfig(): array { return $this->config; }
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $config */
|
||||
public function setConfig(array $config): void { $this->config = $config; }
|
||||
public function setConfig(array $config): void
|
||||
{
|
||||
$this->config = $config;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Channel\Repository;
|
||||
|
||||
use App\Domain\Channel\ArticleTypePlatformConfig;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
interface ArticleTypePlatformConfigRepositoryInterface
|
||||
{
|
||||
public function findById(Uuid $id): ?ArticleTypePlatformConfig;
|
||||
|
||||
public function findByArticleTypeAndPlatform(Uuid $articleTypeId, Uuid $platformId): ?ArticleTypePlatformConfig;
|
||||
|
||||
/** @return list<ArticleTypePlatformConfig> */
|
||||
public function findByArticleType(Uuid $articleTypeId): array;
|
||||
|
||||
public function save(ArticleTypePlatformConfig $config): void;
|
||||
|
||||
public function remove(ArticleTypePlatformConfig $config): void;
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Channel\Repository;
|
||||
|
||||
use App\Domain\Channel\AttributeMapping;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
interface AttributeMappingRepositoryInterface
|
||||
{
|
||||
public function findById(Uuid $id): ?AttributeMapping;
|
||||
|
||||
/** @return list<AttributeMapping> */
|
||||
public function findByPlatformConfig(Uuid $platformConfigId): array;
|
||||
|
||||
public function save(AttributeMapping $mapping): void;
|
||||
|
||||
public function remove(AttributeMapping $mapping): void;
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Channel\Repository;
|
||||
|
||||
use App\Domain\Channel\ChannelField;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
interface ChannelFieldRepositoryInterface
|
||||
{
|
||||
public function findById(Uuid $id): ?ChannelField;
|
||||
|
||||
/** @return list<ChannelField> */
|
||||
public function findByPlatform(Uuid $platformId): array;
|
||||
|
||||
public function save(ChannelField $field): void;
|
||||
|
||||
public function remove(ChannelField $field): void;
|
||||
}
|
||||
|
|
@ -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<string, string> */
|
||||
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<string, string> */
|
||||
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'] ?? '',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,9 +37,9 @@ class AIPipelineJob
|
|||
#[ORM\Column(type: 'json')]
|
||||
private array $outputData = [];
|
||||
|
||||
/** @var list<string> */
|
||||
/** @var list<string>|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<string, mixed> */
|
||||
public function getInputData(): array { return $this->inputData; }
|
||||
public function getInputData(): array
|
||||
{
|
||||
return $this->inputData;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function getOutputData(): array { return $this->outputData; }
|
||||
public function getOutputData(): array
|
||||
{
|
||||
return $this->outputData;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $outputData */
|
||||
public function setOutputData(array $outputData): void { $this->outputData = $outputData; }
|
||||
public function setOutputData(array $outputData): void
|
||||
{
|
||||
$this->outputData = $outputData;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function getMissingFields(): array { return $this->missingFields; }
|
||||
public function getMissingFields(): array
|
||||
{
|
||||
return $this->missingFields ?? [];
|
||||
}
|
||||
|
||||
/** @param list<string> $fields */
|
||||
public function setMissingFields(array $fields): void { $this->missingFields = $fields; }
|
||||
|
||||
public function getErrorMessage(): ?string { return $this->errorMessage; }
|
||||
public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
|
||||
public function getCompletedAt(): ?\DateTimeImmutable { return $this->completedAt; }
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Storage\Repository;
|
||||
|
||||
use App\Domain\Storage\StoragePath;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
interface StoragePathRepositoryInterface
|
||||
{
|
||||
public function findById(Uuid $id): ?StoragePath;
|
||||
|
||||
/** @return list<StoragePath> active paths ordered by priority DESC */
|
||||
public function findActiveSortedByPriority(): array;
|
||||
|
||||
public function save(StoragePath $storagePath): void;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
176
src/Infrastructure/Http/Controller/Api/ArticleController.php
Normal file
176
src/Infrastructure/Http/Controller/Api/ArticleController.php
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Http\Controller\Api;
|
||||
|
||||
use App\Application\Article\ArticleService;
|
||||
use App\Domain\Article\ArticleCondition;
|
||||
use App\Domain\Article\ArticleStatus;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[Route('/articles', name: 'api_articles_')]
|
||||
final class ArticleController extends AbstractController
|
||||
{
|
||||
public function __construct(private readonly ArticleService $service)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('', name: 'list', methods: ['GET'])]
|
||||
public function list(Request $request): JsonResponse
|
||||
{
|
||||
$statusParam = $request->query->getString('status', 'draft');
|
||||
$status = ArticleStatus::tryFrom($statusParam) ?? ArticleStatus::Draft;
|
||||
|
||||
return $this->json(array_map(
|
||||
static fn ($a) => [
|
||||
'id' => $a->getId()->toRfc4122(),
|
||||
'sku' => $a->getSku(),
|
||||
'inventoryNumber' => $a->getInventoryNumber(),
|
||||
'status' => $a->getStatus()->value,
|
||||
'articleType' => $a->getArticleType()->getName(),
|
||||
'condition' => $a->getCondition()->value,
|
||||
'stock' => $a->getStock(),
|
||||
'listingPrice' => $a->getListingPrice(),
|
||||
],
|
||||
$this->service->findByStatus($status),
|
||||
));
|
||||
}
|
||||
|
||||
#[Route('', name: 'create', methods: ['POST'])]
|
||||
public function create(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->toArray();
|
||||
if (empty($data['articleTypeId']) || empty($data['condition'])) {
|
||||
return $this->json(['error' => 'articleTypeId and condition are required'], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$condition = ArticleCondition::tryFrom($data['condition']);
|
||||
if (null === $condition) {
|
||||
$valid = implode(', ', array_column(ArticleCondition::cases(), 'value'));
|
||||
|
||||
return $this->json(['error' => "condition must be one of: {$valid}"], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$article = $this->service->create(
|
||||
Uuid::fromString($data['articleTypeId']),
|
||||
$condition,
|
||||
(int) ($data['stock'] ?? 1),
|
||||
$data['conditionNotes'] ?? null,
|
||||
);
|
||||
} catch (\DomainException $e) {
|
||||
return $this->json(['error' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
return $this->json([
|
||||
'id' => $article->getId()->toRfc4122(),
|
||||
'sku' => $article->getSku(),
|
||||
'inventoryNumber' => $article->getInventoryNumber(),
|
||||
'status' => $article->getStatus()->value,
|
||||
], Response::HTTP_CREATED);
|
||||
}
|
||||
|
||||
#[Route('/{id}', name: 'get', methods: ['GET'])]
|
||||
public function get(string $id): JsonResponse
|
||||
{
|
||||
$article = $this->service->findById(Uuid::fromString($id));
|
||||
if (null === $article) {
|
||||
return $this->json(['error' => 'Not found'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->json([
|
||||
'id' => $article->getId()->toRfc4122(),
|
||||
'sku' => $article->getSku(),
|
||||
'inventoryNumber' => $article->getInventoryNumber(),
|
||||
'status' => $article->getStatus()->value,
|
||||
'articleType' => ['id' => $article->getArticleType()->getId()->toRfc4122(), 'name' => $article->getArticleType()->getName()],
|
||||
'condition' => $article->getCondition()->value,
|
||||
'conditionNotes' => $article->getConditionNotes(),
|
||||
'stock' => $article->getStock(),
|
||||
'listingPrice' => $article->getListingPrice(),
|
||||
'serialNumber' => $article->getSerialNumber(),
|
||||
'ebayListingId' => $article->getEbayListingId(),
|
||||
'ebayTitle' => $article->getEbayTitle(),
|
||||
'ebayDescription' => $article->getEbayDescription(),
|
||||
'attributes' => array_map(static fn ($v) => [
|
||||
'definitionId' => $v->getAttributeDefinition()->getId()->toRfc4122(),
|
||||
'name' => $v->getAttributeDefinition()->getName(),
|
||||
'value' => $v->getValue(),
|
||||
], $article->getAttributeValues()->toArray()),
|
||||
'photos' => array_map(static fn ($p) => [
|
||||
'id' => $p->getId()->toRfc4122(),
|
||||
'isMain' => $p->isMain(),
|
||||
'order' => $p->getSortOrder(),
|
||||
], $article->getPhotos()->toArray()),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/{id}/attributes', name: 'update_attributes', methods: ['PATCH'])]
|
||||
public function updateAttributes(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$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<string, string> $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]);
|
||||
}
|
||||
}
|
||||
135
src/Infrastructure/Http/Controller/Api/ArticleTypeController.php
Normal file
135
src/Infrastructure/Http/Controller/Api/ArticleTypeController.php
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Http\Controller\Api;
|
||||
|
||||
use App\Application\Article\ArticleTypeService;
|
||||
use App\Domain\Article\AttributeType;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[Route('/article-types', name: 'api_article_types_')]
|
||||
final class ArticleTypeController extends AbstractController
|
||||
{
|
||||
public function __construct(private readonly ArticleTypeService $service)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('', name: 'list', methods: ['GET'])]
|
||||
public function list(): JsonResponse
|
||||
{
|
||||
$types = $this->service->findAll();
|
||||
|
||||
return $this->json(array_map(static fn ($t) => [
|
||||
'id' => $t->getId()->toRfc4122(),
|
||||
'name' => $t->getName(),
|
||||
'attributes' => array_map(static fn ($a) => [
|
||||
'id' => $a->getId()->toRfc4122(),
|
||||
'name' => $a->getName(),
|
||||
'type' => $a->getType()->value,
|
||||
'unit' => $a->getUnit(),
|
||||
'options' => $a->getOptions(),
|
||||
], $t->getAttributeDefinitions()->toArray()),
|
||||
], $types));
|
||||
}
|
||||
|
||||
#[Route('', name: 'create', methods: ['POST'])]
|
||||
public function create(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->toArray();
|
||||
if (empty($data['name'])) {
|
||||
return $this->json(['error' => 'name is required'], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$type = $this->service->create($data['name']);
|
||||
|
||||
return $this->json(['id' => $type->getId()->toRfc4122(), 'name' => $type->getName()], Response::HTTP_CREATED);
|
||||
}
|
||||
|
||||
#[Route('/{id}', name: 'get', methods: ['GET'])]
|
||||
public function get(string $id): JsonResponse
|
||||
{
|
||||
$type = $this->service->findById(Uuid::fromString($id));
|
||||
if (null === $type) {
|
||||
return $this->json(['error' => 'Not found'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->json([
|
||||
'id' => $type->getId()->toRfc4122(),
|
||||
'name' => $type->getName(),
|
||||
'attributes' => array_map(static fn ($a) => [
|
||||
'id' => $a->getId()->toRfc4122(),
|
||||
'name' => $a->getName(),
|
||||
'type' => $a->getType()->value,
|
||||
'unit' => $a->getUnit(),
|
||||
'options' => $a->getOptions(),
|
||||
], $type->getAttributeDefinitions()->toArray()),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/{id}', name: 'rename', methods: ['PATCH'])]
|
||||
public function rename(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->toArray();
|
||||
if (empty($data['name'])) {
|
||||
return $this->json(['error' => 'name is required'], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$type = $this->service->rename(Uuid::fromString($id), $data['name']);
|
||||
} catch (\DomainException $e) {
|
||||
return $this->json(['error' => $e->getMessage()], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->json(['id' => $type->getId()->toRfc4122(), 'name' => $type->getName()]);
|
||||
}
|
||||
|
||||
#[Route('/{id}/attributes', name: 'add_attribute', methods: ['POST'])]
|
||||
public function addAttribute(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->toArray();
|
||||
if (empty($data['name']) || empty($data['type'])) {
|
||||
return $this->json(['error' => 'name and type are required'], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$attrType = AttributeType::tryFrom($data['type']);
|
||||
if (null === $attrType) {
|
||||
$valid = implode(', ', array_column(AttributeType::cases(), 'value'));
|
||||
|
||||
return $this->json(['error' => "type must be one of: {$valid}"], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$def = $this->service->createAttribute($data['name'], $attrType, $data['unit'] ?? null, $data['options'] ?? null);
|
||||
$type = $this->service->addAttribute(Uuid::fromString($id), $def);
|
||||
} catch (\DomainException $e) {
|
||||
return $this->json(['error' => $e->getMessage()], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->json([
|
||||
'id' => $type->getId()->toRfc4122(),
|
||||
'attributes' => array_map(static fn ($a) => [
|
||||
'id' => $a->getId()->toRfc4122(),
|
||||
'name' => $a->getName(),
|
||||
'type' => $a->getType()->value,
|
||||
], $type->getAttributeDefinitions()->toArray()),
|
||||
], Response::HTTP_CREATED);
|
||||
}
|
||||
|
||||
#[Route('/{id}/attributes/{attrId}', name: 'remove_attribute', methods: ['DELETE'])]
|
||||
public function removeAttribute(string $id, string $attrId): JsonResponse
|
||||
{
|
||||
try {
|
||||
$this->service->removeAttribute(Uuid::fromString($id), Uuid::fromString($attrId));
|
||||
} catch (\DomainException $e) {
|
||||
return $this->json(['error' => $e->getMessage()], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->json(null, Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
}
|
||||
90
src/Infrastructure/Http/Controller/Api/MappingController.php
Normal file
90
src/Infrastructure/Http/Controller/Api/MappingController.php
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Http\Controller\Api;
|
||||
|
||||
use App\Application\Channel\MappingService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[Route('/article-types/{typeId}/platform-configs', name: 'api_mapping_')]
|
||||
final class MappingController extends AbstractController
|
||||
{
|
||||
public function __construct(private readonly MappingService $service)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('', name: 'list', methods: ['GET'])]
|
||||
public function list(string $typeId): JsonResponse
|
||||
{
|
||||
$configs = $this->service->findConfigsByArticleType(Uuid::fromString($typeId));
|
||||
|
||||
return $this->json(array_map(static fn ($c) => [
|
||||
'id' => $c->getId()->toRfc4122(),
|
||||
'platform' => ['id' => $c->getPlatform()->getId()->toRfc4122(), 'label' => $c->getPlatform()->getLabel()],
|
||||
'categoryId' => $c->getCategoryId(),
|
||||
'mappings' => array_map(static fn ($m) => [
|
||||
'id' => $m->getId()->toRfc4122(),
|
||||
'attribute' => ['id' => $m->getAttributeDefinition()->getId()->toRfc4122(), 'name' => $m->getAttributeDefinition()->getName()],
|
||||
'channelField' => ['id' => $m->getChannelField()->getId()->toRfc4122(), 'path' => $m->getChannelField()->getPath()],
|
||||
'transformer' => $m->getTransformer(),
|
||||
], $c->getAttributeMappings()->toArray()),
|
||||
], $configs));
|
||||
}
|
||||
|
||||
#[Route('', name: 'create_config', methods: ['POST'])]
|
||||
public function createConfig(string $typeId, Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->toArray();
|
||||
if (empty($data['platformId']) || empty($data['categoryId'])) {
|
||||
return $this->json(['error' => 'platformId and categoryId are required'], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$config = $this->service->createConfig(Uuid::fromString($typeId), Uuid::fromString($data['platformId']), $data['categoryId']);
|
||||
} catch (\DomainException $e) {
|
||||
return $this->json(['error' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
return $this->json(['id' => $config->getId()->toRfc4122()], Response::HTTP_CREATED);
|
||||
}
|
||||
|
||||
#[Route('/{configId}/mappings', name: 'add_mapping', methods: ['POST'])]
|
||||
public function addMapping(string $typeId, string $configId, Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->toArray();
|
||||
if (empty($data['attributeDefinitionId']) || empty($data['channelFieldId'])) {
|
||||
return $this->json(['error' => 'attributeDefinitionId and channelFieldId are required'], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$mapping = $this->service->addMapping(
|
||||
Uuid::fromString($configId),
|
||||
Uuid::fromString($data['attributeDefinitionId']),
|
||||
Uuid::fromString($data['channelFieldId']),
|
||||
$data['transformer'] ?? null,
|
||||
);
|
||||
} catch (\DomainException $e) {
|
||||
return $this->json(['error' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
return $this->json(['id' => $mapping->getId()->toRfc4122()], Response::HTTP_CREATED);
|
||||
}
|
||||
|
||||
#[Route('/mappings/{mappingId}', name: 'remove_mapping', methods: ['DELETE'])]
|
||||
public function removeMapping(string $typeId, string $mappingId): JsonResponse
|
||||
{
|
||||
try {
|
||||
$this->service->removeMapping(Uuid::fromString($mappingId));
|
||||
} catch (\DomainException $e) {
|
||||
return $this->json(['error' => $e->getMessage()], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->json(null, Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
}
|
||||
76
src/Infrastructure/Http/Controller/Api/PhotoController.php
Normal file
76
src/Infrastructure/Http/Controller/Api/PhotoController.php
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Http\Controller\Api;
|
||||
|
||||
use App\Application\Article\PhotoService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[Route('/articles/{articleId}/photos', name: 'api_photos_')]
|
||||
final class PhotoController extends AbstractController
|
||||
{
|
||||
public function __construct(private readonly PhotoService $service)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('', name: 'upload', methods: ['POST'])]
|
||||
public function upload(string $articleId, Request $request): JsonResponse
|
||||
{
|
||||
$file = $request->files->get('photo');
|
||||
if (!$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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Http\Controller\Api;
|
||||
|
||||
use App\Application\Channel\PlatformService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[Route('/platforms', name: 'api_platforms_')]
|
||||
final class PlatformController extends AbstractController
|
||||
{
|
||||
public function __construct(private readonly PlatformService $service)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('', name: 'list', methods: ['GET'])]
|
||||
public function list(): JsonResponse
|
||||
{
|
||||
return $this->json(array_map(static fn ($p) => [
|
||||
'id' => $p->getId()->toRfc4122(),
|
||||
'type' => $p->getType(),
|
||||
'label' => $p->getLabel(),
|
||||
], $this->service->findAll()));
|
||||
}
|
||||
|
||||
#[Route('', name: 'create', methods: ['POST'])]
|
||||
public function create(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->toArray();
|
||||
if (empty($data['type']) || empty($data['label'])) {
|
||||
return $this->json(['error' => 'type and label are required'], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$platform = $this->service->create($data['type'], $data['label'], $data['config'] ?? []);
|
||||
|
||||
return $this->json(['id' => $platform->getId()->toRfc4122(), 'type' => $platform->getType()], Response::HTTP_CREATED);
|
||||
}
|
||||
|
||||
#[Route('/{id}/channel-fields', name: 'list_fields', methods: ['GET'])]
|
||||
public function listFields(string $id): JsonResponse
|
||||
{
|
||||
$fields = $this->service->findChannelFields(Uuid::fromString($id));
|
||||
|
||||
return $this->json(array_map(static fn ($f) => [
|
||||
'id' => $f->getId()->toRfc4122(),
|
||||
'label' => $f->getLabel(),
|
||||
'path' => $f->getPath(),
|
||||
], $fields));
|
||||
}
|
||||
|
||||
#[Route('/{id}/channel-fields', name: 'add_field', methods: ['POST'])]
|
||||
public function addField(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->toArray();
|
||||
if (empty($data['label']) || empty($data['path'])) {
|
||||
return $this->json(['error' => 'label and path are required'], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$field = $this->service->addChannelField(Uuid::fromString($id), $data['label'], $data['path']);
|
||||
} catch (\DomainException $e) {
|
||||
return $this->json(['error' => $e->getMessage()], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->json(['id' => $field->getId()->toRfc4122(), 'label' => $field->getLabel()], Response::HTTP_CREATED);
|
||||
}
|
||||
|
||||
#[Route('/channel-fields/{fieldId}', name: 'remove_field', methods: ['DELETE'])]
|
||||
public function removeField(string $fieldId): JsonResponse
|
||||
{
|
||||
try {
|
||||
$this->service->removeChannelField(Uuid::fromString($fieldId));
|
||||
} catch (\DomainException $e) {
|
||||
return $this->json(['error' => $e->getMessage()], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->json(null, Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Persistence\Repository;
|
||||
|
||||
use App\Domain\Article\ArticlePhoto;
|
||||
use App\Domain\Article\Repository\ArticlePhotoRepositoryInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
final class DoctrineArticlePhotoRepository implements ArticlePhotoRepositoryInterface
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $em)
|
||||
{
|
||||
}
|
||||
|
||||
public function findById(Uuid $id): ?ArticlePhoto
|
||||
{
|
||||
return $this->em->find(ArticlePhoto::class, $id);
|
||||
}
|
||||
|
||||
/** @return list<ArticlePhoto> */
|
||||
public function findByArticle(Uuid $articleId): array
|
||||
{
|
||||
/** @var list<ArticlePhoto> */
|
||||
return $this->em->getRepository(ArticlePhoto::class)
|
||||
->createQueryBuilder('p')
|
||||
->where('IDENTITY(p.article) = :articleId')
|
||||
->setParameter('articleId', $articleId->toRfc4122())
|
||||
->orderBy('p.sortOrder', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function save(ArticlePhoto $photo): void
|
||||
{
|
||||
$this->em->persist($photo);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
public function remove(ArticlePhoto $photo): void
|
||||
{
|
||||
$this->em->remove($photo);
|
||||
$this->em->flush();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Persistence\Repository;
|
||||
|
||||
use App\Domain\Channel\ArticleTypePlatformConfig;
|
||||
use App\Domain\Channel\Repository\ArticleTypePlatformConfigRepositoryInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
final class DoctrineArticleTypePlatformConfigRepository implements ArticleTypePlatformConfigRepositoryInterface
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $em)
|
||||
{
|
||||
}
|
||||
|
||||
public function findById(Uuid $id): ?ArticleTypePlatformConfig
|
||||
{
|
||||
return $this->em->find(ArticleTypePlatformConfig::class, $id);
|
||||
}
|
||||
|
||||
public function findByArticleTypeAndPlatform(Uuid $articleTypeId, Uuid $platformId): ?ArticleTypePlatformConfig
|
||||
{
|
||||
/** @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<ArticleTypePlatformConfig> */
|
||||
public function findByArticleType(Uuid $articleTypeId): array
|
||||
{
|
||||
/** @var list<ArticleTypePlatformConfig> */
|
||||
return $this->em->getRepository(ArticleTypePlatformConfig::class)
|
||||
->createQueryBuilder('c')
|
||||
->where('IDENTITY(c.articleType) = :typeId')
|
||||
->setParameter('typeId', $articleTypeId->toRfc4122())
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function save(ArticleTypePlatformConfig $config): void
|
||||
{
|
||||
$this->em->persist($config);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
public function remove(ArticleTypePlatformConfig $config): void
|
||||
{
|
||||
$this->em->remove($config);
|
||||
$this->em->flush();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Persistence\Repository;
|
||||
|
||||
use App\Domain\Channel\AttributeMapping;
|
||||
use App\Domain\Channel\Repository\AttributeMappingRepositoryInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
final class DoctrineAttributeMappingRepository implements AttributeMappingRepositoryInterface
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $em)
|
||||
{
|
||||
}
|
||||
|
||||
public function findById(Uuid $id): ?AttributeMapping
|
||||
{
|
||||
return $this->em->find(AttributeMapping::class, $id);
|
||||
}
|
||||
|
||||
/** @return list<AttributeMapping> */
|
||||
public function findByPlatformConfig(Uuid $platformConfigId): array
|
||||
{
|
||||
/** @var list<AttributeMapping> */
|
||||
return $this->em->getRepository(AttributeMapping::class)
|
||||
->createQueryBuilder('m')
|
||||
->where('IDENTITY(m.platformConfig) = :configId')
|
||||
->setParameter('configId', $platformConfigId->toRfc4122())
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function save(AttributeMapping $mapping): void
|
||||
{
|
||||
$this->em->persist($mapping);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
public function remove(AttributeMapping $mapping): void
|
||||
{
|
||||
$this->em->remove($mapping);
|
||||
$this->em->flush();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Persistence\Repository;
|
||||
|
||||
use App\Domain\Article\AttributeValue;
|
||||
use App\Domain\Article\Repository\AttributeValueRepositoryInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
final class DoctrineAttributeValueRepository implements AttributeValueRepositoryInterface
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $em)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return list<AttributeValue> */
|
||||
public function findByArticle(Uuid $articleId): array
|
||||
{
|
||||
/** @var list<AttributeValue> */
|
||||
return $this->em->getRepository(AttributeValue::class)
|
||||
->createQueryBuilder('v')
|
||||
->where('IDENTITY(v.article) = :articleId')
|
||||
->setParameter('articleId', $articleId->toRfc4122())
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function findByArticleAndDefinition(Uuid $articleId, Uuid $definitionId): ?AttributeValue
|
||||
{
|
||||
/** @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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Persistence\Repository;
|
||||
|
||||
use App\Domain\Channel\ChannelField;
|
||||
use App\Domain\Channel\Repository\ChannelFieldRepositoryInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
final class DoctrineChannelFieldRepository implements ChannelFieldRepositoryInterface
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $em)
|
||||
{
|
||||
}
|
||||
|
||||
public function findById(Uuid $id): ?ChannelField
|
||||
{
|
||||
return $this->em->find(ChannelField::class, $id);
|
||||
}
|
||||
|
||||
/** @return list<ChannelField> */
|
||||
public function findByPlatform(Uuid $platformId): array
|
||||
{
|
||||
/** @var list<ChannelField> */
|
||||
return $this->em->getRepository(ChannelField::class)
|
||||
->createQueryBuilder('f')
|
||||
->where('IDENTITY(f.platform) = :platformId')
|
||||
->setParameter('platformId', $platformId->toRfc4122())
|
||||
->orderBy('f.label', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function save(ChannelField $field): void
|
||||
{
|
||||
$this->em->persist($field);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
public function remove(ChannelField $field): void
|
||||
{
|
||||
$this->em->remove($field);
|
||||
$this->em->flush();
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Persistence\Repository;
|
||||
|
||||
use App\Domain\Storage\Repository\StoragePathRepositoryInterface;
|
||||
use App\Domain\Storage\StoragePath;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
final class DoctrineStoragePathRepository implements StoragePathRepositoryInterface
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $em)
|
||||
{
|
||||
}
|
||||
|
||||
public function findById(Uuid $id): ?StoragePath
|
||||
{
|
||||
return $this->em->find(StoragePath::class, $id);
|
||||
}
|
||||
|
||||
/** @return list<StoragePath> */
|
||||
public function findActiveSortedByPriority(): array
|
||||
{
|
||||
/** @var list<StoragePath> */
|
||||
return $this->em->getRepository(StoragePath::class)
|
||||
->createQueryBuilder('s')
|
||||
->where('s.isActive = :active')
|
||||
->setParameter('active', true)
|
||||
->orderBy('s.priority', 'DESC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function save(StoragePath $storagePath): void
|
||||
{
|
||||
$this->em->persist($storagePath);
|
||||
$this->em->flush();
|
||||
}
|
||||
}
|
||||
76
src/Infrastructure/Storage/LocalStorageManager.php
Normal file
76
src/Infrastructure/Storage/LocalStorageManager.php
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Storage;
|
||||
|
||||
use App\Application\Storage\StorageManagerInterface;
|
||||
use App\Application\Storage\StoredFile;
|
||||
use App\Domain\Storage\Repository\StoragePathRepositoryInterface;
|
||||
use App\Domain\Storage\StoragePath;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
final class LocalStorageManager implements StorageManagerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly StoragePathRepositoryInterface $storagePathRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
public function store(string $tempPath, string $originalFilename): StoredFile
|
||||
{
|
||||
$fileSize = filesize($tempPath);
|
||||
if (false === $fileSize) {
|
||||
throw new \RuntimeException("Cannot read file size for: {$tempPath}");
|
||||
}
|
||||
|
||||
$paths = $this->storagePathRepository->findActiveSortedByPriority();
|
||||
|
||||
foreach ($paths as $storagePath) {
|
||||
$used = $this->getUsedBytes($storagePath->getBasePath());
|
||||
$available = $storagePath->getQuotaBytes() - $used;
|
||||
|
||||
if ($available < $fileSize) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ext = pathinfo($originalFilename, \PATHINFO_EXTENSION);
|
||||
$filename = Uuid::v7()->toRfc4122().('' !== $ext ? '.'.$ext : '');
|
||||
$destination = $storagePath->getBasePath().'/'.$filename;
|
||||
|
||||
if (!is_dir($storagePath->getBasePath())) {
|
||||
mkdir($storagePath->getBasePath(), 0755, true);
|
||||
}
|
||||
|
||||
rename($tempPath, $destination);
|
||||
|
||||
return new StoredFile($storagePath, $filename);
|
||||
}
|
||||
|
||||
throw new \RuntimeException('No active storage path with sufficient quota available');
|
||||
}
|
||||
|
||||
public function getFullPath(StoragePath $storagePath, string $filename): string
|
||||
{
|
||||
return $storagePath->getBasePath().'/'.$filename;
|
||||
}
|
||||
|
||||
private function getUsedBytes(string $directory): int
|
||||
{
|
||||
if (!is_dir($directory)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$total = 0;
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS),
|
||||
);
|
||||
foreach ($iterator as $file) {
|
||||
if ($file instanceof \SplFileInfo) {
|
||||
$total += $file->getSize();
|
||||
}
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
}
|
||||
66
tests/Unit/Application/Article/ArticleTypeServiceTest.php
Normal file
66
tests/Unit/Application/Article/ArticleTypeServiceTest.php
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Application\Article;
|
||||
|
||||
use App\Application\Article\ArticleTypeService;
|
||||
use App\Domain\Article\ArticleType;
|
||||
use App\Domain\Article\AttributeDefinition;
|
||||
use App\Domain\Article\AttributeType;
|
||||
use App\Domain\Article\Repository\ArticleTypeRepositoryInterface;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ArticleTypeServiceTest extends TestCase
|
||||
{
|
||||
private ArticleTypeRepositoryInterface&MockObject $repo;
|
||||
private ArticleTypeService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repo = $this->createMock(ArticleTypeRepositoryInterface::class);
|
||||
$this->service = new ArticleTypeService($this->repo);
|
||||
}
|
||||
|
||||
public function test_create_saves_article_type(): void
|
||||
{
|
||||
$this->repo->expects($this->once())->method('save');
|
||||
|
||||
$type = $this->service->create('Notebook');
|
||||
|
||||
$this->assertSame('Notebook', $type->getName());
|
||||
}
|
||||
|
||||
public function test_rename_updates_name(): void
|
||||
{
|
||||
$type = new ArticleType('Notebook');
|
||||
$this->repo->method('findById')->willReturn($type);
|
||||
$this->repo->expects($this->once())->method('save');
|
||||
|
||||
$this->service->rename($type->getId(), 'Laptop');
|
||||
|
||||
$this->assertSame('Laptop', $type->getName());
|
||||
}
|
||||
|
||||
public function test_rename_throws_when_not_found(): void
|
||||
{
|
||||
$this->repo->method('findById')->willReturn(null);
|
||||
|
||||
$this->expectException(\DomainException::class);
|
||||
|
||||
$this->service->rename(\Symfony\Component\Uid\Uuid::v7(), 'X');
|
||||
}
|
||||
|
||||
public function test_add_attribute_links_definition(): void
|
||||
{
|
||||
$type = new ArticleType('Notebook');
|
||||
$def = new AttributeDefinition('RAM', AttributeType::String);
|
||||
$this->repo->method('findById')->willReturn($type);
|
||||
$this->repo->expects($this->once())->method('save');
|
||||
|
||||
$this->service->addAttribute($type->getId(), $def);
|
||||
|
||||
$this->assertCount(1, $type->getAttributeDefinitions());
|
||||
}
|
||||
}
|
||||
66
tests/Unit/Application/Article/ArticleValidatorTest.php
Normal file
66
tests/Unit/Application/Article/ArticleValidatorTest.php
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Application\Article;
|
||||
|
||||
use App\Application\Article\ArticleValidator;
|
||||
use App\Domain\Article\Article;
|
||||
use App\Domain\Article\ArticleCondition;
|
||||
use App\Domain\Article\ArticleType;
|
||||
use App\Domain\Article\AttributeDefinition;
|
||||
use App\Domain\Article\AttributeType;
|
||||
use App\Domain\Article\AttributeValue;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ArticleValidatorTest extends TestCase
|
||||
{
|
||||
private ArticleValidator $validator;
|
||||
private ArticleType $type;
|
||||
private AttributeDefinition $ramDef;
|
||||
private AttributeDefinition $cpuDef;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->validator = new ArticleValidator();
|
||||
$this->type = new ArticleType('Notebook');
|
||||
$this->ramDef = new AttributeDefinition('RAM', AttributeType::String);
|
||||
$this->cpuDef = new AttributeDefinition('CPU', AttributeType::String);
|
||||
$this->type->addAttributeDefinition($this->ramDef);
|
||||
$this->type->addAttributeDefinition($this->cpuDef);
|
||||
}
|
||||
|
||||
public function test_valid_when_all_attributes_set(): void
|
||||
{
|
||||
$article = new Article($this->type, 'NB-001', 'INV-001', 1, ArticleCondition::Good);
|
||||
$article->setAttributeValue(new AttributeValue($article, $this->ramDef, '16 GB'));
|
||||
$article->setAttributeValue(new AttributeValue($article, $this->cpuDef, 'Intel i7'));
|
||||
|
||||
$missing = $this->validator->getMissingAttributes($article);
|
||||
|
||||
$this->assertEmpty($missing);
|
||||
$this->assertTrue($this->validator->isValid($article));
|
||||
}
|
||||
|
||||
public function test_returns_missing_attribute_names(): void
|
||||
{
|
||||
$article = new Article($this->type, 'NB-001', 'INV-001', 1, ArticleCondition::Good);
|
||||
$article->setAttributeValue(new AttributeValue($article, $this->ramDef, '16 GB'));
|
||||
// cpuDef not set
|
||||
|
||||
$missing = $this->validator->getMissingAttributes($article);
|
||||
|
||||
$this->assertCount(1, $missing);
|
||||
$this->assertContains('CPU', $missing);
|
||||
$this->assertFalse($this->validator->isValid($article));
|
||||
}
|
||||
|
||||
public function test_all_missing_when_no_values_set(): void
|
||||
{
|
||||
$article = new Article($this->type, 'NB-001', 'INV-001', 1, ArticleCondition::Good);
|
||||
|
||||
$missing = $this->validator->getMissingAttributes($article);
|
||||
|
||||
$this->assertCount(2, $missing);
|
||||
}
|
||||
}
|
||||
88
tests/Unit/Application/Storage/LocalStorageManagerTest.php
Normal file
88
tests/Unit/Application/Storage/LocalStorageManagerTest.php
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Application\Storage;
|
||||
|
||||
use App\Domain\Storage\Repository\StoragePathRepositoryInterface;
|
||||
use App\Domain\Storage\StoragePath;
|
||||
use App\Infrastructure\Storage\LocalStorageManager;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class LocalStorageManagerTest extends TestCase
|
||||
{
|
||||
private StoragePathRepositoryInterface&MockObject $repo;
|
||||
private LocalStorageManager $manager;
|
||||
private string $tmpFile;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repo = $this->createMock(StoragePathRepositoryInterface::class);
|
||||
$this->manager = new LocalStorageManager($this->repo);
|
||||
$this->tmpFile = sys_get_temp_dir().'/test-upload-'.uniqid().'.jpg';
|
||||
file_put_contents($this->tmpFile, str_repeat('x', 100));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (file_exists($this->tmpFile)) {
|
||||
unlink($this->tmpFile);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_store_picks_active_path_with_quota(): void
|
||||
{
|
||||
$path = new StoragePath('Main', sys_get_temp_dir().'/storage-test-'.uniqid(), 1_000_000, 10);
|
||||
mkdir($path->getBasePath(), recursive: true);
|
||||
|
||||
$this->repo->expects($this->once())
|
||||
->method('findActiveSortedByPriority')
|
||||
->willReturn([$path]);
|
||||
|
||||
$stored = $this->manager->store($this->tmpFile, 'photo.jpg');
|
||||
|
||||
$this->assertSame($path->getId()->toRfc4122(), $stored->storagePath->getId()->toRfc4122());
|
||||
$this->assertStringEndsWith('.jpg', $stored->filename);
|
||||
$this->assertFileExists($path->getBasePath().'/'.$stored->filename);
|
||||
|
||||
// cleanup
|
||||
unlink($path->getBasePath().'/'.$stored->filename);
|
||||
rmdir($path->getBasePath());
|
||||
}
|
||||
|
||||
public function test_throws_when_no_active_path(): void
|
||||
{
|
||||
$this->repo->method('findActiveSortedByPriority')->willReturn([]);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('No active storage path');
|
||||
|
||||
$this->manager->store($this->tmpFile, 'photo.jpg');
|
||||
}
|
||||
|
||||
public function test_skips_full_path_and_uses_next(): void
|
||||
{
|
||||
$fullPath = new StoragePath('Full', sys_get_temp_dir().'/full-'.uniqid(), 50, 20);
|
||||
$okPath = new StoragePath('OK', sys_get_temp_dir().'/ok-'.uniqid(), 1_000_000, 10);
|
||||
mkdir($fullPath->getBasePath(), recursive: true);
|
||||
mkdir($okPath->getBasePath(), recursive: true);
|
||||
|
||||
// fullPath quota (50 bytes) is less than file size (100 bytes) — skip it
|
||||
$this->repo->method('findActiveSortedByPriority')->willReturn([$fullPath, $okPath]);
|
||||
|
||||
$stored = $this->manager->store($this->tmpFile, 'photo.jpg');
|
||||
|
||||
$this->assertSame($okPath->getId()->toRfc4122(), $stored->storagePath->getId()->toRfc4122());
|
||||
|
||||
unlink($okPath->getBasePath().'/'.$stored->filename);
|
||||
rmdir($fullPath->getBasePath());
|
||||
rmdir($okPath->getBasePath());
|
||||
}
|
||||
|
||||
public function test_get_full_path(): void
|
||||
{
|
||||
$path = new StoragePath('Main', '/srv/storage', 1_000_000, 10);
|
||||
$this->assertSame('/srv/storage/photo.jpg', $this->manager->getFullPath($path, 'photo.jpg'));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue