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
@@ -47,6 +47,27 @@ class TreatmentSessionRepository extends ServiceEntityRepository
->getOneOrNullResult();
}
/**
* اولین جلسه‌ای که هنوز نوبتی ندارد.
*
* جدا از {@see self::findNextOpen()} است چون آن جلسهٔ رزروشده را هم «باز» می‌داند:
* بیماری که وسط دوره نوبت دوم می‌گیرد باید به جلسهٔ **بعدی** بچسبد، نه به جلسه‌ای
* که همین حالا نوبت دارد.
*/
public function findNextUnbooked(TreatmentCase $case): ?TreatmentSession
{
return $this->createQueryBuilder('s')
->where('s.treatmentCase = :case')
->andWhere('s.appointment IS NULL')
->andWhere('s.status IN (:open)')
->setParameter('case', $case)
->setParameter('open', [TreatmentSession::STATUS_PLANNED, TreatmentSession::STATUS_NO_SHOW])
->orderBy('s.sessionNumber', 'ASC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
/** جلسهٔ انجام‌شدهٔ قبلی — لنگرِ محاسبهٔ سررسید جلسهٔ بعد. */
public function findLastFinishedBefore(TreatmentCase $case, int $sessionNumber): ?TreatmentSession
{