Files
clinicpro/tests/Treatment/TreatmentPlanProjectorTest.php
T
hamedandClaude Opus 5 50d82279d6 feat(treatment): project a whole course's calendar without storing it
TreatmentScheduler deliberately writes only the next session's due_at, because a
date not yet anchored to anything real is a false claim about the future and has
to be rewritten every time a patient runs late. But the panel still needs to
show the whole course.

TreatmentPlanProjector builds that chain at display time and writes nothing.
Each date carries is_estimate so a projection is never mistaken for a fact. A
session's anchor is, in order: when it was finished, when its appointment is, or
its written due_at; a case with none falls back to when it was opened, so a
course that has not been booked yet still shows dates instead of blanks.

Read and write stay in separate classes — mixing them risks storing an estimate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:08:19 +03:30

153 lines
5.8 KiB
PHP

<?php
namespace App\Tests\Treatment;
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\TreatmentProtocol;
use App\Treatment\Entity\TreatmentProtocolStep;
use App\Treatment\Entity\TreatmentSession;
use App\Treatment\Service\TreatmentPlanProjector;
/**
* تقویم کل دوره — نمای مشتق. هیچ‌چیز نباید ذخیره شود.
*/
class TreatmentPlanProjectorTest extends ApiTestCase
{
private const DAY = 86400;
/** بدون وابستگی است؛ ساختنش مستقیم، تست را به container گره نمی‌زند. */
private function projector(): TreatmentPlanProjector
{
return new TreatmentPlanProjector();
}
/** پروتکل چهارگامی: ۰ / ۱۵ / ۳۰ / ۳۰ روز. */
private function scenario(): TreatmentCase
{
$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);
$category = new CatalogCategory('clinic', (int) $clinic->getId(), 'دست');
$this->em->persist($category);
$service = new ServiceItem($section, 'لیزر تقویم', 3_000_000);
$service->setCatalogCategory($category);
$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),
new TreatmentProtocolStep($protocol, 4, 30),
]);
$this->em->flush();
$patient = $this->createUser(['ROLE_USER']);
$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);
foreach ([1, 2, 3, 4] as $n) {
$case->addSession(new TreatmentSession($case, $n));
}
$this->em->flush();
return $case;
}
/** @return TreatmentSession[] */
private function ordered(TreatmentCase $case): array
{
$s = $case->getSessions()->toArray();
usort($s, static fn (TreatmentSession $a, TreatmentSession $b): int
=> $a->getSessionNumber() <=> $b->getSessionNumber());
return array_values($s);
}
/**
* حالت موفق: جلسهٔ تمام‌شده لنگر است و بقیه از روی آن زنجیره می‌شوند.
*/
public function testFinishedSessionAnchorsTheRest(): void
{
$case = $this->scenario();
$sessions = $this->ordered($case);
$finishedAt = 1_800_000_000;
$sessions[0]->start();
$sessions[0]->finish();
// `finish()` زمان جاری می‌گذارد؛ برای تست لنگر قطعی لازم است.
$sessions[1]->setDueAt($finishedAt + 15 * self::DAY);
$this->em->flush();
$plan = $this->projector()->project($case);
self::assertCount(4, $plan);
self::assertFalse($plan[0]['is_estimate'], 'جلسهٔ انجام‌شده تخمین نیست');
self::assertFalse($plan[1]['is_estimate'], 'جلسه‌ای که due_at دارد تخمین نیست');
// جلسات ۳ و ۴ لنگری ندارند → تخمین، با فاصلهٔ گام خودشان از جلسهٔ قبل
self::assertTrue($plan[2]['is_estimate']);
self::assertTrue($plan[3]['is_estimate']);
self::assertSame($plan[1]['planned_at'] + 30 * self::DAY, $plan[2]['planned_at']);
self::assertSame($plan[2]['planned_at'] + 30 * self::DAY, $plan[3]['planned_at']);
}
/**
* حالت مرزی: دوره‌ای که هیچ جلسهٔ قطعی ندارد — لنگر خودِ بازشدنِ پرونده است،
* وگرنه کارت‌ها بی‌تاریخ می‌مانند.
*/
public function testACaseWithNoAnchorFallsBackToOpenedAt(): void
{
$case = $this->scenario();
$plan = $this->projector()->project($case);
self::assertCount(4, $plan);
foreach ($plan as $row) {
self::assertTrue($row['is_estimate']);
self::assertNotNull($row['planned_at']);
}
// گام اول فاصلهٔ صفر دارد، پس روی خودِ openedAt می‌نشیند.
self::assertSame($case->getOpenedAt(), $plan[0]['planned_at']);
self::assertSame($case->getOpenedAt() + 15 * self::DAY, $plan[1]['planned_at']);
}
/** هیچ‌چیز ذخیره نمی‌شود: `due_at` جلسات بی‌لنگر بعد از projection هنوز null است. */
public function testProjectionNeverWritesDueAt(): void
{
$case = $this->scenario();
$sessions = $this->ordered($case);
$this->projector()->project($case);
$this->em->flush();
$this->em->clear();
$reloaded = $this->em->getRepository(TreatmentSession::class)->find($sessions[3]->getId());
self::assertNotNull($reloaded);
self::assertNull($reloaded->getDueAt(), 'projection نباید سررسید بنویسد');
}
}