diff --git a/src/Domain/Article/Article.php b/src/Domain/Article/Article.php new file mode 100644 index 0000000..91243af --- /dev/null +++ b/src/Domain/Article/Article.php @@ -0,0 +1,142 @@ + */ + #[ORM\OneToMany(mappedBy: 'article', targetEntity: AttributeValue::class, cascade: ['persist', 'remove'])] + private Collection $attributeValues; + + /** @var Collection */ + #[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 */ + public function getAttributeValues(): Collection { return $this->attributeValues; } + + /** @return Collection */ + 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; } +} diff --git a/src/Domain/Article/ArticlePhoto.php b/src/Domain/Article/ArticlePhoto.php new file mode 100644 index 0000000..38f814b --- /dev/null +++ b/src/Domain/Article/ArticlePhoto.php @@ -0,0 +1,62 @@ +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); + } +} diff --git a/src/Domain/Article/ArticleType.php b/src/Domain/Article/ArticleType.php new file mode 100644 index 0000000..dbbe57f --- /dev/null +++ b/src/Domain/Article/ArticleType.php @@ -0,0 +1,53 @@ + */ + #[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 */ + 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); + } +} diff --git a/src/Domain/Article/AttributeDefinition.php b/src/Domain/Article/AttributeDefinition.php new file mode 100644 index 0000000..3f74345 --- /dev/null +++ b/src/Domain/Article/AttributeDefinition.php @@ -0,0 +1,50 @@ +|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|null */ + public function getOptions(): ?array { return $this->options; } + + /** @param list|null $options */ + public function setOptions(?array $options): void { $this->options = $options; } +} diff --git a/src/Domain/Article/AttributeValue.php b/src/Domain/Article/AttributeValue.php new file mode 100644 index 0000000..07b6eb5 --- /dev/null +++ b/src/Domain/Article/AttributeValue.php @@ -0,0 +1,56 @@ +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, + }; + } +} diff --git a/src/Domain/Storage/StoragePath.php b/src/Domain/Storage/StoragePath.php new file mode 100644 index 0000000..df082aa --- /dev/null +++ b/src/Domain/Storage/StoragePath.php @@ -0,0 +1,61 @@ +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; + } +} diff --git a/tests/Unit/Domain/Article/ArticleTest.php b/tests/Unit/Domain/Article/ArticleTest.php new file mode 100644 index 0000000..2af6303 --- /dev/null +++ b/tests/Unit/Domain/Article/ArticleTest.php @@ -0,0 +1,70 @@ +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(); + } +}