Files
clinicpro/src/ClinicService/Entity/ServiceItem.php
T
hamedandClaude Opus 5 e2e3e6b43b feat(treatment): add treatment protocols, the multi-session course of a service
A protocol says a course of a service runs over several sessions, when each
falls due, which doctor supervises it and which staff may perform it. The row
existing IS the "طول درمان" switch, so there is no separate boolean that could
disagree with the step list.

Each step's offset is measured from the previous session rather than from the
start of the course: laser spacing is a clinical requirement — hair regrows
relative to the last treatment — so a late patient shifts the rest of their
course instead of getting the next session early. That also lets one course use
uneven gaps, which a single min/ideal/max triple cannot express: a botox course
is session 1, then +15 days, then monthly.

Steps and staff are cleared and rewritten in two flushes inside a transaction.
A single flush sends inserts before deletes and the replacement row collides
with the unique (protocol, step_number) index — caught by the replace test.

Removes docs/api/course.md and the task-12 folder. They documented src/Course/,
a module deleted in 65d5831c whose commit message only mentions removing two
test files; that design is superseded by this one.

ServiceItem::$sessionCount is marked deprecated. It never had logic behind it
and session count now comes from the protocol; the column stays in payloads so
existing clients keep working.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 16:36:04 +03:30

348 lines
15 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 تایپ‌شده محافظت کن.
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.
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,
];
}
}