Files
clinicpro/tests/Treatment/TreatmentCaseOpenerTest.php
T
hamedandClaude Opus 5 6847a473d4 feat(treatment): open a treatment case with snapshotted areas and its sessions
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) <noreply@anthropic.com>
2026-08-06 16:53:50 +03:30

260 lines
9.6 KiB
PHP

<?php
namespace App\Tests\Treatment;
use App\Clinic\Entity\Clinic;
use App\ClinicService\Entity\CatalogCategory;
use App\ClinicService\Entity\CatalogCategoryInclude;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Patient\Entity\PatientRecord;
use App\Shared\Exception\AppException;
use App\Staff\Entity\ClinicStaff;
use App\Tests\ApiTestCase;
use App\Treatment\Entity\TreatmentCase;
use App\Treatment\Entity\TreatmentProtocol;
use App\Treatment\Entity\TreatmentProtocolStaff;
use App\Treatment\Entity\TreatmentProtocolStep;
use App\Treatment\Entity\TreatmentSession;
use App\Treatment\Service\TreatmentCaseOpener;
class TreatmentCaseOpenerTest extends ApiTestCase
{
private TreatmentCaseOpener $opener;
private Clinic $clinic;
private ServiceSection $section;
protected function setUp(): void
{
parent::setUp();
// مستقیم ساخته می‌شود، نه از container: تا وقتی کنترلری مصرفش نکند سرویس
// inline می‌شود و از container قابل گرفتن نیست — همان کاری که CategoryClosureTest می‌کند.
$this->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());
}
}