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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user