Files
clinicpro/src/ClinicService/Entity/ServiceItem.php
T
hamed c13cc57c48 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.
2026-07-18 12:34:42 +03:30

235 lines
9.9 KiB
PHP

<?php
namespace App\ClinicService\Entity;
use App\ClinicService\Repository\ServiceItemRepository;
use App\Staff\Entity\ClinicStaff;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: ServiceItemRepository::class)]
#[ORM\Table(name: 'service_items')]
class ServiceItem
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: ServiceSection::class, inversedBy: 'items')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ServiceSection $section;
// Legacy single-staff column, kept for backward compatibility with existing
// consumers (reception/session). Mirrors the first entry of $staffMembers.
#[ORM\ManyToOne(targetEntity: ClinicStaff::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?ClinicStaff $staff = null;
/**
* @var Collection<int, ClinicStaff> personnel assigned to this service.
* EAGER so hydration always populates the typed property (avoids the
* "accessed before initialization" pitfall on lazy typed collections).
*/
#[ORM\ManyToMany(targetEntity: ClinicStaff::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'service_item_staff')]
private Collection $staffMembers;
#[ORM\Column(type: 'string', length: 200)]
private string $name;
#[ORM\Column(name: 'price_rials', type: 'integer')]
private int $priceRials = 0;
#[ORM\Column(type: 'boolean')]
private bool $active = true;
#[ORM\Column(name: 'insurance_covered', type: 'boolean')]
private bool $insuranceCovered = false;
#[ORM\Column(name: 'insurance_price_rials', type: 'integer', nullable: true)]
private ?int $insurancePriceRials = null;
#[ORM\Column(name: 'duration_minutes', type: 'integer', nullable: true)]
private ?int $durationMinutes = null;
/** نمایش این سرویس در نوبت‌دهی (پزشک ممکن است همهٔ سرویس‌ها را ارائه ندهد). */
#[ORM\Column(type: 'boolean', options: ['default' => false])]
private bool $bookable = false;
/**
* پکیج کالای مصرفی این خدمت ({@see \App\Inventory\Entity\InventoryPackage}).
* ارجاع خام int بدون FK — همان الگوی Tariff و TenantServiceCoverage — تا دامنهٔ
* ClinicService به Inventory وابسته نشود.
*/
#[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;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(ServiceSection $section, string $name, int $priceRials = 0)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->section = $section;
$this->name = $name;
$this->priceRials = $priceRials;
$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; }
public function getUuid(): string { return $this->uuid; }
public function getSection(): ServiceSection { return $this->section; }
public function getStaff(): ?ClinicStaff { return $this->staff; }
public function getName(): string { return $this->name; }
public function getPriceRials(): int { return $this->priceRials; }
public function isActive(): bool { return $this->active; }
public function isInsuranceCovered(): bool { return $this->insuranceCovered; }
public function getInsurancePriceRials(): ?int { return $this->insurancePriceRials; }
public function getDurationMinutes(): ?int { return $this->durationMinutes; }
public function isBookable(): bool { return $this->bookable; }
public function getInventoryPackageId(): ?int { return $this->inventoryPackageId; }
public function getCreatedAt(): int { return $this->createdAt; }
public function getUpdatedAt(): int { return $this->updatedAt; }
public function setStaff(?ClinicStaff $staff): self { $this->staff = $staff; $this->updatedAt = time(); return $this; }
/** @return Collection<int, ClinicStaff> */
public function getStaffMembers(): Collection
{
// Doctrine hydrates without the constructor; guard the typed property.
return $this->staffMembers ??= new ArrayCollection();
}
/**
* Replace the assigned personnel. Also mirrors the first member into the
* legacy single {@see $staff} column so back-compat consumers keep working.
*
* @param ClinicStaff[] $members
*/
public function setStaffMembers(array $members): self
{
$collection = $this->getStaffMembers();
$collection->clear();
foreach ($members as $m) {
if (!$collection->contains($m)) {
$collection->add($m);
}
}
$this->staff = $members[0] ?? null;
$this->updatedAt = time();
return $this;
}
public function setName(string $name): self { $this->name = $name; $this->updatedAt = time(); return $this; }
public function setPriceRials(int $price): self { $this->priceRials = $price; $this->updatedAt = time(); return $this; }
public function setActive(bool $active): self { $this->active = $active; $this->updatedAt = time(); return $this; }
public function setInsuranceCovered(bool $v): self { $this->insuranceCovered = $v; $this->updatedAt = time(); return $this; }
public function setInsurancePriceRials(?int $v): self { $this->insurancePriceRials = $v; $this->updatedAt = time(); return $this; }
public function setDurationMinutes(?int $v): self { $this->durationMinutes = $v; $this->updatedAt = time(); return $this; }
public function setBookable(bool $v): self { $this->bookable = $v; $this->updatedAt = time(); return $this; }
public function setInventoryPackageId(?int $v): self { $this->inventoryPackageId = $v; $this->updatedAt = time(); return $this; }
public function toArray(): array
{
// Prefer the multi-staff collection; fall back to the legacy single
// staff so rows created before the migration still expose personnel.
$members = array_values($this->getStaffMembers()->toArray());
if (empty($members) && $this->staff !== null) {
$members = [$this->staff];
}
$primary = $members[0] ?? null;
return [
'uuid' => $this->uuid,
'section_uuid' => $this->section->getUuid(),
'section_name' => $this->section->getName(),
'staff_uuid' => $primary?->getUuid(),
'staff_name' => $primary?->getFullName(),
'staff' => $primary !== null
? ['uuid' => $primary->getUuid(), 'full_name' => $primary->getFullName()]
: null,
'staff_members' => array_map(
fn(ClinicStaff $s) => ['uuid' => $s->getUuid(), 'full_name' => $s->getFullName()],
$members
),
'name' => $this->name,
'price_rials' => $this->priceRials,
'active' => $this->active,
'insurance_covered' => $this->insuranceCovered,
'insurance_price_rials' => $this->insurancePriceRials,
'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,
];
}
}