From 50d82279d6e98423203dc3e227c4af07e20daec8 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 7 Aug 2026 16:08:19 +0330 Subject: [PATCH] feat(treatment): project a whole course's calendar without storing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Service/TreatmentPlanProjector.php | 90 +++++++++++ .../Treatment/TreatmentPlanProjectorTest.php | 152 ++++++++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 src/Treatment/Service/TreatmentPlanProjector.php create mode 100644 tests/Treatment/TreatmentPlanProjectorTest.php diff --git a/src/Treatment/Service/TreatmentPlanProjector.php b/src/Treatment/Service/TreatmentPlanProjector.php new file mode 100644 index 00000000..66a3745b --- /dev/null +++ b/src/Treatment/Service/TreatmentPlanProjector.php @@ -0,0 +1,90 @@ + + */ + 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 شمارهٔ گام => روز + */ + private function offsets(TreatmentCase $case): array + { + $out = []; + + foreach ($case->getProtocol()->getSteps() as $step) { + /** @var TreatmentProtocolStep $step */ + $out[$step->getStepNumber()] = $step->getOffsetDays(); + } + + return $out; + } +} diff --git a/tests/Treatment/TreatmentPlanProjectorTest.php b/tests/Treatment/TreatmentPlanProjectorTest.php new file mode 100644 index 00000000..88806810 --- /dev/null +++ b/tests/Treatment/TreatmentPlanProjectorTest.php @@ -0,0 +1,152 @@ +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 نباید سررسید بنویسد'); + } +}