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:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user