Files
clinicpro/tests/Treatment/TreatmentCaseEditTest.php
hamedandClaude Opus 5 250e0b0813 feat(treatment): filter treatment cases by patient record
The list could be narrowed by status, search and open-date, but not by patient
— so a patient's own file had no way to ask which courses belong to them.
`?record=` adds that bound.

patientRecord is joined once and shared with the search branch; joining it twice
under the same alias is a DQL error, and search already needed it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:05:35 +03:30

351 lines
14 KiB
PHP

<?php
namespace App\Tests\Treatment;
use App\Appointment\Entity\Appointment;
use App\Clinic\Entity\Clinic;
use App\ClinicService\Entity\CatalogCategory;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\Doctor;
use App\Patient\Entity\PatientRecord;
use App\Tests\ApiTestCase;
use App\Treatment\Entity\TreatmentCase;
use App\Staff\Entity\ClinicStaff;
use App\Treatment\Entity\TreatmentCaseArea;
use App\Treatment\Entity\TreatmentProtocol;
use App\Treatment\Entity\TreatmentProtocolStep;
use App\Treatment\Entity\TreatmentSession;
use App\Treatment\Repository\TreatmentCaseRepository;
use App\Treatment\Service\TreatmentCaseEditor;
/**
* ویرایش پروندهٔ باز، با یک مرز ثابت: سابقهٔ انجام‌شده بازنویسی نمی‌شود.
*/
class TreatmentCaseEditTest extends ApiTestCase
{
private function editor(): TreatmentCaseEditor
{
return static::getContainer()->get(TreatmentCaseEditor::class);
}
private function cases(): TreatmentCaseRepository
{
return static::getContainer()->get(TreatmentCaseRepository::class);
}
/**
* @return array{TreatmentCase, Clinic, ServiceItem, array<string, CatalogCategory>}
*/
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));
}
}