feat(treatment): select treatment behaviour by practice domain, not by if-branch

Everything that differs between specialties as data is already stored as data.
What is left is behaviour — when a case opens, what happens once a session ends —
so it becomes a TreatmentWorkflow resolved through a tagged-service registry.
The booking path calls one collaborator and never names a specialty; adding
dentistry is a new class, not an edit to confirmation.

A clinic that has chosen no practice domain still gets working multi-session
courses: DefaultTreatmentWorkflow answers for null and for any code without a
dedicated implementation, keeping "unset means behave as today, not error".
LaserTreatmentWorkflow is deliberately empty beyond claiming `beauty` — it is the
seam where laser-specific behaviour will land without disturbing anyone else.

Session due dates are anchored to the previous session's actual finish, so a
patient who comes twenty days late shifts the rest of their course instead of
getting the next session while it can still do nothing. Only the next session is
recomputed; later ones keep their estimate because they are anchored to nothing
yet.

Attachment targets the first session without an appointment rather than the
first open one: a patient booking again mid-course was otherwise matched to the
session that already had a booking, and the second appointment went nowhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-06 17:23:51 +03:30
co-authored by Claude Opus 5
parent 9af763bfbe
commit 252e20bfe9
14 changed files with 892 additions and 5 deletions
+183
View File
@@ -0,0 +1,183 @@
<?php
namespace App\Tests\Treatment;
use App\Appointment\Entity\Appointment;
use App\Appointment\Service\AppointmentConfirmationService;
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\PracticeDomain\Entity\PracticeDomain;
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;
/**
* تأیید نوبتِ سرویسِ پروتکل‌دار باید پروندهٔ درمان باز کند — بدون اینکه مسیر رزرو
* چیزی دربارهٔ لیزر بداند.
*/
class OpenCaseOnConfirmTest extends ApiTestCase
{
private function confirmation(): AppointmentConfirmationService
{
return static::getContainer()->get(AppointmentConfirmationService::class);
}
private function scenario(bool $withProtocol, ?string $domainCode = 'beauty'): array
{
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر آزمون');
$this->em->persist($doctor);
$clinic = new Clinic($this->createUser(['ROLE_CLINIC']));
$clinic->setName('کلینیک دورهٔ درمان');
$clinic->getDoctors()->add($doctor);
$this->em->persist($clinic);
$this->em->flush();
if ($domainCode !== null) {
$domain = $this->em->getRepository(PracticeDomain::class)->findOneBy(['code' => $domainCode])
?? new PracticeDomain($domainCode, 'حوزهٔ آزمون');
$this->em->persist($domain);
$this->em->flush();
$clinic->setPracticeDomain($domain);
}
$section = new ServiceSection('clinic', (int) $clinic->getId(), 'لیزر');
$this->em->persist($section);
$category = new CatalogCategory('clinic', (int) $clinic->getId(), 'دست');
$this->em->persist($category);
$service = new ServiceItem($section, 'لیزر دست', 5_000_000);
$service->setCatalogCategory($category);
$this->em->persist($service);
$this->em->flush();
if ($withProtocol) {
$staff = new ClinicStaff('clinic', (int) $clinic->getId(), 'اپراتور');
$this->em->persist($staff);
$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),
]);
$protocol->replaceAllowedStaff([new TreatmentProtocolStaff($protocol, $staff)]);
$this->em->flush();
}
$appointment = $this->newAppointment(
$doctor,
$this->createUser(['ROLE_USER']),
1_790_000_000,
1_790_001_800,
$clinic,
);
$appointment->addServiceItem($service);
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
$this->em->persist($appointment);
$this->em->flush();
return [$appointment, $service, $clinic];
}
/** @return TreatmentCase[] */
private function casesFor(ServiceItem $service): array
{
return $this->em->getRepository(TreatmentCase::class)->findBy(['serviceItem' => $service]);
}
public function testConfirmingAProtocolServiceOpensACase(): void
{
[$appointment, $service] = $this->scenario(withProtocol: true);
$this->confirmation()->onConfirmed($appointment);
$cases = $this->casesFor($service);
self::assertCount(1, $cases);
self::assertSame(3, $cases[0]->getTotalSessions());
self::assertCount(3, $cases[0]->getSessions());
self::assertSame(['دست'], array_map(
static fn ($a) => $a->getName(),
$cases[0]->getAreas()->toArray(),
));
}
/** سرویس بدون پروتکل همان نوبت تک‌جلسه‌ای است — نباید پرونده‌ای ساخته شود. */
public function testConfirmingAPlainServiceOpensNoCase(): void
{
[$appointment, $service] = $this->scenario(withProtocol: false);
$this->confirmation()->onConfirmed($appointment);
self::assertSame([], $this->casesFor($service));
}
/** کلینیکِ بدون حوزهٔ فعالیت هم باید دوره‌اش کار کند — نال یعنی رفتار پیش‌فرض. */
public function testCaseOpensEvenWithoutAPracticeDomain(): void
{
[$appointment, $service] = $this->scenario(withProtocol: true, domainCode: null);
$this->confirmation()->onConfirmed($appointment);
self::assertCount(1, $this->casesFor($service));
}
public function testFirstSessionIsAttachedToTheAppointment(): void
{
[$appointment, $service] = $this->scenario(withProtocol: true);
$this->confirmation()->onConfirmed($appointment);
$sessions = $this->casesFor($service)[0]->getSessions()->toArray();
usort($sessions, static fn (TreatmentSession $a, TreatmentSession $b): int
=> $a->getSessionNumber() <=> $b->getSessionNumber());
self::assertSame($appointment->getId(), $sessions[0]->getAppointment()?->getId());
self::assertSame(TreatmentSession::STATUS_BOOKED, $sessions[0]->getStatus());
self::assertSame($appointment->getSlotStart(), $sessions[0]->getDueAt());
// جلسات بعدی نه نوبت دارند نه سررسید قطعی — سررسیدشان از جلسهٔ قبل می‌آید.
self::assertNull($sessions[1]->getAppointment());
self::assertNull($sessions[1]->getDueAt());
}
/** بیمار وسط دوره نوبت دیگری می‌گیرد — باید جلسهٔ همان دوره باشد نه دورهٔ موازی. */
public function testASecondAppointmentDoesNotOpenASecondCase(): void
{
[$appointment, $service, $clinic] = $this->scenario(withProtocol: true);
$this->confirmation()->onConfirmed($appointment);
$second = $this->newAppointment(
$appointment->getDoctor(),
$appointment->getUser(),
1_791_000_000,
1_791_001_800,
$clinic,
);
$second->addServiceItem($service);
$second->transitionTo(Appointment::STATUS_CONFIRMED);
$this->em->persist($second);
$this->em->flush();
$this->confirmation()->onConfirmed($second);
$cases = $this->casesFor($service);
self::assertCount(1, $cases);
$sessions = $cases[0]->getSessions()->toArray();
usort($sessions, static fn (TreatmentSession $a, TreatmentSession $b): int
=> $a->getSessionNumber() <=> $b->getSessionNumber());
self::assertSame($second->getId(), $sessions[1]->getAppointment()?->getId());
}
}
+198
View File
@@ -0,0 +1,198 @@
<?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\ClinicService\Service\CategoryClosureResolver;
use App\Patient\Entity\PatientRecord;
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\Repository\TreatmentSessionRepository;
use App\Treatment\Service\TreatmentCaseOpener;
use App\Treatment\Service\TreatmentScheduler;
/**
* سررسید نسبی: فاصله از تاریخ **واقعی** جلسهٔ قبل، نه از شروع دوره.
*/
class TreatmentSchedulerTest extends ApiTestCase
{
private const DAY = 86400;
private TreatmentScheduler $scheduler;
private TreatmentSessionRepository $sessions;
private TreatmentCase $case;
protected function setUp(): void
{
parent::setUp();
$this->sessions = $this->em->getRepository(TreatmentSession::class);
$this->scheduler = new TreatmentScheduler($this->sessions);
}
/** سناریوی بوتاکس: جلسه ۲ بعد از ۱۵ روز، بقیه ماهانه. */
private function botoxCase(): TreatmentCase
{
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($user);
$clinic->setName('کلینیک بوتاکس');
$this->em->persist($clinic);
$this->em->flush();
$section = new ServiceSection('clinic', (int) $clinic->getId(), 'تزریق');
$this->em->persist($section);
$category = new CatalogCategory('clinic', (int) $clinic->getId(), 'پیشانی');
$this->em->persist($category);
$service = new ServiceItem($section, 'بوتاکس پیشانی', 8_000_000);
$service->setCatalogCategory($category);
$this->em->persist($service);
$staff = new ClinicStaff('clinic', (int) $clinic->getId(), 'اپراتور');
$this->em->persist($staff);
$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),
new TreatmentProtocolStep($protocol, 4, 30),
]);
$protocol->replaceAllowedStaff([new TreatmentProtocolStaff($protocol, $staff)]);
$this->em->flush();
$record = new PatientRecord('clinic', (int) $clinic->getId(), $this->createUser(), 'clinic', (int) $clinic->getId());
$this->em->persist($record);
$this->em->flush();
$opener = new TreatmentCaseOpener(
new CategoryClosureResolver($this->em->getRepository(CatalogCategoryInclude::class)),
$this->em->getRepository(CatalogCategory::class),
$this->em,
);
return $opener->open('clinic', (int) $clinic->getId(), $record, $service, $protocol);
}
/** @return TreatmentSession[] */
private function orderedSessions(TreatmentCase $case): array
{
$sessions = $case->getSessions()->toArray();
usort($sessions, static fn (TreatmentSession $a, TreatmentSession $b): int
=> $a->getSessionNumber() <=> $b->getSessionNumber());
return $sessions;
}
private function finish(TreatmentSession $session, int $at): void
{
$session->finish();
// finish() زمان واقعی می‌گذارد؛ تست تاریخ را کنترل‌شده جا می‌اندازد.
$ref = new \ReflectionProperty(TreatmentSession::class, 'finishedAt');
$ref->setValue($session, $at);
$this->em->flush();
}
public function testUnevenIntervalsAreMeasuredFromTheLastFinishedSession(): void
{
$case = $this->botoxCase();
$sessions = $this->orderedSessions($case);
$day1 = 1_800_000_000;
$this->finish($sessions[0], $day1);
$next = $this->scheduler->scheduleNextAfter($sessions[0]);
self::assertSame($sessions[1]->getId(), $next?->getId());
self::assertSame($day1 + 15 * self::DAY, $sessions[1]->getDueAt());
// بیمار ۲۰ روز دیر آمد — جلسهٔ سوم باید از همین تاریخ حساب شود، نه از شروع دوره.
$actual = $sessions[1]->getDueAt() + 20 * self::DAY;
$this->finish($sessions[1], $actual);
$this->scheduler->scheduleNextAfter($sessions[1]);
self::assertSame($actual + 30 * self::DAY, $sessions[2]->getDueAt());
}
/** با لنگر ثابت، بیمارِ دیرآمده جلسهٔ بعدی‌اش را زودتر از موعد می‌گرفت. */
public function testALateSessionShiftsTheRestOfTheCourse(): void
{
$case = $this->botoxCase();
$sessions = $this->orderedSessions($case);
$day1 = 1_800_000_000;
$this->finish($sessions[0], $day1);
$this->scheduler->scheduleNextAfter($sessions[0]);
$late = $day1 + 60 * self::DAY;
$this->finish($sessions[1], $late);
$this->scheduler->scheduleNextAfter($sessions[1]);
self::assertSame($late + 30 * self::DAY, $sessions[2]->getDueAt());
self::assertGreaterThan($day1 + 45 * self::DAY, $sessions[2]->getDueAt());
}
/** فقط جلسهٔ بعدی بازمحاسبه می‌شود؛ جلسات دورتر هنوز لنگری ندارند. */
public function testOnlyTheNextSessionIsRescheduled(): void
{
$case = $this->botoxCase();
$sessions = $this->orderedSessions($case);
$this->finish($sessions[0], 1_800_000_000);
$this->scheduler->scheduleNextAfter($sessions[0]);
self::assertNotNull($sessions[1]->getDueAt());
self::assertNull($sessions[2]->getDueAt());
self::assertNull($sessions[3]->getDueAt());
}
/** غیبت جلسه را نمی‌سوزاند: تعداد جلسات ثابت می‌ماند و همان جلسه دوباره برنامه‌ریزی می‌شود. */
public function testNoShowKeepsTheSessionAndTheTotal(): void
{
$case = $this->botoxCase();
$sessions = $this->orderedSessions($case);
$this->finish($sessions[0], 1_800_000_000);
$this->scheduler->scheduleNextAfter($sessions[0]);
$sessions[1]->markNoShow();
$this->em->flush();
self::assertSame(4, $case->getTotalSessions());
self::assertCount(4, $case->getSessions());
self::assertSame(TreatmentSession::STATUS_NO_SHOW, $sessions[1]->getStatus());
// همان جلسه دوباره در صف رزرو می‌آید، نه جلسه‌ای تازه.
self::assertSame($sessions[1]->getId(), $this->sessions->findNextUnbooked($case)?->getId());
}
public function testLastSessionSchedulesNothing(): void
{
$case = $this->botoxCase();
$sessions = $this->orderedSessions($case);
foreach ($sessions as $index => $session) {
$this->finish($session, 1_800_000_000 + $index * 30 * self::DAY);
}
self::assertNull($this->scheduler->scheduleNextAfter($sessions[3]));
}
public function testUnfinishedSessionSchedulesNothing(): void
{
$case = $this->botoxCase();
$sessions = $this->orderedSessions($case);
self::assertNull($this->scheduler->scheduleNextAfter($sessions[0]));
self::assertNull($sessions[1]->getDueAt());
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
namespace App\Tests\Treatment;
use App\PracticeDomain\Entity\PracticeDomain;
use App\Tests\ApiTestCase;
use App\Treatment\Workflow\DefaultTreatmentWorkflow;
use App\Treatment\Workflow\LaserTreatmentWorkflow;
use App\Treatment\Workflow\TreatmentWorkflowRegistry;
class TreatmentWorkflowTest extends ApiTestCase
{
private TreatmentWorkflowRegistry $registry;
protected function setUp(): void
{
parent::setUp();
$this->registry = static::getContainer()->get(TreatmentWorkflowRegistry::class);
}
public function testBeautyResolvesToTheLaserWorkflow(): void
{
self::assertInstanceOf(LaserTreatmentWorkflow::class, $this->registry->for('beauty'));
}
/** حوزه‌ای که هنوز پیاده‌سازی ندارد باید رفتار پیش‌فرض بگیرد، نه خطا. */
public function testUnknownDomainFallsBackToTheDefaultWorkflow(): void
{
self::assertSame(
DefaultTreatmentWorkflow::class,
$this->registry->for('physiotherapy')::class,
);
}
/** نال یعنی «حوزه تنظیم نشده» و همان رفتار امروز، نه خطا. */
public function testNullDomainFallsBackToTheDefaultWorkflow(): void
{
self::assertSame(DefaultTreatmentWorkflow::class, $this->registry->for(null)::class);
}
public function testHasDedicatedWorkflowDistinguishesTheTwo(): void
{
self::assertTrue($this->registry->hasDedicatedWorkflow('beauty'));
self::assertFalse($this->registry->hasDedicatedWorkflow('physiotherapy'));
self::assertFalse($this->registry->hasDedicatedWorkflow(null));
}
/** پنل ادمین باید ببیند کدام حوزه هنوز workflow ندارد. */
public function testListExposesHasWorkflow(): void
{
$suffix = bin2hex(random_bytes(4));
foreach ([['beauty_' . $suffix, false], ['beauty', true]] as [$code, $expected]) {
if ($this->em->getRepository(PracticeDomain::class)->findOneBy(['code' => $code]) === null) {
$this->em->persist(new PracticeDomain($code, 'حوزهٔ آزمون'));
}
}
$this->em->flush();
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
$body = $this->authJson('GET', '/api/v1/practice-domains', $admin);
$byCode = array_column($body['data'], 'has_workflow', 'code');
self::assertTrue($byCode['beauty']);
self::assertFalse($byCode['beauty_' . $suffix]);
}
}