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 */ #[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 */ public function getConsumables(): Collection { // Doctrine بدون constructor هیدریت می‌کند؛ از property تایپ‌شده محافظت کن. return $this->consumables ??= new ArrayCollection(); } /** * جایگزینی کامل اقلام تکی. کلید تطبیق، خودِ InventoryItem است تا ردیف بدون تغییر * حذف و دوباره ساخته نشود. * * @param array $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 */ 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, ]; } }