feat(treatment): select treatment behaviour by practice domain, not by if-branch

Everything that differs between specialties as data is already stored as data.
What is left is behaviour — when a case opens, what happens once a session ends —
so it becomes a TreatmentWorkflow resolved through a tagged-service registry.
The booking path calls one collaborator and never names a specialty; adding
dentistry is a new class, not an edit to confirmation.

A clinic that has chosen no practice domain still gets working multi-session
courses: DefaultTreatmentWorkflow answers for null and for any code without a
dedicated implementation, keeping "unset means behave as today, not error".
LaserTreatmentWorkflow is deliberately empty beyond claiming `beauty` — it is the
seam where laser-specific behaviour will land without disturbing anyone else.

Session due dates are anchored to the previous session's actual finish, so a
patient who comes twenty days late shifts the rest of their course instead of
getting the next session while it can still do nothing. Only the next session is
recomputed; later ones keep their estimate because they are anchored to nothing
yet.

Attachment targets the first session without an appointment rather than the
first open one: a patient booking again mid-course was otherwise matched to the
session that already had a booking, and the second appointment went nowhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-06 17:23:51 +03:30
co-authored by Claude Opus 5
parent 9af763bfbe
commit 252e20bfe9
14 changed files with 892 additions and 5 deletions
@@ -0,0 +1,97 @@
<?php
namespace App\Treatment\Workflow;
use App\Appointment\Entity\Appointment;
use App\ClinicService\Entity\ServiceItem;
use App\Patient\Entity\PatientRecord;
use App\Treatment\Entity\TreatmentCase;
use App\Treatment\Entity\TreatmentProtocol;
use App\Treatment\Entity\TreatmentSession;
use App\Treatment\Repository\TreatmentCaseRepository;
use App\Treatment\Repository\TreatmentSessionRepository;
use App\Treatment\Service\TreatmentCaseOpener;
use App\Treatment\Service\TreatmentScheduler;
use Doctrine\ORM\EntityManagerInterface;
/**
* رفتار دورهٔ درمان وقتی حوزهٔ فعالیت پیاده‌سازی اختصاصی ندارد.
*
* عمداً پایین‌ترین اولویت را دارد و به هر کدی جواب مثبت می‌دهد، پس محیطی که هنوز
* حوزه‌اش را انتخاب نکرده هم دورهٔ چندجلسه‌ای‌اش کار می‌کند — تصمیمِ «نال یعنی رفتار
* امروز، نه خطا».
*/
class DefaultTreatmentWorkflow implements TreatmentWorkflow
{
public function __construct(
protected readonly TreatmentCaseRepository $cases,
protected readonly TreatmentSessionRepository $sessions,
protected readonly TreatmentCaseOpener $opener,
protected readonly TreatmentScheduler $scheduler,
protected readonly EntityManagerInterface $em,
) {}
public function supports(?string $practiceDomainCode): bool
{
return true;
}
public function openCase(
Appointment $appointment,
PatientRecord $record,
ServiceItem $service,
TreatmentProtocol $protocol,
): TreatmentCase {
// بیماری که وسط دوره‌اش نوبت دیگری از همان سرویس می‌گیرد، جلسهٔ همان دوره را
// می‌گیرد نه یک دورهٔ موازی.
$case = $this->cases->findOpenFor($record, $service)
?? $this->opener->open(
$record->getEntityType(),
$record->getEntityId(),
$record,
$service,
$protocol,
);
$this->attachToNextOpenSession($case, $appointment);
return $case;
}
public function onSessionFinished(TreatmentSession $session): void
{
$next = $this->scheduler->scheduleNextAfter($session);
if ($next === null) {
$this->closeIfEverySessionIsSettled($session->getTreatmentCase());
}
$this->em->flush();
}
/** نوبت به اولین جلسهٔ بدونِ نوبت وصل می‌شود، نه لزوماً به جلسهٔ اول. */
protected function attachToNextOpenSession(TreatmentCase $case, Appointment $appointment): void
{
$session = $this->sessions->findNextUnbooked($case);
if ($session === null) {
return;
}
$session->attachAppointment($appointment);
// سررسیدِ جلسه‌ای که نوبت گرفته دیگر حدس نیست — همان ساعت نوبت است.
$this->scheduler->seedFirstSession($session, $appointment->getSlotStart());
$this->em->flush();
}
protected function closeIfEverySessionIsSettled(TreatmentCase $case): void
{
foreach ($case->getSessions() as $session) {
if (!in_array($session->getStatus(), [TreatmentSession::STATUS_DONE, TreatmentSession::STATUS_CANCELLED], true)) {
return;
}
}
$case->close(TreatmentCase::STATUS_COMPLETED);
}
}