feat: add Article domain cluster (ArticleType, AttributeDefinition, Article, AttributeValue, ArticlePhoto, StoragePath)
This commit is contained in:
parent
6e8a2e070f
commit
3cc8f57f11
7 changed files with 494 additions and 0 deletions
142
src/Domain/Article/Article.php
Normal file
142
src/Domain/Article/Article.php
Normal 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; }
|
||||
}
|
||||
62
src/Domain/Article/ArticlePhoto.php
Normal file
62
src/Domain/Article/ArticlePhoto.php
Normal 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);
|
||||
}
|
||||
}
|
||||
53
src/Domain/Article/ArticleType.php
Normal file
53
src/Domain/Article/ArticleType.php
Normal 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);
|
||||
}
|
||||
}
|
||||
50
src/Domain/Article/AttributeDefinition.php
Normal file
50
src/Domain/Article/AttributeDefinition.php
Normal 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; }
|
||||
}
|
||||
56
src/Domain/Article/AttributeValue.php
Normal file
56
src/Domain/Article/AttributeValue.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
61
src/Domain/Storage/StoragePath.php
Normal file
61
src/Domain/Storage/StoragePath.php
Normal 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;
|
||||
}
|
||||
}
|
||||
70
tests/Unit/Domain/Article/ArticleTest.php
Normal file
70
tests/Unit/Domain/Article/ArticleTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue