feat: add Article domain cluster (ArticleType, AttributeDefinition, Article, AttributeValue, ArticlePhoto, StoragePath)

This commit is contained in:
Simon Kuehn 2026-05-14 04:28:06 +00:00
parent 6e8a2e070f
commit 3cc8f57f11
7 changed files with 494 additions and 0 deletions

View file

@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Domain\Article;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity]
#[ORM\Table(name: 'articles', schema: 'app')]
class Article
{
#[ORM\Id]
#[ORM\Column(type: 'uuid')]
private Uuid $id;
#[ORM\ManyToOne(targetEntity: ArticleType::class)]
#[ORM\JoinColumn(nullable: false)]
private ArticleType $articleType;
#[ORM\Column(type: 'string', length: 255, unique: true)]
private string $sku;
#[ORM\Column(type: 'string', length: 100, unique: true)]
private string $inventoryNumber;
#[ORM\Column(type: 'string', enumType: ArticleStatus::class)]
private ArticleStatus $status;
#[ORM\Column(type: 'integer')]
private int $stock;
#[ORM\Column(type: 'string', enumType: ArticleCondition::class)]
private ArticleCondition $condition;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $conditionNotes = null;
#[ORM\Column(type: 'decimal', precision: 10, scale: 2, nullable: true)]
private ?string $listingPrice = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $serialNumber = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $ebayListingId = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $ebayTitle = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $ebayDescription = null;
/** @var Collection<int, AttributeValue> */
#[ORM\OneToMany(mappedBy: 'article', targetEntity: AttributeValue::class, cascade: ['persist', 'remove'])]
private Collection $attributeValues;
/** @var Collection<int, ArticlePhoto> */
#[ORM\OneToMany(mappedBy: 'article', targetEntity: ArticlePhoto::class, cascade: ['persist', 'remove'], orderBy: ['sortOrder' => 'ASC'])]
private Collection $photos;
public function __construct(
ArticleType $articleType,
string $sku,
string $inventoryNumber,
int $stock,
ArticleCondition $condition,
) {
$this->id = Uuid::v7();
$this->articleType = $articleType;
$this->sku = $sku;
$this->inventoryNumber = $inventoryNumber;
$this->status = ArticleStatus::Ingesting;
$this->stock = $stock;
$this->condition = $condition;
$this->attributeValues = new ArrayCollection();
$this->photos = new ArrayCollection();
}
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,
));
}
$this->status = $newStatus;
}
public function decrementStock(): void
{
if ($this->stock <= 0) {
throw new \DomainException('Stock cannot go below zero');
}
--$this->stock;
}
public function isOutOfStock(): bool { return 0 === $this->stock; }
public function getMainPhoto(): ?ArticlePhoto
{
foreach ($this->photos as $photo) {
if ($photo->isMain()) {
return $photo;
}
}
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; }
/** @return Collection<int, AttributeValue> */
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; }
}

View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Domain\Article;
use App\Domain\Storage\StoragePath;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity]
#[ORM\Table(name: 'article_photos', schema: 'app')]
class ArticlePhoto
{
#[ORM\Id]
#[ORM\Column(type: 'uuid')]
private Uuid $id;
#[ORM\ManyToOne(targetEntity: Article::class, inversedBy: 'photos')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private Article $article;
#[ORM\ManyToOne(targetEntity: StoragePath::class)]
#[ORM\JoinColumn(nullable: false)]
private StoragePath $storagePath;
#[ORM\Column(type: 'string', length: 500)]
private string $filename;
#[ORM\Column(type: 'boolean')]
private bool $isMain;
#[ORM\Column(type: 'integer')]
private int $sortOrder;
public function __construct(
Article $article,
StoragePath $storagePath,
string $filename,
bool $isMain,
int $sortOrder,
) {
$this->id = Uuid::v7();
$this->article = $article;
$this->storagePath = $storagePath;
$this->filename = $filename;
$this->isMain = $isMain;
$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 getFullPath(): string
{
return $this->storagePath->resolveFilePath($this->filename);
}
}

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Domain\Article;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity]
#[ORM\Table(name: 'article_types', schema: 'app')]
class ArticleType
{
#[ORM\Id]
#[ORM\Column(type: 'uuid')]
private Uuid $id;
#[ORM\Column(type: 'string', length: 255, unique: true)]
private string $name;
/** @var Collection<int, AttributeDefinition> */
#[ORM\ManyToMany(targetEntity: AttributeDefinition::class)]
#[ORM\JoinTable(name: 'article_type_attributes', schema: 'app')]
private Collection $attributeDefinitions;
public function __construct(string $name)
{
$this->id = Uuid::v7();
$this->name = $name;
$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; }
/** @return Collection<int, AttributeDefinition> */
public function getAttributeDefinitions(): Collection { return $this->attributeDefinitions; }
public function addAttributeDefinition(AttributeDefinition $def): void
{
if (!$this->attributeDefinitions->contains($def)) {
$this->attributeDefinitions->add($def);
}
}
public function removeAttributeDefinition(AttributeDefinition $def): void
{
$this->attributeDefinitions->removeElement($def);
}
}

