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
@@ -17,6 +17,7 @@ use App\ClinicService\Service\ServiceItemAuditService;
use App\ClinicService\Service\TariffService;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Inventory\Repository\InventoryItemRepository;
use App\Inventory\Repository\InventoryPackageRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
@@ -44,6 +45,7 @@ class ClinicServiceController extends BaseController
private readonly TariffRepository $tariffRepo,
private readonly TariffService $tariffService,
private readonly InventoryPackageRepository $packageRepo,
private readonly InventoryItemRepository $inventoryItemRepo,
private readonly ServiceItemAuditService $auditService,
private readonly ServiceItemAuditLogRepository $auditLogRepo,
private readonly EntityManagerInterface $em,
@@ -98,6 +100,39 @@ class ClinicServiceController extends BaseController
return null;
}
/**
* `consumables: [{item_uuid, amount}]` را روی خدمت می‌نشاند. آرایه‌ی خالی یعنی حذف
* همه‌ی اقلام. هر قلم باید متعلق به همان مطب/کلینیک باشد.
*/
private function applyConsumables(ServiceItem $item, array $data, string $entityType, ?int $entityId): ?JsonResponse
{
if (!array_key_exists('consumables', $data)) {
return null;
}
$lines = [];
foreach ((array) ($data['consumables'] ?? []) as $raw) {
$uuid = is_array($raw) ? ($raw['item_uuid'] ?? null) : null;
if ($uuid === null || $uuid === '') {
continue;
}
$inventoryItem = $this->inventoryItemRepo->findByUuid((string) $uuid);
if ($inventoryItem === null
|| $inventoryItem->getEntityType() !== $entityType
|| $inventoryItem->getEntityId() !== $entityId
) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کالای انتخاب‌شده یافت نشد', 422, 'consumables');
}
$lines[] = ['item' => $inventoryItem, 'amount' => max(1, (int) ($raw['amount'] ?? 1))];
}
$item->replaceConsumables($lines);
return null;
}
// ── Service Sections ─────────────────────────────────────────────────────
#[Route('/api/v1/service-sections', methods: ['GET'])]
@@ -275,6 +310,10 @@ class ClinicServiceController extends BaseController
if ($packageError !== null) {
return $packageError;
}
$consumableError = $this->applyConsumables($item, $data, $entityType, $entityId);
if ($consumableError !== null) {
return $consumableError;
}
$this->itemRepo->save($item);
@@ -325,6 +364,10 @@ class ClinicServiceController extends BaseController
if ($packageError !== null) {
return $packageError;
}
$consumableError = $this->applyConsumables($item, $data, $entityType, $entityId);
if ($consumableError !== null) {
return $consumableError;
}
$this->itemRepo->save($item);
+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,
];
}
}
@@ -22,6 +22,7 @@ class ServiceItemAuditService
'bookable' => 'نمایش در نوبت‌دهی',
'insurance_covered' => 'پوشش بیمه',
'inventory_package' => 'پکیج کالا',
'consumables' => 'کالاهای تکی',
];
public function __construct(private readonly ServiceItemAuditLogRepository $repo) {}
@@ -37,9 +38,27 @@ class ServiceItemAuditService
'bookable' => $item->isBookable() ? '1' : '0',
'insurance_covered' => $item->isInsuranceCovered() ? '1' : '0',
'inventory_package' => $item->getInventoryPackageId() === null ? null : (string) $item->getInventoryPackageId(),
'consumables' => $this->consumablesFingerprint($item),
];
}
/** امضای مرتب‌شده‌ی اقلام تکی تا تغییر در قلم یا تعداد قابل تشخیص باشد. */
private function consumablesFingerprint(ServiceItem $item): ?string
{
$lines = [];
foreach ($item->getConsumables() as $consumable) {
$lines[] = $consumable->getItem()->getName() . '×' . $consumable->getAmount();
}
if ($lines === []) {
return null;
}
sort($lines);
return implode('، ', $lines);
}
public function logCreate(ServiceItem $item, ?User $actor): void
{
$this->repo->save(