get(TreatmentCaseEditor::class); } private function cases(): TreatmentCaseRepository { return static::getContainer()->get(TreatmentCaseRepository::class); } /** * @return array{TreatmentCase, Clinic, ServiceItem, array} */ private function scenario(string $patientName = 'سارا کاظمی'): array { $doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر ویرایش'); $this->em->persist($doctor); $clinic = new Clinic($this->createUser(['ROLE_CLINIC'])); $clinic->setName('کلینیک ویرایش ' . uniqid()); $clinic->getDoctors()->add($doctor); $this->em->persist($clinic); $this->em->flush(); $section = new ServiceSection('clinic', (int) $clinic->getId(), 'لیزر'); $this->em->persist($section); $categories = []; foreach (['دست', 'پا', 'صورت'] as $name) { $c = new CatalogCategory('clinic', (int) $clinic->getId(), $name); $this->em->persist($c); $categories[$name] = $c; } $service = new ServiceItem($section, 'لیزر بدن', 4_000_000); $this->em->persist($service); $this->em->flush(); $protocol = new TreatmentProtocol($service); $this->em->persist($protocol); $protocol->replaceSteps([ new TreatmentProtocolStep($protocol, 1, 0), new TreatmentProtocolStep($protocol, 2, 15), new TreatmentProtocolStep($protocol, 3, 30), ]); $this->em->flush(); $patient = $this->createUser(['ROLE_USER']); $patient->setRealName($patientName); $record = new PatientRecord('clinic', (int) $clinic->getId(), $patient, 'clinic', (int) $clinic->getId()); $this->em->persist($record); $this->em->flush(); $case = new TreatmentCase('clinic', (int) $clinic->getId(), $record, $service, $protocol); $this->em->persist($case); $case->addArea(new TreatmentCaseArea($case, $categories['دست'], 0)); $case->addArea(new TreatmentCaseArea($case, $categories['پا'], 1)); foreach ([1, 2, 3] as $n) { $case->addSession(new TreatmentSession($case, $n)); } $this->em->flush(); return [$case, $clinic, $service, $categories]; } private function sessionNumbers(TreatmentCase $case): array { $numbers = array_map( static fn (TreatmentSession $s): int => $s->getSessionNumber(), $case->getSessions()->toArray(), ); sort($numbers); return $numbers; } public function testStatusCanBeClosedAndReopened(): void { [$case] = $this->scenario(); $this->editor()->update($case, ['status' => TreatmentCase::STATUS_ABANDONED]); self::assertSame(TreatmentCase::STATUS_ABANDONED, $case->getStatus()); self::assertNotNull($case->getClosedAt()); // بازگرداندن باید closedAt را پاک کند، وگرنه پرونده «بستهٔ فعال» می‌ماند. $this->editor()->update($case, ['status' => TreatmentCase::STATUS_ACTIVE]); self::assertSame(TreatmentCase::STATUS_ACTIVE, $case->getStatus()); self::assertNull($case->getClosedAt()); } public function testSupervisorCanBeChangedAndCleared(): void { [$case, $clinic] = $this->scenario(); $other = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر تازه'); $this->em->persist($other); $clinic->getDoctors()->add($other); $this->em->flush(); $this->editor()->update($case, ['supervisor_doctor_uuid' => $other->getUuid()]); self::assertSame($other->getId(), $case->getSupervisorDoctor()?->getId()); $this->editor()->update($case, ['supervisor_doctor_uuid' => null]); self::assertNull($case->getSupervisorDoctor()); } public function testAreasAreReplaced(): void { [$case, , , $categories] = $this->scenario(); $this->editor()->update($case, ['area_uuids' => [ $categories['دست']->getUuid(), $categories['صورت']->getUuid(), ]]); $names = array_map(static fn ($a) => $a->getName(), $case->getAreas()->toArray()); sort($names); self::assertSame(['دست', 'صورت'], $names); } /** حذف ناحیه‌ای که جلسه‌ای رویش ثبت شده یعنی پاک کردن سابقهٔ درمان. */ public function testAnAreaWithRecordsCannotBeRemoved(): void { [$case, , , $categories] = $this->scenario(); $area = $case->getAreas()->first(); $session = $case->getSessions()->first(); $this->em->persist(new \App\Treatment\Entity\SessionAreaRecord($session, $area)); $this->em->flush(); $this->expectException(\App\Shared\Exception\AppException::class); $this->editor()->update($case, ['area_uuids' => [$categories['صورت']->getUuid()]]); } public function testSessionsGrowAndShrink(): void { [$case] = $this->scenario(); $this->editor()->update($case, ['total_sessions' => 5]); self::assertSame([1, 2, 3, 4, 5], $this->sessionNumbers($case)); self::assertSame(5, $case->getTotalSessions()); $this->editor()->update($case, ['total_sessions' => 2]); self::assertSame([1, 2], $this->sessionNumbers($case)); self::assertSame(2, $case->getTotalSessions()); } /** جلسه‌ای که نوبت گرفته کفِ تعداد را بالا می‌برد؛ حذفش یعنی گم شدن یک نوبت واقعی. */ public function testSessionsCannotDropBelowBookedWork(): void { [$case, $clinic] = $this->scenario(); $sessions = $case->getSessions()->toArray(); usort($sessions, static fn ($a, $b) => $a->getSessionNumber() <=> $b->getSessionNumber()); $appointment = $this->newAppointment( $clinic->getDoctors()->first(), $this->createUser(['ROLE_USER']), 1_795_000_000, 1_795_001_800, $clinic, ); $this->em->persist($appointment); $this->em->flush(); $sessions[2]->attachAppointment($appointment); $this->em->flush(); $this->expectException(\App\Shared\Exception\AppException::class); $this->editor()->update($case, ['total_sessions' => 2]); } public function testTotalSessionsIsBounded(): void { [$case] = $this->scenario(); $this->expectException(\App\Shared\Exception\AppException::class); $this->editor()->update($case, ['total_sessions' => 1]); } /** جستجو باید همان کلیدی را بگیرد که منشی در فرم نوبت می‌زند. */ public function testSearchMatchesPatientAndService(): void { $name = 'نازنین ' . uniqid(); [$case, $clinic] = $this->scenario($name); $type = 'clinic'; $id = (int) $clinic->getId(); self::assertCount(1, $this->cases()->findForTenant($type, $id, null, $name)); self::assertCount(1, $this->cases()->findForTenant($type, $id, null, 'لیزر بدن')); self::assertSame([], $this->cases()->findForTenant($type, $id, null, 'چیزی که نیست')); self::assertCount(1, $this->cases()->findForTenant($type, $id, null, null)); self::assertSame($case->getId(), $this->cases()->findForTenant($type, $id, null, $name)[0]->getId()); } private function staffIn(Clinic $clinic, string $name): ClinicStaff { $staff = new ClinicStaff('clinic', (int) $clinic->getId(), $name); $this->em->persist($staff); $this->em->flush(); return $staff; } /** اپراتورِ پرونده: بیمار دوره‌اش را با یک نفر شروع می‌کند. */ public function testAssignedStaffCanBeSetAndCleared(): void { [$case, $clinic] = $this->scenario(); $a = $this->staffIn($clinic, 'اپراتور الف'); $b = $this->staffIn($clinic, 'اپراتور ب'); $this->editor()->update($case, ['staff_uuids' => [$a->getUuid(), $b->getUuid()]]); $names = array_map(static fn ($r) => $r->getStaff()->getFullName(), $case->getAssignedStaff()->toArray()); sort($names); self::assertSame(['اپراتور الف', 'اپراتور ب'], $names); // فهرست خالی مجاز است — یعنی «هر کسی که پروتکل مجاز دانسته»، نه «هیچ‌کس». $this->editor()->update($case, ['staff_uuids' => []]); self::assertCount(0, $case->getAssignedStaff()); } public function testStaffFromAnotherTenantIsRejected(): void { [$case] = $this->scenario(); $otherClinic = new Clinic($this->createUser(['ROLE_CLINIC'])); $otherClinic->setName('کلینیک دیگر ' . uniqid()); $this->em->persist($otherClinic); $this->em->flush(); $outsider = $this->staffIn($otherClinic, 'پرسنل بیرونی'); $this->expectException(\App\Shared\Exception\AppException::class); $this->editor()->update($case, ['staff_uuids' => [$outsider->getUuid()]]); } /** مدیر نام اپراتور را می‌زند و انتظار دارد پرونده‌هایش بیاید. */ public function testSearchMatchesAssignedStaff(): void { [$case, $clinic] = $this->scenario(); $name = 'اپراتور ' . uniqid(); $staff = $this->staffIn($clinic, $name); $type = 'clinic'; $id = (int) $clinic->getId(); self::assertSame([], $this->cases()->findForTenant($type, $id, null, $name)); $this->editor()->update($case, ['staff_uuids' => [$staff->getUuid()]]); $found = $this->cases()->findForTenant($type, $id, null, $name); self::assertCount(1, $found); self::assertSame($case->getId(), $found[0]->getId()); } /** «کارهای این نفر» شاملِ کارِ انجام‌شده هم هست، نه فقط اختصاصِ آینده. */ public function testSearchMatchesTheOperatorWhoPerformedASession(): void { [$case, $clinic] = $this->scenario(); $name = 'انجام‌دهنده ' . uniqid(); $staff = $this->staffIn($clinic, $name); $case->getSessions()->first()->setPerformedBy($staff); $this->em->flush(); $found = $this->cases()->findForTenant('clinic', (int) $clinic->getId(), null, $name); self::assertCount(1, $found); // و در payload هم دیده می‌شود، جدا از اختصاص. $payload = $found[0]->toArray(); self::assertSame([$name], array_column($payload['performed_by'], 'name')); self::assertSame([], $payload['assigned_staff']); } /** نمای «پروندهٔ بیمار»: فقط دوره‌های همان بیمار. */ public function testFilteringByPatientRecord(): void { [$case, $clinic] = $this->scenario('بیمار الف ' . uniqid()); $recordUuid = $case->getPatientRecord()->getUuid(); // دورهٔ دومی برای بیمار دیگری در همان محیط [$other] = $this->scenario('بیمار ب ' . uniqid()); $type = 'clinic'; $id = (int) $clinic->getId(); $mine = $this->cases()->findForTenant($type, $id, null, null, null, null, $recordUuid); self::assertCount(1, $mine); self::assertSame($case->getId(), $mine[0]->getId()); // uuid ناموجود → آرایهٔ خالی، نه خطا self::assertSame([], $this->cases()->findForTenant($type, $id, null, null, null, null, 'does-not-exist')); // فیلتر بیمار و جستجو با هم — یک join مشترک، نه دو تا $name = $case->getPatientRecord()->getUser()->getRealName(); self::assertCount(1, $this->cases()->findForTenant($type, $id, null, $name, null, null, $recordUuid)); self::assertNotSame($case->getId(), $other->getId()); } /** فیلتر بازه روی تاریخِ باز شدن پرونده است، نه سررسید جلسه. */ public function testDateRangeFiltersByOpenedAt(): void { [$case, $clinic] = $this->scenario('بازه ' . uniqid()); $type = 'clinic'; $id = (int) $clinic->getId(); $openedAt = $case->getOpenedAt(); // بازه‌ای که همان لحظه را در بر می‌گیرد self::assertCount(1, $this->cases()->findForTenant($type, $id, null, null, $openedAt - 60, $openedAt + 60)); // کرانِ پایین بعد از پرونده self::assertSame([], $this->cases()->findForTenant($type, $id, null, null, $openedAt + 60, null)); // کرانِ بالا قبل از پرونده self::assertSame([], $this->cases()->findForTenant($type, $id, null, null, null, $openedAt - 60)); // یک‌طرفه هم باید کار کند self::assertCount(1, $this->cases()->findForTenant($type, $id, null, null, $openedAt - 60, null)); self::assertCount(1, $this->cases()->findForTenant($type, $id, null, null, null, $openedAt + 60)); } }