View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Domain\Article;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity]
#[ORM\Table(name: 'attribute_definitions', schema: 'app')]
class AttributeDefinition
{
#[ORM\Id]
#[ORM\Column(type: 'uuid')]
private Uuid $id;
#[ORM\Column(type: 'string', length: 255)]
private string $name;
#[ORM\Column(type: 'string', enumType: AttributeType::class)]
private AttributeType $type;
#[ORM\Column(type: 'string', length: 50, nullable: true)]
private ?string $unit = null;
/** @var list<string>|null */
#[ORM\Column(type: 'json', nullable: true)]
private ?array $options = null;
public function __construct(string $name, AttributeType $type)
{
$this->id = Uuid::v7();
$this->name = $name;
$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; }
/** @return list<string>|null */
public function getOptions(): ?array { return $this->options; }
/** @param list<string>|null $options */
public function setOptions(?array $options): void { $this->options = $options; }
}

View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Domain\Article;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity]
#[ORM\Table(name: 'attribute_values', schema: 'app')]
#[ORM\UniqueConstraint(columns: ['article_id', 'attribute_definition_id'])]
class AttributeValue
{
#[ORM\Id]
#[ORM\Column(type: 'uuid')]
private Uuid $id;
#[ORM\ManyToOne(targetEntity: Article::class, inversedBy: 'attributeValues')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private Article $article;
#[ORM\ManyToOne(targetEntity: AttributeDefinition::class)]
#[ORM\JoinColumn(nullable: false)]
private AttributeDefinition $attributeDefinition;
#[ORM\Column(type: 'text')]
private string $value;
public function __construct(
Article $article,
AttributeDefinition $attributeDefinition,
string $value,
) {
$this->id = Uuid::v7();
$this->article = $article;
$this->attributeDefinition = $attributeDefinition;
$this->value = $value;
}
public function getId(): Uuid { return $this->id; }
public function 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
{
return match ($this->attributeDefinition->getType()) {
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),
default => $this->value,
};
}
}

View file

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Domain\Storage;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity]
#[ORM\Table(name: 'storage_paths', schema: 'app')]
class StoragePath
{
#[ORM\Id]
#[ORM\Column(type: 'uuid')]
private Uuid $id;
#[ORM\Column(type: 'string', length: 255)]
private string $label;
#[ORM\Column(type: 'string', length: 500)]
private string $basePath;
#[ORM\Column(type: 'bigint')]
private int $quotaBytes;
#[ORM\Column(type: 'integer')]
private int $priority;
#[ORM\Column(type: 'boolean')]
private bool $isActive;
public function __construct(
string $label,
string $basePath,
int $quotaBytes,
int $priority,
bool $isActive = true,
) {
$this->id = Uuid::v7();
$this->label = $label;
$this->basePath = $basePath;
$this->quotaBytes = $quotaBytes;
$this->priority = $priority;
$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;
}
}

View file

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Domain\Article;
use App\Domain\Article\Article;
use App\Domain\Article\ArticleCondition;
use App\Domain\Article\ArticleStatus;
use App\Domain\Article\ArticleType;
use PHPUnit\Framework\TestCase;
final class ArticleTest extends TestCase
{
private ArticleType $type;
protected function setUp(): void
{
$this->type = new ArticleType('Notebook');
}
public function test_new_article_has_ingesting_status(): void
{
$article = new Article($this->type, 'NB-001', 'INV-001', 1, ArticleCondition::Good);
$this->assertSame(ArticleStatus::Ingesting, $article->getStatus());
$this->assertSame(1, $article->getStock());
}
public function test_valid_status_transition(): void
{
$article = new Article($this->type, 'NB-001', 'INV-001', 1, ArticleCondition::Good);
$article->transitionTo(ArticleStatus::Draft);
$this->assertSame(ArticleStatus::Draft, $article->getStatus());
}
public function test_invalid_status_transition_throws(): void
{
$article = new Article($this->type, 'NB-001', 'INV-001', 1, ArticleCondition::Good);
$this->expectException(\DomainException::class);
$article->transitionTo(ArticleStatus::Sold);
}
public function test_decrement_stock(): void
{
$article = new Article($this->type, 'NB-001', 'INV-001', 3, ArticleCondition::Good);
$article->decrementStock();
$this->assertSame(2, $article->getStock());
$this->assertFalse($article->isOutOfStock());
}
public function test_decrement_to_zero_marks_out_of_stock(): void
{
$article = new Article($this->type, 'NB-001', 'INV-001', 1, ArticleCondition::Good);
$article->decrementStock();
$this->assertTrue($article->isOutOfStock());
}
public function test_decrement_below_zero_throws(): void
{
$article = new Article($this->type, 'NB-001', 'INV-001', 0, ArticleCondition::Good);
$this->expectException(\DomainException::class);
$article->decrementStock();
}
}