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>
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
<?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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user