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:
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260806131640 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add treatment cases, their snapshotted areas, sessions and per-area records';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE session_area_records (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, status VARCHAR(20) NOT NULL, parameters JSON DEFAULT NULL, started_at INT DEFAULT NULL, finished_at INT DEFAULT NULL, note LONGTEXT DEFAULT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, session_id INT NOT NULL, case_area_id INT NOT NULL, resource_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_51D08C3AD17F50A6 (uuid), INDEX IDX_51D08C3A613FECDF (session_id), INDEX IDX_51D08C3A12ACB597 (case_area_id), INDEX IDX_51D08C3A89329D25 (resource_id), INDEX idx_session_area_tenant (entity_type, entity_id), UNIQUE INDEX uq_session_area (session_id, case_area_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||
$this->addSql('CREATE TABLE treatment_case_areas (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, name_snapshot VARCHAR(150) NOT NULL, sort_order SMALLINT DEFAULT 0 NOT NULL, treatment_case_id INT NOT NULL, catalog_category_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_1927F5A5D17F50A6 (uuid), INDEX IDX_1927F5A5B7D61639 (treatment_case_id), INDEX IDX_1927F5A53F2BC4C (catalog_category_id), UNIQUE INDEX uq_case_area_category (treatment_case_id, catalog_category_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||
$this->addSql('CREATE TABLE treatment_cases (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, status VARCHAR(20) NOT NULL, total_sessions SMALLINT NOT NULL, opened_at INT NOT NULL, closed_at INT DEFAULT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, patient_record_id INT NOT NULL, service_item_id INT NOT NULL, protocol_id INT NOT NULL, supervisor_doctor_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_7668E0B5D17F50A6 (uuid), INDEX IDX_7668E0B5DDEB00C2 (service_item_id), INDEX IDX_7668E0B5CCD59258 (protocol_id), INDEX IDX_7668E0B5EB665C09 (supervisor_doctor_id), INDEX idx_treatment_cases_tenant (entity_type, entity_id, status), INDEX idx_treatment_cases_record (patient_record_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||
$this->addSql('CREATE TABLE treatment_sessions (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, session_number SMALLINT NOT NULL, status VARCHAR(20) NOT NULL, due_at INT DEFAULT NULL, started_at INT DEFAULT NULL, finished_at INT DEFAULT NULL, note LONGTEXT DEFAULT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, treatment_case_id INT NOT NULL, appointment_id INT DEFAULT NULL, performed_by_staff_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_8CAD54AD17F50A6 (uuid), INDEX IDX_8CAD54AB7D61639 (treatment_case_id), INDEX IDX_8CAD54AE5B533F9 (appointment_id), INDEX IDX_8CAD54A94A09A49 (performed_by_staff_id), INDEX idx_treatment_sessions_tenant (entity_type, entity_id, status), INDEX idx_treatment_sessions_due (due_at), UNIQUE INDEX uq_session_case_number (treatment_case_id, session_number), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||
$this->addSql('ALTER TABLE session_area_records ADD CONSTRAINT FK_51D08C3A613FECDF FOREIGN KEY (session_id) REFERENCES treatment_sessions (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE session_area_records ADD CONSTRAINT FK_51D08C3A12ACB597 FOREIGN KEY (case_area_id) REFERENCES treatment_case_areas (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE session_area_records ADD CONSTRAINT FK_51D08C3A89329D25 FOREIGN KEY (resource_id) REFERENCES clinic_resources (id) ON DELETE SET NULL');
|
||||
$this->addSql('ALTER TABLE treatment_case_areas ADD CONSTRAINT FK_1927F5A5B7D61639 FOREIGN KEY (treatment_case_id) REFERENCES treatment_cases (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE treatment_case_areas ADD CONSTRAINT FK_1927F5A53F2BC4C FOREIGN KEY (catalog_category_id) REFERENCES service_catalog_categories (id) ON DELETE SET NULL');
|
||||
$this->addSql('ALTER TABLE treatment_cases ADD CONSTRAINT FK_7668E0B5EB76A733 FOREIGN KEY (patient_record_id) REFERENCES patient_records (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE treatment_cases ADD CONSTRAINT FK_7668E0B5DDEB00C2 FOREIGN KEY (service_item_id) REFERENCES service_items (id) ON DELETE RESTRICT');
|
||||
$this->addSql('ALTER TABLE treatment_cases ADD CONSTRAINT FK_7668E0B5CCD59258 FOREIGN KEY (protocol_id) REFERENCES treatment_protocols (id) ON DELETE RESTRICT');
|
||||
$this->addSql('ALTER TABLE treatment_cases ADD CONSTRAINT FK_7668E0B5EB665C09 FOREIGN KEY (supervisor_doctor_id) REFERENCES doctors (id) ON DELETE SET NULL');
|
||||
$this->addSql('ALTER TABLE treatment_sessions ADD CONSTRAINT FK_8CAD54AB7D61639 FOREIGN KEY (treatment_case_id) REFERENCES treatment_cases (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE treatment_sessions ADD CONSTRAINT FK_8CAD54AE5B533F9 FOREIGN KEY (appointment_id) REFERENCES appointments (id) ON DELETE SET NULL');
|
||||
$this->addSql('ALTER TABLE treatment_sessions ADD CONSTRAINT FK_8CAD54A94A09A49 FOREIGN KEY (performed_by_staff_id) REFERENCES clinic_staff (id) ON DELETE SET NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE session_area_records DROP FOREIGN KEY FK_51D08C3A613FECDF');
|
||||
$this->addSql('ALTER TABLE session_area_records DROP FOREIGN KEY FK_51D08C3A12ACB597');
|
||||
$this->addSql('ALTER TABLE session_area_records DROP FOREIGN KEY FK_51D08C3A89329D25');
|
||||
$this->addSql('ALTER TABLE treatment_case_areas DROP FOREIGN KEY FK_1927F5A5B7D61639');
|
||||
$this->addSql('ALTER TABLE treatment_case_areas DROP FOREIGN KEY FK_1927F5A53F2BC4C');
|
||||
$this->addSql('ALTER TABLE treatment_cases DROP FOREIGN KEY FK_7668E0B5EB76A733');
|
||||
$this->addSql('ALTER TABLE treatment_cases DROP FOREIGN KEY FK_7668E0B5DDEB00C2');
|
||||
$this->addSql('ALTER TABLE treatment_cases DROP FOREIGN KEY FK_7668E0B5CCD59258');
|
||||
$this->addSql('ALTER TABLE treatment_cases DROP FOREIGN KEY FK_7668E0B5EB665C09');
|
||||
$this->addSql('ALTER TABLE treatment_sessions DROP FOREIGN KEY FK_8CAD54AB7D61639');
|
||||
$this->addSql('ALTER TABLE treatment_sessions DROP FOREIGN KEY FK_8CAD54AE5B533F9');
|
||||
$this->addSql('ALTER TABLE treatment_sessions DROP FOREIGN KEY FK_8CAD54A94A09A49');
|
||||
$this->addSql('DROP TABLE session_area_records');
|
||||
$this->addSql('DROP TABLE treatment_case_areas');
|
||||
$this->addSql('DROP TABLE treatment_cases');
|
||||
$this->addSql('DROP TABLE treatment_sessions');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(); }
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Treatment;
|
||||
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicService\Entity\CatalogCategory;
|
||||
use App\ClinicService\Entity\CatalogCategoryInclude;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use App\Tests\ApiTestCase;
|
||||
use App\Treatment\Entity\TreatmentCase;
|
||||
use App\Treatment\Entity\TreatmentProtocol;
|
||||
use App\Treatment\Entity\TreatmentProtocolStaff;
|
||||
use App\Treatment\Entity\TreatmentProtocolStep;
|
||||
use App\Treatment\Entity\TreatmentSession;
|
||||
use App\Treatment\Service\TreatmentCaseOpener;
|
||||
|
||||
class TreatmentCaseOpenerTest extends ApiTestCase
|
||||
{
|
||||
private TreatmentCaseOpener $opener;
|
||||
private Clinic $clinic;
|
||||
private ServiceSection $section;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// مستقیم ساخته میشود، نه از container: تا وقتی کنترلری مصرفش نکند سرویس
|
||||
// inline میشود و از container قابل گرفتن نیست — همان کاری که CategoryClosureTest میکند.
|
||||
$this->opener = new TreatmentCaseOpener(
|
||||
new \App\ClinicService\Service\CategoryClosureResolver(
|
||||
$this->em->getRepository(CatalogCategoryInclude::class),
|
||||
),
|
||||
$this->em->getRepository(CatalogCategory::class),
|
||||
$this->em,
|
||||
);
|
||||
|
||||
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||
$this->clinic = new Clinic($user);
|
||||
$this->clinic->setName('کلینیک پروندهٔ درمان');
|
||||
$this->em->persist($this->clinic);
|
||||
$this->em->flush();
|
||||
|
||||
$this->section = new ServiceSection('clinic', (int) $this->clinic->getId(), 'لیزر');
|
||||
$this->em->persist($this->section);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
private function category(string $name): CatalogCategory
|
||||
{
|
||||
$category = new CatalogCategory('clinic', (int) $this->clinic->getId(), $name);
|
||||
$this->em->persist($category);
|
||||
$this->em->flush();
|
||||
|
||||
return $category;
|
||||
}
|
||||
|
||||
private function includes(CatalogCategory $parent, CatalogCategory $child): void
|
||||
{
|
||||
$this->em->persist(new CatalogCategoryInclude($parent, $child));
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
private function service(string $name, ?CatalogCategory $category): ServiceItem
|
||||
{
|
||||
$item = new ServiceItem($this->section, $name, 10_000_000);
|
||||
if ($category !== null) {
|
||||
$item->setCatalogCategory($category);
|
||||
}
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
private function protocolFor(ServiceItem $service, array $offsets): TreatmentProtocol
|
||||
{
|
||||
$staff = new ClinicStaff('clinic', (int) $this->clinic->getId(), 'اپراتور');
|
||||
$this->em->persist($staff);
|
||||
|
||||
$protocol = new TreatmentProtocol($service);
|
||||
$this->em->persist($protocol);
|
||||
|
||||
$steps = [];
|
||||
foreach ($offsets as $index => $offset) {
|
||||
$steps[] = new TreatmentProtocolStep($protocol, $index + 1, $offset);
|
||||
}
|
||||
$protocol->replaceSteps($steps);
|
||||
$protocol->replaceAllowedStaff([new TreatmentProtocolStaff($protocol, $staff)]);
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $protocol;
|
||||
}
|
||||
|
||||
private function record(): PatientRecord
|
||||
{
|
||||
$patient = $this->createUser();
|
||||
$record = new PatientRecord('clinic', (int) $this->clinic->getId(), $patient, 'clinic', (int) $this->clinic->getId());
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
private function open(ServiceItem $service, TreatmentProtocol $protocol): TreatmentCase
|
||||
{
|
||||
return $this->opener->open(
|
||||
'clinic',
|
||||
(int) $this->clinic->getId(),
|
||||
$this->record(),
|
||||
$service,
|
||||
$protocol,
|
||||
);
|
||||
}
|
||||
|
||||
/** «توتال» شامل بیکینی و پا و دست است؛ درمان روی آن سه انجام میشود نه روی خودِ توتال. */
|
||||
public function testAreasAreTheLeavesOfTheServiceCategory(): void
|
||||
{
|
||||
$total = $this->category('توتال');
|
||||
$bikini = $this->category('بیکینی');
|
||||
$leg = $this->category('پا');
|
||||
$hand = $this->category('دست');
|
||||
$this->includes($total, $bikini);
|
||||
$this->includes($total, $leg);
|
||||
$this->includes($total, $hand);
|
||||
|
||||
$service = $this->service('لیزر توتال', $total);
|
||||
$protocol = $this->protocolFor($service, [0, 30]);
|
||||
|
||||
$case = $this->open($service, $protocol);
|
||||
|
||||
$names = array_map(static fn ($a) => $a->getName(), $case->getAreas()->toArray());
|
||||
sort($names);
|
||||
self::assertSame(['بیکینی', 'دست', 'پا'], $names);
|
||||
}
|
||||
|
||||
/** دستهٔ میانی گروهبندی است، نه ناحیهٔ درمان. */
|
||||
public function testIntermediateCategoriesAreNotAreas(): void
|
||||
{
|
||||
$body = $this->category('تمام بدن');
|
||||
$lower = $this->category('نیمتنهٔ پایین');
|
||||
$leg = $this->category('پا');
|
||||
$this->includes($body, $lower);
|
||||
$this->includes($lower, $leg);
|
||||
|
||||
$service = $this->service('لیزر تمام بدن', $body);
|
||||
$protocol = $this->protocolFor($service, [0, 30]);
|
||||
|
||||
$case = $this->open($service, $protocol);
|
||||
|
||||
$names = array_map(static fn ($a) => $a->getName(), $case->getAreas()->toArray());
|
||||
self::assertSame(['پا'], $names);
|
||||
}
|
||||
|
||||
/** «لیزر دست» یک سرویس واقعی است و باید یک ناحیه داشته باشد، نه صفر. */
|
||||
public function testCategoryWithoutChildrenIsItsOwnArea(): void
|
||||
{
|
||||
$hand = $this->category('دست');
|
||||
$service = $this->service('لیزر دست', $hand);
|
||||
$protocol = $this->protocolFor($service, [0, 30]);
|
||||
|
||||
$case = $this->open($service, $protocol);
|
||||
|
||||
$names = array_map(static fn ($a) => $a->getName(), $case->getAreas()->toArray());
|
||||
self::assertSame(['دست'], $names);
|
||||
}
|
||||
|
||||
public function testServiceWithoutCategoryIsRejected(): void
|
||||
{
|
||||
$service = $this->service('لیزر بیدسته', null);
|
||||
$protocol = $this->protocolFor($service, [0, 30]);
|
||||
|
||||
$this->expectException(AppException::class);
|
||||
$this->open($service, $protocol);
|
||||
}
|
||||
|
||||
/** همهٔ جلسات از ابتدا ساخته میشوند تا «جلسهٔ ۵ از ۸» جایی برای زندگی داشته باشد. */
|
||||
public function testEverySessionIsCreatedUpFrontAndNoneIsBooked(): void
|
||||
{
|
||||
$hand = $this->category('دست');
|
||||
$service = $this->service('لیزر دست', $hand);
|
||||
$protocol = $this->protocolFor($service, [0, 15, 30, 30]);
|
||||
|
||||
$case = $this->open($service, $protocol);
|
||||
|
||||
self::assertSame(4, $case->getTotalSessions());
|
||||
self::assertCount(4, $case->getSessions());
|
||||
self::assertSame([1, 2, 3, 4], array_map(
|
||||
static fn (TreatmentSession $s): int => $s->getSessionNumber(),
|
||||
$case->getSessions()->toArray(),
|
||||
));
|
||||
|
||||
foreach ($case->getSessions() as $session) {
|
||||
self::assertSame(TreatmentSession::STATUS_PLANNED, $session->getStatus());
|
||||
self::assertNull($session->getAppointment());
|
||||
}
|
||||
}
|
||||
|
||||
/** تعداد جلسات کپی میشود: تغییر فردای پروتکل نباید «۳ از ۸» را به «۳ از ۶» تبدیل کند. */
|
||||
public function testTotalSessionsIsSnapshotted(): void
|
||||
{
|
||||
$hand = $this->category('دست');
|
||||
$service = $this->service('لیزر دست', $hand);
|
||||
$protocol = $this->protocolFor($service, [0, 30, 30, 30]);
|
||||
|
||||
$case = $this->open($service, $protocol);
|
||||
self::assertSame(4, $case->getTotalSessions());
|
||||
|
||||
// دو flush، مثل TreatmentProtocolWriter: در یک flush، درج پیش از حذف میرود و
|
||||
// به قید یکتای (protocol, step_number) میخورد.
|
||||
$protocol->replaceSteps([]);
|
||||
$this->em->flush();
|
||||
$protocol->replaceSteps([
|
||||
new TreatmentProtocolStep($protocol, 1, 0),
|
||||
new TreatmentProtocolStep($protocol, 2, 30),
|
||||
]);
|
||||
$this->em->flush();
|
||||
|
||||
self::assertSame(4, $case->getTotalSessions());
|
||||
}
|
||||
|
||||
/** اسم ناحیه هم snapshot است — سابقهٔ درمان با ویرایش تنظیمات بازنویسی نمیشود. */
|
||||
public function testAreaNameIsSnapshotted(): void
|
||||
{
|
||||
$hand = $this->category('دست');
|
||||
$service = $this->service('لیزر دست', $hand);
|
||||
$protocol = $this->protocolFor($service, [0, 30]);
|
||||
|
||||
$case = $this->open($service, $protocol);
|
||||
|
||||
$hand->setName('دست و ساعد');
|
||||
$this->em->flush();
|
||||
|
||||
$names = array_map(static fn ($a) => $a->getName(), $case->getAreas()->toArray());
|
||||
self::assertSame(['دست'], $names);
|
||||
}
|
||||
|
||||
public function testCaseCarriesTheProtocolSupervisor(): void
|
||||
{
|
||||
$hand = $this->category('دست');
|
||||
$service = $this->service('لیزر دست', $hand);
|
||||
$protocol = $this->protocolFor($service, [0, 30]);
|
||||
|
||||
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$doctor = new \App\Doctor\Entity\Doctor($doctorUser, 'دکتر ناظر');
|
||||
$doctor->setMobileNumber($doctorUser->getMobileNumber());
|
||||
$this->em->persist($doctor);
|
||||
$protocol->setSupervisorDoctor($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$case = $this->open($service, $protocol);
|
||||
|
||||
self::assertSame($doctor->getId(), $case->getSupervisorDoctor()?->getId());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user