feat(treatment): search and edit for treatment cases

The list had no way to tell two cases apart. TreatmentCase::toArray() carried
no patient, so four cases of the same service rendered as four identical
cards — same service, same supervisor, same date, same areas. Search would have
been meaningless without fixing that first, so the payload now carries the
patient (name, mobile, record number) and the card leads with the name.

Search: `?q=` on the list endpoint, matching patient name, mobile, national
code, record number and service name — the same keys a secretary already types
into the booking form. It lives in the URL via useUrlState, debounced, so back
and refresh keep the view.

Edit: PATCH /api/v1/treatment-case/{uuid} covering status, supervising doctor,
areas and session count, driven from a modal on the list. Rules live in
TreatmentCaseEditor, not the controller, around one boundary: no edit may
overwrite work already done. An area with session records cannot be removed, and
the session count cannot drop below the sessions that are booked or finished —
both 409, both tested. Reopening a closed case clears closed_at.

`areas[]` now also exposes `category_uuid`; the edit form selects catalog
categories, while `uuid` identifies the snapshot row.

Page fixes from the redesign checklist: the status filter was a hand-rolled
primary/secondary button pair, now `.seg` with `.on`; the raw `<progress>` bar
took the browser's own appearance and ignored the theme tokens, now a token-
styled bar with an explicit progressbar role; session counts go through
formatNumber; a failed request rendered as "no cases found", which reads as an
empty clinic rather than a broken one, and an empty search now says so in its
own words.

Adds the test files neither the page nor the case editor had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-07 13:48:57 +03:30
co-authored by Claude Opus 5
parent 00349cdb44
commit 952e09bd6a
11 changed files with 1120 additions and 58 deletions
+221
View File
@@ -0,0 +1,221 @@
<?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\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());
}
}