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>
153 lines
5.8 KiB
PHP
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 نباید سررسید بنویسد');
|
|
}
|
|
}
|