feat(treatment): open a treatment case with snapshotted areas and its sessions

Opening a case copies what must not move afterwards — the session count and the
list of body areas, each with its category name — because a treatment record is
a medical document and editing settings tomorrow must not rewrite what was done
yesterday. The areas are the leaf categories under the service's own category:
"توتال" contains bikini, leg and hand, and treatment happens on those three, not
on the grouping node above them. A category with no children is its own single
area, so "لیزر دست" gets one area rather than none.

Every session in the course is created up front so that "session 5 of 8" has
somewhere to live, but none of them is booked: creating eight real appointments
would lock eight months of slots for a patient who may not attend session three.

CategoryClosureResolver gains leaves(); the graph walk it already does is what
tells a leaf from a grouping node, so this belongs next to descendants() rather
than in a second traversal elsewhere.

TreatmentCase and TreatmentSession carry no money field, and must not: billing
lives on PatientSession, which is created when an appointment is confirmed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-06 16:53:50 +03:30
co-authored by Claude Opus 5
parent e2e3e6b43b
commit 6847a473d4
13 changed files with 1278 additions and 0 deletions
@@ -33,6 +33,32 @@ final class CategoryClosureResolver
return $this->walk((int) $category->getId(), $map);
}
/**
* برگ‌های زیرمجموعه — دسته‌هایی که خودشان زیرمجموعه‌ای ندارند.
*
* نواحیِ درمانِ یک سرویس همین‌هایند: «توتال» شامل «بیکینی» و «پا» و «دست» است و
* درمان روی آن سه انجام می‌شود، نه روی خودِ «توتال». دستهٔ میانی فقط گروه‌بندی است.
*
* دسته‌ای که هیچ زیرمجموعه‌ای ندارد، خودش تنها ناحیه است — «لیزر دست» یک سرویس
* واقعی است و باید یک ناحیه داشته باشد، نه صفر.
*
* @return list<int>
*/
public function leaves(CatalogCategory $category): array
{
$map = $this->includes->edgeMapFor($category->getEntityType(), $category->getEntityId());
$descendants = $this->walk((int) $category->getId(), $map);
if ($descendants === []) {
return [(int) $category->getId()];
}
return array_values(array_filter(
$descendants,
static fn (int $id): bool => ($map[$id] ?? []) === [],
));
}
/** آیا یکی از این دو، دیگری را در بر می‌گیرد؟ (رابطه متقارن نیست، ولی تعارض هست) */
public function overlaps(CatalogCategory $a, CatalogCategory $b): bool
{
+5
View File
@@ -117,6 +117,11 @@ final class GlobalTables
\App\Treatment\Entity\TreatmentProtocol::class => \App\ClinicService\Entity\ServiceItem::class,
\App\Treatment\Entity\TreatmentProtocolStep::class => \App\Treatment\Entity\TreatmentProtocol::class,
\App\Treatment\Entity\TreatmentProtocolStaff::class => \App\Treatment\Entity\TreatmentProtocol::class,
// فهرست نواحیِ یک پرونده فقط از خودِ پرونده پیمایش می‌شود و uuidش هیچ‌جا از
// درخواست نمی‌آید. جلسه و رکورد ناحیه برعکس‌اند — پنل پرسنل uuidشان را مستقیم
// می‌فرستد — پس آن دو جفت محیط خودشان را دارند، نه اینجا.
\App\Treatment\Entity\TreatmentCaseArea::class => \App\Treatment\Entity\TreatmentCase::class,
// یال «این دسته شامل آن دسته است» جزئی از تعریف دستهٔ والد است؛ هر دو سرِ یال
// در یک محیط‌اند و سازندهٔ یال همین را اجبار می‌کند.
\App\ClinicService\Entity\CatalogCategoryInclude::class => \App\ClinicService\Entity\CatalogCategory::class,
+157
View File
@@ -0,0 +1,157 @@
<?php
namespace App\Treatment\Entity;
use App\Resource\Entity\ClinicResource;
use App\Shared\Tenant\TenantOwnedTrait;
use App\Treatment\Repository\SessionAreaRecordRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* آنچه اپراتور روی یک ناحیه در یک جلسه انجام داد.
*
* ستون‌های واقعی فقط چیزهایی‌اند که در هر تخصصی معنی دارند: کدام منبع، کِی شروع و
* تمام شد، و یادداشت. خوانده‌های مخصوصِ دستگاه — انرژی و پالس و شات لیزر — در
* `parameters` می‌نشینند و فهرست فیلدهایشان روی `ResourceType` تعریف می‌شود، پس
* افزودن دستگاه RF یا یونیت دندانپزشکی تنظیمات است نه migration.
*
* جفت محیط خودش را دارد چون uuidش مستقیم از درخواستِ پنل پرسنل می‌آید.
*/
#[ORM\Entity(repositoryClass: SessionAreaRecordRepository::class)]
#[ORM\Table(name: 'session_area_records')]
#[ORM\UniqueConstraint(name: 'uq_session_area', columns: ['session_id', 'case_area_id'])]
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_session_area_tenant')]
class SessionAreaRecord
{
use TenantOwnedTrait;
public const STATUS_PENDING = 'pending';
public const STATUS_IN_PROGRESS = 'in_progress';
public const STATUS_COMPLETED = 'completed';
public const STATUS_SKIPPED = 'skipped';
#[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: TreatmentSession::class, inversedBy: 'areaRecords')]
#[ORM\JoinColumn(name: 'session_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private TreatmentSession $session;
#[ORM\ManyToOne(targetEntity: TreatmentCaseArea::class)]
#[ORM\JoinColumn(name: 'case_area_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private TreatmentCaseArea $caseArea;
/** دستگاه در سطح ناحیه است نه جلسه: هر ناحیه فیزیک پوستِ خودش را دارد. */
#[ORM\ManyToOne(targetEntity: ClinicResource::class)]
#[ORM\JoinColumn(name: 'resource_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?ClinicResource $resource = null;
#[ORM\Column(type: 'string', length: 20)]
private string $status = self::STATUS_PENDING;
/** @var array<string, scalar>|null خوانده‌های دستگاه، بر اساس `ResourceType::$fieldSchema` */
#[ORM\Column(type: 'json', nullable: true)]
private ?array $parameters = null;
#[ORM\Column(name: 'started_at', type: 'integer', nullable: true)]
private ?int $startedAt = null;
#[ORM\Column(name: 'finished_at', type: 'integer', nullable: true)]
private ?int $finishedAt = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $note = null;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(TreatmentSession $session, TreatmentCaseArea $caseArea)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->session = $session;
$this->assignTenantPair($session->getEntityType(), $session->getEntityId());
$this->caseArea = $caseArea;
$this->createdAt = time();
$this->updatedAt = time();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getSession(): TreatmentSession { return $this->session; }
public function getCaseArea(): TreatmentCaseArea { return $this->caseArea; }
public function getResource(): ?ClinicResource { return $this->resource; }
public function getStatus(): string { return $this->status; }
public function getParameters(): ?array { return $this->parameters; }
public function getStartedAt(): ?int { return $this->startedAt; }
public function getFinishedAt(): ?int { return $this->finishedAt; }
public function getNote(): ?string { return $this->note; }
public function start(?ClinicResource $resource = null): self
{
$this->status = self::STATUS_IN_PROGRESS;
$this->startedAt = time();
if ($resource !== null) {
$this->resource = $resource;
}
$this->touch();
return $this;
}
public function complete(?ClinicResource $resource, ?array $parameters, ?string $note): self
{
$this->status = self::STATUS_COMPLETED;
$this->finishedAt = time();
$this->startedAt ??= $this->finishedAt;
$this->resource = $resource ?? $this->resource;
$this->parameters = $parameters;
$this->note = $note;
$this->touch();
return $this;
}
/** بیمار امروز فقط یک ناحیه می‌خواهد — بقیه صرف‌نظر می‌شوند، نه ناتمام رها. */
public function skip(): self
{
$this->status = self::STATUS_SKIPPED;
$this->finishedAt = time();
$this->touch();
return $this;
}
public function isSettled(): bool
{
return in_array($this->status, [self::STATUS_COMPLETED, self::STATUS_SKIPPED], true);
}
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'area' => $this->caseArea->toArray(),
'status' => $this->status,
'parameters' => $this->parameters,
'started_at' => $this->startedAt,
'finished_at' => $this->finishedAt,
'note' => $this->note,
'resource' => $this->resource === null ? null : [
'uuid' => $this->resource->getUuid(),
'name' => $this->resource->getName(),
'type' => $this->resource->getType()->getCode(),
],
];
}
private function touch(): void { $this->updatedAt = time(); }
}
+192
View File
@@ -0,0 +1,192 @@
<?php
namespace App\Treatment\Entity;
use App\ClinicService\Entity\ServiceItem;
use App\Doctor\Entity\Doctor;
use App\Patient\Entity\PatientRecord;
use App\Shared\Tenant\TenantOwnedTrait;
use App\Treatment\Repository\TreatmentCaseRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* یک بیمار، یک سرویس، یک دورهٔ درمان — از اولین رزرو تا پایان دوره.
*
* هیچ ستون پولی اینجا نیست و نباید بیاید. صورتحساب مالِ `PatientSession` است که با
* تأیید هر نوبت ساخته می‌شود؛ دو طرف دو چرخهٔ عمر دارند و آوردن قیمت به اینجا یعنی
* دو منبع حقیقت برای یک عدد.
*/
#[ORM\Entity(repositoryClass: TreatmentCaseRepository::class)]
#[ORM\Table(name: 'treatment_cases')]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'status'], name: 'idx_treatment_cases_tenant')]
#[ORM\Index(columns: ['patient_record_id'], name: 'idx_treatment_cases_record')]
class TreatmentCase
{
use TenantOwnedTrait;
public const STATUS_ACTIVE = 'active';
public const STATUS_COMPLETED = 'completed';
public const STATUS_ABANDONED = 'abandoned';
#[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: PatientRecord::class)]
#[ORM\JoinColumn(name: 'patient_record_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private PatientRecord $patientRecord;
#[ORM\ManyToOne(targetEntity: ServiceItem::class)]
#[ORM\JoinColumn(name: 'service_item_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
private ServiceItem $serviceItem;
#[ORM\ManyToOne(targetEntity: TreatmentProtocol::class)]
#[ORM\JoinColumn(name: 'protocol_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
private TreatmentProtocol $protocol;
#[ORM\ManyToOne(targetEntity: Doctor::class)]
#[ORM\JoinColumn(name: 'supervisor_doctor_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?Doctor $supervisorDoctor = null;
#[ORM\Column(type: 'string', length: 20)]
private string $status = self::STATUS_ACTIVE;
/**
* تعداد جلسات در لحظهٔ باز شدن پرونده.
*
* کپی است نه ارجاع: مدیر فردا پروتکل را عوض می‌کند و «جلسهٔ ۳ از ۸» نباید وسط
* درمان به «۳ از ۶» تبدیل شود.
*/
#[ORM\Column(name: 'total_sessions', type: 'smallint')]
private int $totalSessions;
#[ORM\Column(name: 'opened_at', type: 'integer')]
private int $openedAt;
#[ORM\Column(name: 'closed_at', type: 'integer', nullable: true)]
private ?int $closedAt = null;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
#[ORM\OneToMany(targetEntity: TreatmentCaseArea::class, mappedBy: 'treatmentCase', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['sortOrder' => 'ASC'])]
private Collection $areas;
#[ORM\OneToMany(targetEntity: TreatmentSession::class, mappedBy: 'treatmentCase', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['sessionNumber' => 'ASC'])]
private Collection $sessions;
public function __construct(
string $entityType,
int $entityId,
PatientRecord $patientRecord,
ServiceItem $serviceItem,
TreatmentProtocol $protocol,
) {
$this->uuid = Uuid::v4()->toRfc4122();
$this->assignTenantPair($entityType, $entityId);
$this->patientRecord = $patientRecord;
$this->serviceItem = $serviceItem;
$this->protocol = $protocol;
$this->totalSessions = $protocol->totalSessions();
$this->supervisorDoctor = $protocol->getSupervisorDoctor();
$this->openedAt = time();
$this->createdAt = time();
$this->updatedAt = time();
$this->areas = new ArrayCollection();
$this->sessions = new ArrayCollection();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getPatientRecord(): PatientRecord { return $this->patientRecord; }
public function getServiceItem(): ServiceItem { return $this->serviceItem; }
public function getProtocol(): TreatmentProtocol { return $this->protocol; }
public function getSupervisorDoctor(): ?Doctor { return $this->supervisorDoctor; }
public function getStatus(): string { return $this->status; }
public function getTotalSessions(): int { return $this->totalSessions; }
public function getOpenedAt(): int { return $this->openedAt; }
public function getClosedAt(): ?int { return $this->closedAt; }
public function getAreas(): Collection { return $this->areas; }
public function getSessions(): Collection { return $this->sessions; }
public function addArea(TreatmentCaseArea $area): self
{
$this->areas->add($area);
$this->touch();
return $this;
}
public function addSession(TreatmentSession $session): self
{
$this->sessions->add($session);
$this->touch();
return $this;
}
/** شمارهٔ جلساتی که به وضعیت نهایی رسیده‌اند — «۳ از ۸» در پنل. */
public function completedSessions(): int
{
return count(array_filter(
$this->sessions->toArray(),
static fn (TreatmentSession $s): bool => $s->getStatus() === TreatmentSession::STATUS_DONE,
));
}
public function close(string $status): self
{
$this->status = $status;
$this->closedAt = time();
$this->touch();
return $this;
}
public function toArray(bool $withSessions = false): array
{
$data = [
'uuid' => $this->uuid,
'status' => $this->status,
'total_sessions' => $this->totalSessions,
'completed_sessions' => $this->completedSessions(),
'opened_at' => $this->openedAt,
'closed_at' => $this->closedAt,
'service' => [
'uuid' => $this->serviceItem->getUuid(),
'name' => $this->serviceItem->getName(),
],
'supervisor' => $this->supervisorDoctor === null ? null : [
'uuid' => $this->supervisorDoctor->getUuid(),
'name' => $this->supervisorDoctor->getName(),
],
'areas' => array_map(
static fn (TreatmentCaseArea $a): array => $a->toArray(),
$this->areas->toArray(),
),
];
if ($withSessions) {
$data['sessions'] = array_map(
static fn (TreatmentSession $s): array => $s->toArray(),
$this->sessions->toArray(),
);
}
return $data;
}
private function touch(): void { $this->updatedAt = time(); }
}
@@ -0,0 +1,67 @@
<?php
namespace App\Treatment\Entity;
use App\ClinicService\Entity\CatalogCategory;
use App\Treatment\Repository\TreatmentCaseAreaRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* ناحیه‌ای از بدن که این دوره رویش انجام می‌شود — کپیِ لحظهٔ باز شدن پرونده.
*
* `nameSnapshot` عمدی است: سابقهٔ درمان سند پزشکی است و نباید با ویرایش تنظیمات
* بازنویسی شود. اگر مدیر فردا اسم دسته را عوض کند یا آن را از سرویس بردارد، «جلسهٔ ۱
* روی چه ناحیه‌ای زده شد» باید همان بماند.
*/
#[ORM\Entity(repositoryClass: TreatmentCaseAreaRepository::class)]
#[ORM\Table(name: 'treatment_case_areas')]
#[ORM\UniqueConstraint(name: 'uq_case_area_category', columns: ['treatment_case_id', 'catalog_category_id'])]
class TreatmentCaseArea
{
#[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: TreatmentCase::class, inversedBy: 'areas')]
#[ORM\JoinColumn(name: 'treatment_case_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private TreatmentCase $treatmentCase;
/** دسته می‌تواند بعداً حذف شود؛ سابقه با `nameSnapshot` سرِ پا می‌ماند. */
#[ORM\ManyToOne(targetEntity: CatalogCategory::class)]
#[ORM\JoinColumn(name: 'catalog_category_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?CatalogCategory $category = null;
#[ORM\Column(name: 'name_snapshot', type: 'string', length: 150)]
private string $nameSnapshot;
#[ORM\Column(name: 'sort_order', type: 'smallint', options: ['default' => 0])]
private int $sortOrder = 0;
public function __construct(TreatmentCase $case, CatalogCategory $category, int $sortOrder = 0)
{
$this->uuid = \Symfony\Component\Uid\Uuid::v4()->toRfc4122();
$this->treatmentCase = $case;
$this->category = $category;
$this->nameSnapshot = $category->getName();
$this->sortOrder = $sortOrder;
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getTreatmentCase(): TreatmentCase { return $this->treatmentCase; }
public function getCategory(): ?CatalogCategory { return $this->category; }
public function getName(): string { return $this->nameSnapshot; }
public function getSortOrder(): int { return $this->sortOrder; }
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'name' => $this->nameSnapshot,
];
}
}
+217
View File
@@ -0,0 +1,217 @@
<?php
namespace App\Treatment\Entity;
use App\Appointment\Entity\Appointment;
use App\Shared\Tenant\TenantOwnedTrait;
use App\Staff\Entity\ClinicStaff;
use App\Treatment\Repository\TreatmentSessionRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* جلسهٔ شمارهٔ n از یک دوره — چه رزرو شده باشد چه نه.
*
* جدا از `Appointment` است چون دو چرخهٔ عمرند: نوبت لغو و جابه‌جا می‌شود، ولی «جلسهٔ ۳
* از ۸» و آنچه در آن انجام شد باید سرِ جایش بماند. `appointment` تهی‌پذیر است تا
* جلسات آینده بدون قفل‌کردن ماه‌ها اسلات وجود داشته باشند.
*
* جفت محیط خودش را دارد و فرزند aggregate نیست: uuid این جلسه مستقیم از درخواستِ
* پنل پرسنل می‌آید و جست‌وجوی بی‌لنگر تور ایمنی می‌خواهد.
*/
#[ORM\Entity(repositoryClass: TreatmentSessionRepository::class)]
#[ORM\Table(name: 'treatment_sessions')]
#[ORM\UniqueConstraint(name: 'uq_session_case_number', columns: ['treatment_case_id', 'session_number'])]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'status'], name: 'idx_treatment_sessions_tenant')]
#[ORM\Index(columns: ['due_at'], name: 'idx_treatment_sessions_due')]
class TreatmentSession
{
use TenantOwnedTrait;
public const STATUS_PLANNED = 'planned';
public const STATUS_BOOKED = 'booked';
public const STATUS_IN_PROGRESS = 'in_progress';
public const STATUS_DONE = 'done';
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_NO_SHOW = 'no_show';
#[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: TreatmentCase::class, inversedBy: 'sessions')]
#[ORM\JoinColumn(name: 'treatment_case_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private TreatmentCase $treatmentCase;
#[ORM\Column(name: 'session_number', type: 'smallint')]
private int $sessionNumber;
/** لغو نوبت جلسه را نمی‌کشد — فقط بی‌نوبتش می‌کند. */
#[ORM\ManyToOne(targetEntity: Appointment::class)]
#[ORM\JoinColumn(name: 'appointment_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?Appointment $appointment = null;
/** چه کسی **واقعاً** انجامش داد؛ ممکن است با پرسنلِ برنامه‌ریزی‌شدهٔ نوبت فرق کند. */
#[ORM\ManyToOne(targetEntity: ClinicStaff::class)]
#[ORM\JoinColumn(name: 'performed_by_staff_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?ClinicStaff $performedBy = null;
#[ORM\Column(type: 'string', length: 20)]
private string $status = self::STATUS_PLANNED;
/**
* سررسید تخمینی: از تاریخ **واقعی** جلسهٔ قبل حساب می‌شود، پس تا انجام شدن جلسهٔ
* قبلی یک حدس است و بعد از آن بازمحاسبه می‌شود.
*/
#[ORM\Column(name: 'due_at', type: 'integer', nullable: true)]
private ?int $dueAt = null;
#[ORM\Column(name: 'started_at', type: 'integer', nullable: true)]
private ?int $startedAt = null;
#[ORM\Column(name: 'finished_at', type: 'integer', nullable: true)]
private ?int $finishedAt = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $note = null;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
#[ORM\OneToMany(targetEntity: SessionAreaRecord::class, mappedBy: 'session', cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $areaRecords;
public function __construct(TreatmentCase $case, int $sessionNumber, ?int $dueAt = null)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->treatmentCase = $case;
$this->assignTenantPair($case->getEntityType(), $case->getEntityId());
$this->sessionNumber = $sessionNumber;
$this->dueAt = $dueAt;
$this->createdAt = time();
$this->updatedAt = time();
$this->areaRecords = new ArrayCollection();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getTreatmentCase(): TreatmentCase { return $this->treatmentCase; }
public function getSessionNumber(): int { return $this->sessionNumber; }
public function getAppointment(): ?Appointment { return $this->appointment; }
public function getPerformedBy(): ?ClinicStaff { return $this->performedBy; }
public function getStatus(): string { return $this->status; }
public function getDueAt(): ?int { return $this->dueAt; }
public function getStartedAt(): ?int { return $this->startedAt; }
public function getFinishedAt(): ?int { return $this->finishedAt; }
public function getNote(): ?string { return $this->note; }
public function getAreaRecords(): Collection { return $this->areaRecords; }
public function setDueAt(?int $v): self { $this->dueAt = $v; $this->touch(); return $this; }
public function setPerformedBy(?ClinicStaff $v): self { $this->performedBy = $v; $this->touch(); return $this; }
public function setNote(?string $v): self { $this->note = $v; $this->touch(); return $this; }
public function addAreaRecord(SessionAreaRecord $record): self
{
$this->areaRecords->add($record);
$this->touch();
return $this;
}
/** رزرو یا جابه‌جایی: جلسه همان می‌ماند و فقط نوبتش عوض می‌شود. */
public function attachAppointment(?Appointment $appointment): self
{
$this->appointment = $appointment;
$this->status = $appointment === null ? self::STATUS_PLANNED : self::STATUS_BOOKED;
$this->touch();
return $this;
}
public function start(): self
{
$this->status = self::STATUS_IN_PROGRESS;
$this->startedAt = time();
$this->touch();
return $this;
}
public function finish(?string $note = null): self
{
$this->status = self::STATUS_DONE;
$this->finishedAt = time();
if ($note !== null) {
$this->note = $note;
}
$this->touch();
return $this;
}
/**
* غیبت جلسه را نمی‌سوزاند: بیمار پول ۸ جلسه می‌دهد و ۸ جلسه طلبکار است، پس همین
* جلسه دوباره برنامه‌ریزی می‌شود و `totalSessions` دست نمی‌خورد.
*/
public function markNoShow(): self
{
$this->status = self::STATUS_NO_SHOW;
$this->appointment = null;
$this->touch();
return $this;
}
public function reopen(): self
{
$this->status = self::STATUS_PLANNED;
$this->appointment = null;
$this->touch();
return $this;
}
public function toArray(bool $withAreas = false): array
{
$data = [
'uuid' => $this->uuid,
'session_number' => $this->sessionNumber,
'total_sessions' => $this->treatmentCase->getTotalSessions(),
'status' => $this->status,
'due_at' => $this->dueAt,
'started_at' => $this->startedAt,
'finished_at' => $this->finishedAt,
'note' => $this->note,
'appointment' => $this->appointment === null ? null : [
'uuid' => $this->appointment->getUuid(),
'slot_start' => $this->appointment->getSlotStart(),
'slot_end' => $this->appointment->getSlotEnd(),
'status' => $this->appointment->getStatus(),
],
'performed_by' => $this->performedBy === null ? null : [
'uuid' => $this->performedBy->getUuid(),
'name' => $this->performedBy->getFullName(),
],
];
if ($withAreas) {
$data['areas'] = array_map(
static fn (SessionAreaRecord $r): array => $r->toArray(),
$this->areaRecords->toArray(),
);
}
return $data;
}
private function touch(): void { $this->updatedAt = time(); }
}
@@ -0,0 +1,23 @@
<?php
namespace App\Treatment\Repository;
use App\Treatment\Entity\SessionAreaRecord;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<SessionAreaRecord>
*/
class SessionAreaRecordRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, SessionAreaRecord::class);
}
public function findByUuid(string $uuid): ?SessionAreaRecord
{
return $this->findOneBy(['uuid' => $uuid]);
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Treatment\Repository;
use App\Treatment\Entity\TreatmentCaseArea;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<TreatmentCaseArea>
*/
class TreatmentCaseAreaRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TreatmentCaseArea::class);
}
public function findByUuid(string $uuid): ?TreatmentCaseArea
{
return $this->findOneBy(['uuid' => $uuid]);
}
}
@@ -0,0 +1,57 @@
<?php
namespace App\Treatment\Repository;
use App\ClinicService\Entity\ServiceItem;
use App\Patient\Entity\PatientRecord;
use App\Treatment\Entity\TreatmentCase;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<TreatmentCase>
*/
class TreatmentCaseRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TreatmentCase::class);
}
public function findByUuid(string $uuid): ?TreatmentCase
{
return $this->findOneBy(['uuid' => $uuid]);
}
/**
* پروندهٔ بازِ همین بیمار برای همین سرویس.
*
* نقطهٔ تصمیمِ «پروندهٔ دوم نساز»: بیماری که وسط دورهٔ لیزرش نوبت دیگری از همان
* سرویس می‌گیرد، باید جلسهٔ همان دوره را بگیرد، نه یک دورهٔ موازی.
*/
public function findOpenFor(PatientRecord $record, ServiceItem $service): ?TreatmentCase
{
return $this->findOneBy([
'patientRecord' => $record,
'serviceItem' => $service,
'status' => TreatmentCase::STATUS_ACTIVE,
]);
}
/** @return TreatmentCase[] */
public function findForTenant(string $entityType, int $entityId, ?string $status = null): array
{
$qb = $this->createQueryBuilder('c')
->where('c.entityType = :type')
->andWhere('c.entityId = :id')
->setParameter('type', $entityType)
->setParameter('id', $entityId)
->orderBy('c.openedAt', 'DESC');
if ($status !== null) {
$qb->andWhere('c.status = :status')->setParameter('status', $status);
}
return $qb->getQuery()->getResult();
}
}
@@ -0,0 +1,108 @@
<?php
namespace App\Treatment\Repository;
use App\Staff\Entity\ClinicStaff;
use App\Treatment\Entity\TreatmentCase;
use App\Treatment\Entity\TreatmentSession;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<TreatmentSession>
*/
class TreatmentSessionRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TreatmentSession::class);
}
public function findByUuid(string $uuid): ?TreatmentSession
{
return $this->findOneBy(['uuid' => $uuid]);
}
/**
* جلسهٔ بعدیِ یک دوره — اولین جلسه‌ای که هنوز به وضعیت نهایی نرسیده.
*
* `no_show` هم برمی‌گردد: غیبت جلسه را نمی‌سوزاند و همان جلسه دوباره
* برنامه‌ریزی می‌شود.
*/
public function findNextOpen(TreatmentCase $case): ?TreatmentSession
{
return $this->createQueryBuilder('s')
->where('s.treatmentCase = :case')
->andWhere('s.status IN (:open)')
->setParameter('case', $case)
->setParameter('open', [
TreatmentSession::STATUS_PLANNED,
TreatmentSession::STATUS_BOOKED,
TreatmentSession::STATUS_IN_PROGRESS,
TreatmentSession::STATUS_NO_SHOW,
])
->orderBy('s.sessionNumber', 'ASC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
/** جلسهٔ انجام‌شدهٔ قبلی — لنگرِ محاسبهٔ سررسید جلسهٔ بعد. */
public function findLastFinishedBefore(TreatmentCase $case, int $sessionNumber): ?TreatmentSession
{
return $this->createQueryBuilder('s')
->where('s.treatmentCase = :case')
->andWhere('s.sessionNumber < :number')
->andWhere('s.status = :done')
->andWhere('s.finishedAt IS NOT NULL')
->setParameter('case', $case)
->setParameter('number', $sessionNumber)
->setParameter('done', TreatmentSession::STATUS_DONE)
->orderBy('s.sessionNumber', 'DESC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
/**
* جلسات امروزِ یک پرسنل — از روی نوبتِ متصل، نه از روی سررسید تخمینی.
*
* @return TreatmentSession[]
*/
public function findTodayForStaff(ClinicStaff $staff, int $dayStart, int $dayEnd): array
{
return $this->createQueryBuilder('s')
->join('s.appointment', 'a')
->where('a.staff = :staff')
->andWhere('a.slotStart >= :from')
->andWhere('a.slotStart <= :to')
->setParameter('staff', $staff)
->setParameter('from', $dayStart)
->setParameter('to', $dayEnd)
->orderBy('a.slotStart', 'ASC')
->getQuery()
->getResult();
}
/**
* صفِ «جلسات بدون نوبت» — جلسه‌ای که سررسیدش رسیده و کسی رزروش نکرده.
*
* @return TreatmentSession[]
*/
public function findUnbookedDue(string $entityType, int $entityId, int $until): array
{
return $this->createQueryBuilder('s')
->where('s.entityType = :type')
->andWhere('s.entityId = :id')
->andWhere('s.status IN (:open)')
->andWhere('s.dueAt IS NOT NULL')
->andWhere('s.dueAt <= :until')
->setParameter('type', $entityType)
->setParameter('id', $entityId)
->setParameter('open', [TreatmentSession::STATUS_PLANNED, TreatmentSession::STATUS_NO_SHOW])
->setParameter('until', $until)
->orderBy('s.dueAt', 'ASC')
->getQuery()
->getResult();
}
}
@@ -0,0 +1,88 @@
<?php
namespace App\Treatment\Service;
use App\ClinicService\Entity\CatalogCategory;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Repository\CatalogCategoryRepository;
use App\ClinicService\Service\CategoryClosureResolver;
use App\Patient\Entity\PatientRecord;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use App\Treatment\Entity\TreatmentCase;
use App\Treatment\Entity\TreatmentCaseArea;
use App\Treatment\Entity\TreatmentProtocol;
use App\Treatment\Entity\TreatmentSession;
use Doctrine\ORM\EntityManagerInterface;
/**
* باز کردن پروندهٔ درمان: کپی‌کردن نواحی و ساختن همهٔ جلسات دوره.
*
* هر دو جلسه از ابتدا ساخته می‌شوند تا «جلسهٔ ۵ از ۸» جایی برای زندگی داشته باشد،
* ولی فقط جلسهٔ اول نوبت می‌گیرد — ساختن هشت نوبتِ واقعی یعنی قفل‌کردن هشت ماه اسلات
* برای بیماری که شاید جلسهٔ سوم را هم نیاید.
*/
final class TreatmentCaseOpener
{
public function __construct(
private readonly CategoryClosureResolver $closure,
private readonly CatalogCategoryRepository $categories,
private readonly EntityManagerInterface $em,
) {}
public function open(
string $entityType,
int $entityId,
PatientRecord $record,
ServiceItem $service,
TreatmentProtocol $protocol,
): TreatmentCase {
$areas = $this->resolveAreas($service);
$case = new TreatmentCase($entityType, $entityId, $record, $service, $protocol);
foreach ($areas as $index => $category) {
$case->addArea(new TreatmentCaseArea($case, $category, $index));
}
// سررسید جلسات آینده تا انجام‌شدن جلسهٔ قبلی حدس است، پس فقط جلسهٔ اول تاریخ
// قطعی می‌گیرد و بقیه با `null` می‌مانند تا TreatmentScheduler پرشان کند.
foreach ($protocol->getSteps() as $step) {
$case->addSession(new TreatmentSession($case, $step->getStepNumber()));
}
$this->em->persist($case);
$this->em->flush();
return $case;
}
/**
* نواحیِ یک سرویس: برگ‌های دستهٔ همان سرویس.
*
* @return list<CatalogCategory>
*/
public function resolveAreas(ServiceItem $service): array
{
$category = $service->getCatalogCategory();
if ($category === null) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
'این سرویس دسته‌بندی ندارد؛ بدون دسته‌بندی، نواحی درمان مشخص نمی‌شوند',
422,
'catalog_category',
);
}
$ids = $this->closure->leaves($category);
$found = $this->categories->findBy(['id' => $ids]);
// ترتیب برگ‌ها را به ترتیب شناسه ثابت می‌کنیم تا فهرست نواحیِ دو پروندهٔ همزمان
// یکی باشد و پنل هر بار چیدمان دیگری نشان ندهد.
usort($found, static fn (CatalogCategory $a, CatalogCategory $b): int => $a->getId() <=> $b->getId());
return $found;
}
}