feat: add support for individual consumable items in service items

- Introduced `consumables` field in `ServiceItem` to allow multiple individual items alongside inventory packages.
- Created `ServiceItemConsumable` entity to manage individual consumable items linked to a service.
- Updated `ServiceItemController` to handle CRUD operations for consumables.
- Enhanced `ServiceDetailPage` and `ServiceItemFormModal` to display and manage consumables.
- Added tests to ensure functionality for adding, updating, and validating consumables.
- Updated API documentation to reflect changes in service item structure and consumables.
This commit is contained in:
hamed
2026-07-18 12:34:42 +03:30
parent c4a661b542
commit c13cc57c48
11 changed files with 530 additions and 40 deletions
+57
View File
@@ -70,6 +70,14 @@ class ServiceItem
#[ORM\Column(name: 'inventory_package_id', type: 'integer', nullable: true)]
private ?int $inventoryPackageId = null;
/**
* اقلام کالای تکیِ این خدمت — مستقل از پکیج و قابل استفاده هم‌زمان با آن.
*
* @var Collection<int, ServiceItemConsumable>
*/
#[ORM\OneToMany(mappedBy: 'serviceItem', targetEntity: ServiceItemConsumable::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $consumables;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
@@ -85,6 +93,51 @@ class ServiceItem
$this->createdAt = time();
$this->updatedAt = time();
$this->staffMembers = new ArrayCollection();
$this->consumables = new ArrayCollection();
}
/** @return Collection<int, ServiceItemConsumable> */
public function getConsumables(): Collection
{
// Doctrine بدون constructor هیدریت می‌کند؛ از property تایپ‌شده محافظت کن.
return $this->consumables ??= new ArrayCollection();
}
/**
* جایگزینی کامل اقلام تکی. کلید تطبیق، خودِ InventoryItem است تا ردیف بدون تغییر
* حذف و دوباره ساخته نشود.
*
* @param array<int, array{item: \App\Inventory\Entity\InventoryItem, amount: int}> $lines
*/
public function replaceConsumables(array $lines): self
{
$existing = [];
foreach ($this->getConsumables() as $consumable) {
$existing[$consumable->getItem()->getId()] = $consumable;
}
$keep = [];
foreach ($lines as $line) {
$itemId = $line['item']->getId();
$keep[] = $itemId;
if (isset($existing[$itemId])) {
$existing[$itemId]->setAmount($line['amount']);
continue;
}
$this->getConsumables()->add((new ServiceItemConsumable($line['item'], $line['amount']))->setServiceItem($this));
}
foreach ($existing as $itemId => $consumable) {
if (!in_array($itemId, $keep, true)) {
$this->getConsumables()->removeElement($consumable);
}
}
$this->updatedAt = time();
return $this;
}
public function getId(): ?int { return $this->id; }
@@ -170,6 +223,10 @@ class ServiceItem
'duration_minutes' => $this->durationMinutes,
'bookable' => $this->bookable,
'inventory_package_id' => $this->inventoryPackageId,
'consumables' => array_map(
fn(ServiceItemConsumable $c) => $c->toArray(),
$this->getConsumables()->toArray()
),
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt,
];
@@ -0,0 +1,60 @@
<?php
namespace App\ClinicService\Entity;
use App\Inventory\Entity\InventoryItem;
use Doctrine\ORM\Mapping as ORM;
/**
* یک قلم کالای مصرفیِ مستقل روی یک خدمت: ارجاع به {@see InventoryItem} به‌همراه تعداد.
*
* مکمل (نه جایگزین) `ServiceItem::$inventoryPackageId` است؛ یک خدمت می‌تواند هم یک
* پکیج آماده داشته باشد و هم چند قلم تکی. هم‌شکل {@see \App\Inventory\Entity\InventoryPackageItem}.
*/
#[ORM\Entity]
#[ORM\Table(name: 'service_item_consumables')]
#[ORM\UniqueConstraint(name: 'uniq_service_item_consumable', columns: ['service_item_id', 'item_id'])]
class ServiceItemConsumable
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: ServiceItem::class, inversedBy: 'consumables')]
#[ORM\JoinColumn(name: 'service_item_id', nullable: false, onDelete: 'CASCADE')]
private ServiceItem $serviceItem;
#[ORM\ManyToOne(targetEntity: InventoryItem::class)]
#[ORM\JoinColumn(name: 'item_id', nullable: false, onDelete: 'CASCADE')]
private InventoryItem $item;
#[ORM\Column(type: 'integer')]
private int $amount = 1;
public function __construct(InventoryItem $item, int $amount = 1)
{
$this->item = $item;
$this->amount = max(1, $amount);
}
public function getId(): ?int { return $this->id; }
public function getServiceItem(): ServiceItem { return $this->serviceItem; }
public function getItem(): InventoryItem { return $this->item; }
public function getAmount(): int { return $this->amount; }
public function setServiceItem(ServiceItem $s): self { $this->serviceItem = $s; return $this; }
public function setAmount(int $v): self { $this->amount = max(1, $v); return $this; }
public function toArray(): array
{
return [
'item_uuid' => $this->item->getUuid(),
'name' => $this->item->getName(),
'unit' => $this->item->getUnit(),
'price' => $this->item->getPrice(),
'stock' => $this->item->getStock(),
'amount' => $this->amount,
];
}
}