Files
clinicpro/tests/Treatment/TreatmentCaseEditTest.php
T
hamedandClaude Opus 5 b78f7311cf feat(treatment): per-case operators, shown on the list and searchable
A treatment case said which doctor supervised it but never who actually did the
work, so the list could not answer the first question a manager asks about a
course: who performed it.

Two separate things now travel with the case. `performed_by` is history —
derived from the sessions' performedBy, so it only ever reports what happened.
`assigned_staff` is plan — a new treatment_case_staff table, editable from the
modal, saying who is meant to handle this patient's course. The card shows the
first and falls back to the second while nothing has been performed yet.

Search matches both. A manager typing an operator's name wants that person's
work, and work already done is part of it.

Assignment also narrows the operator queue: a case with assigned staff shows its
sessions only to those people, because a patient who started a multi-session
course with one operator should keep them. An unassigned case keeps the existing
protocol rule, and an empty list means "anyone the protocol allows" rather than
"nobody" — the same "no rows is not a restriction" convention used elsewhere.

Unlike areas, removing an operator erases nothing: a finished session carries its
real operator on itself and never consults this list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:55:01 +03:30

325 lines
13 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 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));
}
}