From 6847a473d4028140f7e8347a52ebdb340e43dce5 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 6 Aug 2026 16:53:50 +0330 Subject: [PATCH] feat(treatment): open a treatment case with snapshotted areas and its sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- migrations/Version20260806131640.php | 56 ++++ .../Service/CategoryClosureResolver.php | 26 ++ src/Shared/Tenant/GlobalTables.php | 5 + src/Treatment/Entity/SessionAreaRecord.php | 157 +++++++++++ src/Treatment/Entity/TreatmentCase.php | 192 +++++++++++++ src/Treatment/Entity/TreatmentCaseArea.php | 67 +++++ src/Treatment/Entity/TreatmentSession.php | 217 +++++++++++++++ .../SessionAreaRecordRepository.php | 23 ++ .../TreatmentCaseAreaRepository.php | 23 ++ .../Repository/TreatmentCaseRepository.php | 57 ++++ .../Repository/TreatmentSessionRepository.php | 108 ++++++++ src/Treatment/Service/TreatmentCaseOpener.php | 88 ++++++ tests/Treatment/TreatmentCaseOpenerTest.php | 259 ++++++++++++++++++ 13 files changed, 1278 insertions(+) create mode 100644 migrations/Version20260806131640.php create mode 100644 src/Treatment/Entity/SessionAreaRecord.php create mode 100644 src/Treatment/Entity/TreatmentCase.php create mode 100644 src/Treatment/Entity/TreatmentCaseArea.php create mode 100644 src/Treatment/Entity/TreatmentSession.php create mode 100644 src/Treatment/Repository/SessionAreaRecordRepository.php create mode 100644 src/Treatment/Repository/TreatmentCaseAreaRepository.php create mode 100644 src/Treatment/Repository/TreatmentCaseRepository.php create mode 100644 src/Treatment/Repository/TreatmentSessionRepository.php create mode 100644 src/Treatment/Service/TreatmentCaseOpener.php create mode 100644 tests/Treatment/TreatmentCaseOpenerTest.php diff --git a/migrations/Version20260806131640.php b/migrations/Version20260806131640.php new file mode 100644 index 00000000..3f7f9dab --- /dev/null +++ b/migrations/Version20260806131640.php @@ -0,0 +1,56 @@ +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'); + } +} diff --git a/src/ClinicService/Service/CategoryClosureResolver.php b/src/ClinicService/Service/CategoryClosureResolver.php index a2857874..73e360c4 100644 --- a/src/ClinicService/Service/CategoryClosureResolver.php +++ b/src/ClinicService/Service/CategoryClosureResolver.php @@ -33,6 +33,32 @@ final class CategoryClosureResolver return $this->walk((int) $category->getId(), $map); } + /** + * برگ‌های زیرمجموعه — دسته‌هایی که خودشان زیرمجموعه‌ای ندارند. + * + * نواحیِ درمانِ یک سرویس همین‌هایند: «توتال» شامل «بیکینی» و «پا» و «دست» است و + * درمان روی آن سه انجام می‌شود، نه روی خودِ «توتال». دستهٔ میانی فقط گروه‌بندی است. + * + * دسته‌ای که هیچ زیرمجموعه‌ای ندارد، خودش تنها ناحیه است — «لیزر دست» یک سرویس + * واقعی است و باید یک ناحیه داشته باشد، نه صفر. + * + * @return list + */ + 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 { diff --git a/src/Shared/Tenant/GlobalTables.php b/src/Shared/Tenant/GlobalTables.php index 3897b406..ea4841b4 100644 --- a/src/Shared/Tenant/GlobalTables.php +++ b/src/Shared/Tenant/GlobalTables.php @@ -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, diff --git a/src/Treatment/Entity/SessionAreaRecord.php b/src/Treatment/Entity/SessionAreaRecord.php new file mode 100644 index 00000000..c03ec9ae --- /dev/null +++ b/src/Treatment/Entity/SessionAreaRecord.php @@ -0,0 +1,157 @@ +|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(); } +} diff --git a/src/Treatment/Entity/TreatmentCase.php b/src/Treatment/Entity/TreatmentCase.php new file mode 100644 index 00000000..1dd396bb --- /dev/null +++ b/src/Treatment/Entity/TreatmentCase.php @@ -0,0 +1,192 @@ + '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(); } +} diff --git a/src/Treatment/Entity/TreatmentCaseArea.php b/src/Treatment/Entity/TreatmentCaseArea.php new file mode 100644 index 00000000..c5dcedd5 --- /dev/null +++ b/src/Treatment/Entity/TreatmentCaseArea.php @@ -0,0 +1,67 @@ + 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, + ]; + } +} diff --git a/src/Treatment/Entity/TreatmentSession.php b/src/Treatment/Entity/TreatmentSession.php new file mode 100644 index 00000000..c7da01e1 --- /dev/null +++ b/src/Treatment/Entity/TreatmentSession.php @@ -0,0 +1,217 @@ +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(); } +} diff --git a/src/Treatment/Repository/SessionAreaRecordRepository.php b/src/Treatment/Repository/SessionAreaRecordRepository.php new file mode 100644 index 00000000..55aa0dc5 --- /dev/null +++ b/src/Treatment/Repository/SessionAreaRecordRepository.php @@ -0,0 +1,23 @@ + + */ +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]); + } +} diff --git a/src/Treatment/Repository/TreatmentCaseAreaRepository.php b/src/Treatment/Repository/TreatmentCaseAreaRepository.php new file mode 100644 index 00000000..67c9b503 --- /dev/null +++ b/src/Treatment/Repository/TreatmentCaseAreaRepository.php @@ -0,0 +1,23 @@ + + */ +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]); + } +} diff --git a/src/Treatment/Repository/TreatmentCaseRepository.php b/src/Treatment/Repository/TreatmentCaseRepository.php new file mode 100644 index 00000000..59b348b0 --- /dev/null +++ b/src/Treatment/Repository/TreatmentCaseRepository.php @@ -0,0 +1,57 @@ + + */ +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(); + } +} diff --git a/src/Treatment/Repository/TreatmentSessionRepository.php b/src/Treatment/Repository/TreatmentSessionRepository.php new file mode 100644 index 00000000..51d6c5c4 --- /dev/null +++ b/src/Treatment/Repository/TreatmentSessionRepository.php @@ -0,0 +1,108 @@ + + */ +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(); + } +} diff --git a/src/Treatment/Service/TreatmentCaseOpener.php b/src/Treatment/Service/TreatmentCaseOpener.php new file mode 100644 index 00000000..9b8b293d --- /dev/null +++ b/src/Treatment/Service/TreatmentCaseOpener.php @@ -0,0 +1,88 @@ +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 + */ + 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; + } +} diff --git a/tests/Treatment/TreatmentCaseOpenerTest.php b/tests/Treatment/TreatmentCaseOpenerTest.php new file mode 100644 index 00000000..8d0993f8 --- /dev/null +++ b/tests/Treatment/TreatmentCaseOpenerTest.php @@ -0,0 +1,259 @@ +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()); + } +}