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;
}
}