Files
clinicpro/src/ClinicService/Entity/ServiceItem.php
T
hamed 47323daa27 feat: add RichTextEditor component for rich text editing in articles
feat: create SanitizeBlogBodiesCommand to clean existing blog bodies according to current HTML sanitization policies

test: add AppointmentTreatmentSessionLinkTest to ensure appointment booking functionality works correctly with treatment session links
2026-08-08 11:40:17 +03:30

356 lines
16 KiB
PHP

<?php
namespace App\ClinicService\Entity;
use App\ClinicService\Repository\ServiceItemRepository;
use App\Insurance\Enum\ServiceCategory;
use App\Staff\Entity\ClinicStaff;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: ServiceItemRepository::class)]
#[ORM\Table(name: 'service_items')]
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_service_items_entity')]
class ServiceItem
{
use TenantOwnedTrait;
#[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: '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<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->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<int, ServiceItemConsumable> */
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<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 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<int, ClinicStaff> */
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,
];
}
}