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>
This commit is contained in:
hamed
2026-08-07 16:08:19 +03:30
co-authored by Claude Opus 5
parent 250e0b0813
commit 50d82279d6
2 changed files with 242 additions and 0 deletions
@@ -0,0 +1,90 @@
<?php
namespace App\Treatment\Service;
use App\Treatment\Entity\TreatmentCase;
use App\Treatment\Entity\TreatmentProtocolStep;
use App\Treatment\Entity\TreatmentSession;
/**
* تقویمِ کل یک دوره — نمای مشتق، نه دادهٔ ذخیره‌شده.
*
* `TreatmentScheduler` عمداً فقط سررسید جلسهٔ **بعدی** را می‌نویسد و بقیه `null`
* می‌مانند: عددی که هنوز به هیچ واقعیتی گره نخورده، ذخیره‌کردنش یک ادعای غلط دربارهٔ
* آینده است و با هر تأخیر بیمار باید کل زنجیره بازنویسی شود.
*
* ولی پنل باید کل دوره را نشان بدهد. پس اینجا همان زنجیره **در لحظهٔ نمایش** ساخته
* می‌شود و هیچ‌چیز نوشته نمی‌شود. هر تاریخ برچسب `is_estimate` دارد تا تخمین با
* واقعیت اشتباه گرفته نشود.
*
* این کلاس فقط می‌خواند؛ `TreatmentScheduler` فقط می‌نویسد. قاطی کردنشان یعنی یک کلاس
* با دو مسئولیت و ریسک اینکه تخمین اشتباهی ذخیره شود.
*/
final class TreatmentPlanProjector
{
private const DAY = 86400;
/**
* @return list<array{session: TreatmentSession, planned_at: ?int, is_estimate: bool}>
*/
public function project(TreatmentCase $case): array
{
$sessions = $case->getSessions()->toArray();
usort($sessions, static fn (TreatmentSession $a, TreatmentSession $b): int
=> $a->getSessionNumber() <=> $b->getSessionNumber());
$offsets = $this->offsets($case);
// لنگر = آخرین زمانِ قطعیِ دیده‌شده. تا وقتی هیچ جلسه‌ای قطعی نشده، خودِ
// بازشدنِ پرونده لنگر است — وگرنه دوره‌ای که هنوز نوبت نگرفته هیچ تاریخی
// نشان نمی‌دهد و کارت‌ها خالی می‌آیند.
$anchor = $case->getOpenedAt();
$out = [];
foreach ($sessions as $session) {
$actual = $this->actualTimeOf($session);
if ($actual !== null) {
$anchor = $actual;
$out[] = ['session' => $session, 'planned_at' => $actual, 'is_estimate' => false];
continue;
}
$anchor += ($offsets[$session->getSessionNumber()] ?? 0) * self::DAY;
$out[] = ['session' => $session, 'planned_at' => $anchor, 'is_estimate' => true];
}
return $out;
}
/**
* زمانِ قطعیِ یک جلسه، یا `null` اگر هنوز به هیچ واقعیتی گره نخورده.
*
* ترتیب عمدی است: انجام‌شده > نوبت‌گرفته > سررسیدِ نوشته‌شده. جلسه‌ای که انجام شده
* زمان واقعی دارد و نوبتش دیگر مهم نیست.
*/
private function actualTimeOf(TreatmentSession $session): ?int
{
return $session->getFinishedAt()
?? $session->getAppointment()?->getSlotStart()
?? $session->getDueAt();
}
/**
* فاصلهٔ هر گام از گام قبلی، طبق پروتکلِ همان پرونده.
*
* @return array<int, int> شمارهٔ گام => روز
*/
private function offsets(TreatmentCase $case): array
{
$out = [];
foreach ($case->getProtocol()->getSteps() as $step) {
/** @var TreatmentProtocolStep $step */
$out[$step->getStepNumber()] = $step->getOffsetDays();
}
return $out;
}
}
@@ -0,0 +1,152 @@
<?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 نباید سررسید بنویسد');
}
}