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
@@ -30,6 +30,7 @@ class AppointmentConfirmationService
private readonly PaymentRepository $paymentRepo,
private readonly CommissionService $commissionService,
private readonly DomainContextResolver $domainResolver,
private readonly \App\Treatment\Service\TreatmentCaseStarter $treatmentCases,
private readonly EntityManagerInterface $em,
private readonly LoggerInterface $logger,
) {}
@@ -72,7 +73,15 @@ class AppointmentConfirmationService
$this->splitPaymentShares($appointment);
try {
return $this->patientService->autoCreateOnAppointmentConfirm($appointment);
$session = $this->patientService->autoCreateOnAppointmentConfirm($appointment);
// هسته نمی‌داند دورهٔ درمان چیست: اگر سرویسِ نوبت پروتکل داشته باشد،
// workflowِ حوزهٔ فعالیت تصمیم می‌گیرد چه اتفاقی بیفتد.
if ($session !== null) {
$this->treatmentCases->onAppointmentConfirmed($appointment, $session);
}
return $session;
} catch (\Throwable $e) {
$this->logger->error('Auto-creating the patient record on confirm failed', [
'appointment_uuid' => $appointment->getUuid(),
@@ -5,6 +5,7 @@ namespace App\PracticeDomain\Controller;
use App\PracticeDomain\Entity\PracticeDomain;
use App\PracticeDomain\Repository\PracticeDomainRepository;
use App\Shared\Constant\ErrorCodes;
use App\Treatment\Workflow\TreatmentWorkflowRegistry;
use App\Shared\Controller\BaseController;
use Doctrine\ORM\EntityManagerInterface;
use OpenApi\Attributes as OA;
@@ -19,6 +20,7 @@ class PracticeDomainController extends BaseController
{
public function __construct(
private readonly PracticeDomainRepository $domains,
private readonly TreatmentWorkflowRegistry $workflows,
private readonly EntityManagerInterface $em,
) {}
@@ -34,7 +36,7 @@ class PracticeDomainController extends BaseController
$includeInactive = $this->isGranted('ROLE_ADMIN');
return $this->success(array_map(
static fn (PracticeDomain $d): array => $d->toArray(),
fn (PracticeDomain $d): array => $d->toArray($this->workflows->hasDedicatedWorkflow($d->getCode())),
$this->domains->findOrdered($includeInactive),
));
}
@@ -73,7 +75,7 @@ class PracticeDomainController extends BaseController
$this->em->persist($domain);
$this->em->flush();
return $this->success($domain->toArray(), 201);
return $this->success($domain->toArray($this->workflows->hasDedicatedWorkflow($domain->getCode())), 201);
}
#[Route('/api/v1/practice-domain/{uuid}', name: 'practice_domain_update', methods: ['PATCH'])]
@@ -108,6 +110,6 @@ class PracticeDomainController extends BaseController
$this->em->flush();
return $this->success($domain->toArray());
return $this->success($domain->toArray($this->workflows->hasDedicatedWorkflow($domain->getCode())));
}
}
@@ -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
{
@@ -0,0 +1,64 @@
<?php
namespace App\Treatment\Service;
use App\Appointment\Entity\Appointment;
use App\Patient\Entity\PatientSession;
use App\Treatment\Entity\TreatmentCase;
use App\Treatment\Repository\TreatmentProtocolRepository;
use App\Treatment\Workflow\TreatmentWorkflowRegistry;
use Psr\Log\LoggerInterface;
/**
* پلِ بین «نوبت تأیید شد» و «دورهٔ درمان».
*
* تنها چیزی که مسیر رزرو از دنیای درمان می‌بیند همین کلاس است: سرویس پروتکل دارد یا
* نه، و اگر دارد کارِ باز کردن پرونده به workflow همان حوزهٔ فعالیت سپرده می‌شود. هستهٔ
* رزرو نه نام لیزر را می‌داند نه ساختار پرونده را.
*/
final class TreatmentCaseStarter
{
public function __construct(
private readonly TreatmentProtocolRepository $protocols,
private readonly TreatmentWorkflowRegistry $workflows,
private readonly LoggerInterface $logger,
) {}
/**
* `null` یعنی این نوبت دوره‌ای نیست — سرویس ندارد، یا سرویسش پروتکل فعال ندارد.
*/
public function onAppointmentConfirmed(Appointment $appointment, PatientSession $session): ?TreatmentCase
{
$service = $appointment->getServiceItem();
if ($service === null) {
return null;
}
$protocol = $this->protocols->findActiveForService($service);
if ($protocol === null) {
return null;
}
$domainCode = $appointment->getClinic()?->getPracticeDomain()?->getCode();
try {
return $this->workflows->for($domainCode)->openCase(
$appointment,
$session->getRecord(),
$service,
$protocol,
);
} catch (\Throwable $e) {
// همان قاعدهٔ AppointmentConfirmationService: نوبت رزرو شده و پول پرداخت
// شده؛ شکستِ ساخت پرونده نباید آن را برگرداند.
$this->logger->error('Opening the treatment case on confirm failed', [
'appointment_uuid' => $appointment->getUuid(),
'exception' => $e,
]);
return null;
}
}
}
@@ -0,0 +1,78 @@
<?php
namespace App\Treatment\Service;
use App\Treatment\Entity\TreatmentCase;
use App\Treatment\Entity\TreatmentProtocolStep;
use App\Treatment\Entity\TreatmentSession;
use App\Treatment\Repository\TreatmentSessionRepository;
/**
* سررسید جلسهٔ بعد.
*
* فاصله از تاریخ **واقعی** جلسهٔ قبل حساب می‌شود، نه از شروع دوره: مو بعد از درمان
* قبلی رشد می‌کند. با لنگرِ ثابت، بیمارِ بیست‌روز دیرآمده جلسهٔ بعدی‌اش را ده روز بعد
* می‌گرفت و آن جلسه بی‌اثر بود.
*
* فقط جلسهٔ **بعدی** بازمحاسبه می‌شود. جلسات دورتر حدسِ قبلی‌شان را نگه می‌دارند تا
* نوبتشان برسد — عددی که هنوز به هیچ واقعیتی گره نخورده، بازمحاسبه‌اش دقیق‌ترش نمی‌کند.
*/
final class TreatmentScheduler
{
private const DAY = 86400;
public function __construct(private readonly TreatmentSessionRepository $sessions) {}
/**
* سررسید جلسه‌ای که بعد از این جلسه می‌آید را به‌روز می‌کند.
*
* @return TreatmentSession|null جلسه‌ای که سررسیدش عوض شد، یا null اگر دوره تمام شده
*/
public function scheduleNextAfter(TreatmentSession $finished): ?TreatmentSession
{
$anchor = $finished->getFinishedAt();
if ($anchor === null) {
return null;
}
$next = $this->sessions->findNextOpen($finished->getTreatmentCase());
if ($next === null || $next->getSessionNumber() <= $finished->getSessionNumber()) {
return null;
}
$offset = $this->offsetFor($finished->getTreatmentCase(), $next->getSessionNumber());
if ($offset === null) {
return null;
}
$next->setDueAt($anchor + $offset * self::DAY);
return $next;
}
/**
* سررسید جلسه‌ای که هنوز لنگری ندارد.
*
* برای جلسهٔ اول لنگر خودِ نوبت است؛ برای بقیه تا انجام‌شدن جلسهٔ قبل حدس می‌ماند.
*/
public function seedFirstSession(TreatmentSession $first, int $startsAt): void
{
$first->setDueAt($startsAt);
}
/** فاصلهٔ گامِ شمارهٔ n از جلسهٔ قبلی، طبق پروتکلِ همان پرونده. */
private function offsetFor(TreatmentCase $case, int $sessionNumber): ?int
{
foreach ($case->getProtocol()->getSteps() as $step) {
/** @var TreatmentProtocolStep $step */
if ($step->getStepNumber() === $sessionNumber) {
return $step->getOffsetDays();
}
}
return null;
}
}
@@ -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);
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Treatment\Workflow;
use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem;
/**
* کلینیک زیبایی.
*
* امروز رفتارش دقیقاً پیش‌فرض است و این عمدی است: جدا نگه‌داشتنش نقطهٔ اتصالِ آینده را
* می‌سازد — «بعد از جلسهٔ چهارم عکس قبل و بعد بگیر» جایی برای نشستن دارد بدون اینکه
* رفتار بقیهٔ حوزه‌ها عوض شود.
*
* اولویت بالاتر از پیش‌فرض دارد چون `DefaultTreatmentWorkflow::supports()` به همه‌چیز
* بله می‌گوید و registry اولین تطابق را برمی‌دارد.
*/
#[AsTaggedItem('app.treatment_workflow', priority: 100)]
final class LaserTreatmentWorkflow extends DefaultTreatmentWorkflow
{
public const PRACTICE_DOMAIN = 'beauty';
public function supports(?string $practiceDomainCode): bool
{
return $practiceDomainCode === self::PRACTICE_DOMAIN;
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Treatment\Workflow;
use App\Appointment\Entity\Appointment;
use App\Patient\Entity\PatientRecord;
use App\ClinicService\Entity\ServiceItem;
use App\Treatment\Entity\TreatmentCase;
use App\Treatment\Entity\TreatmentProtocol;
use App\Treatment\Entity\TreatmentSession;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
/**
* رفتارِ مخصوصِ یک حوزهٔ فعالیت.
*
* هرچه بین تخصص‌ها **داده** است — دسته‌بندی، گام‌های دوره، نوع منابع، فیلدهای فرم —
* از قبل داده ذخیره می‌شود. آنچه می‌ماند رفتار است: کِی پرونده باز شود و بعد از بستن
* جلسه چه اتفاقی بیفتد. همین را اینجا می‌گذاریم تا افزودن دندانپزشکی یک کلاس تازه
* باشد، نه دست‌بردن در مسیر رزرو.
*
* موتور داده‌محور عمداً انتخاب نشد: هیچ مدیر کلینیکی state machine رسم نمی‌کند، پس در
* عمل فقط برنامه‌نویس از آن استفاده می‌کرد — یعنی کد، با نحو بدتر و بدون type safety.
*/
#[AutoconfigureTag('app.treatment_workflow')]
interface TreatmentWorkflow
{
/** `null` یعنی محیط حوزهٔ فعالیت انتخاب نکرده — همان رفتار پیش‌فرض. */
public function supports(?string $practiceDomainCode): bool;
/**
* پس از تأیید نوبتی که سرویسش پروتکل فعال دارد.
*
* پیاده‌سازی باید پروندهٔ باز موجود را برگرداند و پروندهٔ دوم نسازد.
*/
public function openCase(
Appointment $appointment,
PatientRecord $record,
ServiceItem $service,
TreatmentProtocol $protocol,
): TreatmentCase;
/** پس از بسته شدن یک جلسه — جای محاسبهٔ سررسید جلسهٔ بعد و بستن دوره. */
public function onSessionFinished(TreatmentSession $session): void;
}
@@ -0,0 +1,47 @@
<?php
namespace App\Treatment\Workflow;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
/**
* انتخاب workflow بر اساس کدِ حوزهٔ فعالیت.
*
* ترتیب از اولویتِ tag می‌آید و اولین تطابق برنده است، پس پیاده‌سازی اختصاصی جلوتر از
* `DefaultTreatmentWorkflow` می‌نشیند. هستهٔ رزرو فقط این کلاس را می‌شناسد و هیچ‌جا
* کلمهٔ «لیزر» را نمی‌بیند.
*/
final class TreatmentWorkflowRegistry
{
/** @param iterable<TreatmentWorkflow> $workflows */
public function __construct(
#[AutowireIterator('app.treatment_workflow')]
private readonly iterable $workflows,
) {}
public function for(?string $practiceDomainCode): TreatmentWorkflow
{
foreach ($this->workflows as $workflow) {
if ($workflow->supports($practiceDomainCode)) {
return $workflow;
}
}
throw new \LogicException(sprintf(
'No treatment workflow supports "%s"; DefaultTreatmentWorkflow should have caught it.',
$practiceDomainCode ?? 'null',
));
}
/**
* آیا این حوزه پیاده‌سازی اختصاصی دارد، یا فقط رفتار پیش‌فرض می‌گیرد؟
*
* پنل ادمین پلتفرم با همین کنار هر حوزه نشان می‌دهد که هنوز workflow ندارد — حوزهٔ
* بی‌workflow کار می‌کند ولی رفتار اختصاصی ندارد، و این باید دیده شود نه حدس زده.
*/
public function hasDedicatedWorkflow(?string $practiceDomainCode): bool
{
return $practiceDomainCode !== null
&& $this->for($practiceDomainCode)::class !== DefaultTreatmentWorkflow::class;
}
}