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: 'service_category', type: 'string', length: 30, enumType: ServiceCategory::class, options: ['default' => 'outpatient'])] private ServiceCategory $serviceCategory = ServiceCategory::Outpatient; /** * @deprecated منبع حقیقتِ پوشش، TenantServiceCoverage است و هیچ محاسبه‌ای این مقدار * را نمی‌خواند. ستون برای داده‌ی تاریخی مانده ولی نه نوشته می‌شود و نه منتشر. */ #[ORM\Column(name: 'insurance_price_rials', type: 'integer', nullable: true)] private ?int $insurancePriceRials = null; /** * @deprecated مقدار قدیمی؛ منبع حقیقتِ مدت اکنون `soloDurationMinutes` است. * هنوز نوشته می‌شود تا مصرف‌کننده‌های موجود نشکنند. */ #[ORM\Column(name: 'duration_minutes', type: 'integer', nullable: true)] private ?int $durationMinutes = null; /** مدت این آیتم وقتی **تنها** انجام شود. */ #[ORM\Column(name: 'solo_duration_minutes', type: 'smallint', nullable: true)] private ?int $soloDurationMinutes = null; /** * مدت این آیتم وقتی **کنار آیتم دیگری** در همان نوبت انجام شود. * * بند ۵ مستند: جمع سادهٔ مدت‌ها ظرفیت را هدر می‌دهد. «صورت + بیکینی» ۱۵+۱۲=۲۷ * نیست، ۱۵+۸=۲۳ است؛ آماده‌سازی و استقرار بیمار دو بار انجام نمی‌شود. * * `null` یعنی «همان مدت تنها» — سازگاری با دادهٔ موجودی که فقط یک عدد داشت. */ #[ORM\Column(name: 'additional_duration_minutes', type: 'smallint', nullable: true)] private ?int $additionalDurationMinutes = null; /** * @deprecated تعداد جلسات از `TreatmentProtocol::totalSessions()` می‌آید. * * این ستون هیچ‌وقت منطقی پشتش نداشت و فقط در پاسخ‌ها دیده می‌شد. برای نشکستن * کلاینت‌ها در `toArray()` می‌ماند، ولی هیچ کد جدیدی نباید بخواندش: پروتکل و این * عدد دو منبع حقیقت برای یک مفهوم‌اند و اولی مرجع است. */ #[ORM\Column(name: 'session_count', type: 'smallint', options: ['default' => 1])] private int $sessionCount = 1; #[ORM\ManyToOne(targetEntity: CatalogCategory::class)] #[ORM\JoinColumn(name: 'catalog_category_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] private ?CatalogCategory $catalogCategory = null; /** نمایش این سرویس در نوبت‌دهی (پزشک ممکن است همهٔ سرویس‌ها را ارائه ندهد). */ #[ORM\Column(type: 'boolean', options: ['default' => false])] private bool $bookable = false; /** * پکیج کالای مصرفی این خدمت ({@see \App\Inventory\Entity\InventoryPackage}). * ارجاع خام int بدون FK — همان الگوی 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->assignTenantPair($section->getEntityType(), $section->getEntityId()); $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 تایپ‌شده محافظت کن. // phpstan فقط constructor را می‌بیند و می‌گوید این property همیشه مقدار // دارد. Doctrine اما بدون constructor هیدریت می‌کند، پس روی نمونهٔ // نیمه‌ساخته می‌تواند initialize نشده باشد. گارد عمدی است. /** @phpstan-ignore-next-line */ 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 getServiceCategory(): ServiceCategory { return $this->serviceCategory; } 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. // phpstan فقط constructor را می‌بیند و می‌گوید این property همیشه مقدار // دارد. Doctrine اما بدون constructor هیدریت می‌کند، پس روی نمونهٔ // نیمه‌ساخته می‌تواند initialize نشده باشد. گارد عمدی است. /** @phpstan-ignore-next-line */ 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 setServiceCategory(ServiceCategory $v): self { $this->serviceCategory = $v; $this->updatedAt = time(); return $this; } /** هر دو ستون را هم‌زمان می‌نویسد تا «مدت» یک منبع حقیقت داشته باشد. */ public function setDurationMinutes(?int $v): self { $this->durationMinutes = $v; $this->soloDurationMinutes = $v; $this->updatedAt = time(); return $this; } public function getSoloDurationMinutes(): ?int { return $this->soloDurationMinutes ?? $this->durationMinutes; } public function getAdditionalDurationMinutes(): ?int { return $this->additionalDurationMinutes; } public function getSessionCount(): int { return $this->sessionCount; } public function getCatalogCategory(): ?CatalogCategory { return $this->catalogCategory; } /** * مدتِ «کنار بقیه». آیتمی که مقدارش را نگذاشته، همان مدت تنها را می‌گیرد — پس * دادهٔ موجود دقیقاً مثل قبل حساب می‌شود و این تغییر افزایشی است. */ public function effectiveAdditionalMinutes(): ?int { return $this->additionalDurationMinutes ?? $this->getSoloDurationMinutes(); } public function setSoloDurationMinutes(?int $v): self { $this->soloDurationMinutes = $v; $this->durationMinutes = $v; // ستون قدیمی هم‌گام می‌ماند $this->updatedAt = time(); return $this; } public function setAdditionalDurationMinutes(?int $v): self { $this->additionalDurationMinutes = $v; $this->updatedAt = time(); return $this; } /** @throws \InvalidArgumentException روی تعداد جلسهٔ کمتر از ۱ */ public function setSessionCount(int $v): self { if ($v < 1) { throw new \InvalidArgumentException('session_count must be at least 1.'); } $this->sessionCount = $v; $this->updatedAt = time(); return $this; } public function setCatalogCategory(?CatalogCategory $v): self { $this->catalogCategory = $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, 'service_category' => $this->getServiceCategory()->value, 'service_category_label' => $this->getServiceCategory()->label(), 'duration_minutes' => $this->durationMinutes, 'solo_duration_minutes' => $this->getSoloDurationMinutes(), 'additional_duration_minutes' => $this->effectiveAdditionalMinutes(), 'session_count' => $this->sessionCount, 'catalog_category_uuid' => $this->catalogCategory?->getUuid(), '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, ]; } }