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
356 lines
16 KiB
PHP
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,
|
|
];
|
|
}
|
|
}
|