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:
@@ -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 نباید سررسید بنویسد');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user