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
+7 -1
View File
@@ -31,12 +31,18 @@ be able to switch them back on.
"code": "beauty",
"name": "کلینیک زیبایی",
"sort_order": 0,
"active": true
"active": true,
"has_workflow": true
}
]
}
```
`has_workflow` می‌گوید آیا پیاده‌سازیِ `TreatmentWorkflow` مخصوصِ این کد ثبت شده یا حوزه
فقط رفتار پیش‌فرض می‌گیرد. حوزهٔ بی‌workflow کار می‌کند — دوره‌های چندجلسه‌ای‌اش باز
می‌شوند — ولی رفتار اختصاصی ندارد، و پنل ادمین پلتفرم باید همین را نشان دهد نه اینکه
مدیر حدس بزند.
**Errors:**
| Code | HTTP | توضیح |
|------|------|-------|
+43
View File
@@ -130,6 +130,49 @@ Real 422 responses:
---
## Opening a case — what happens on confirm
There is no endpoint that opens a treatment case; it happens as a side effect of confirming an
appointment, in `AppointmentConfirmationService::onConfirmed`:
```
نوبت تأیید شد
→ PatientSession ساخته می‌شود (مالی، مثل همیشه)
→ اگر سرویسِ نوبت پروتکل فعال دارد:
TreatmentWorkflowRegistry::for(clinic.practice_domain.code)->openCase(...)
```
The booking core never names a specialty. A `TreatmentWorkflow` is selected by the clinic's practice
domain code through a tagged-service registry, so adding dentistry is a new class rather than a
change in the booking path. `LaserTreatmentWorkflow` handles `beauty`;
`DefaultTreatmentWorkflow` answers for everything else, including a clinic that has chosen no domain
at all — `null` means "behave as today", never an error.
What opening a case does:
| | |
|---|---|
| پروندهٔ باز موجود | برگردانده می‌شود؛ پروندهٔ دوم برای همان بیمار و همان سرویس ساخته نمی‌شود |
| نواحی | برگ‌های دستهٔ سرویس، با نامشان، در همان لحظه کپی می‌شوند |
| جلسات | همهٔ گام‌های پروتکل ساخته می‌شوند، همه `planned` |
| نوبت | به **اولین جلسهٔ بدون نوبت** می‌چسبد و آن جلسه `booked` می‌شود |
| سررسید | جلسهٔ رزروشده ساعت نوبت را می‌گیرد؛ بقیه `null` می‌مانند |
Failure to open a case is logged and swallowed — the appointment is booked and possibly paid for, and
losing that is worse than losing the case file, which can be rebuilt.
### Session due dates
`due_at` of session *n* is `finished_at` of session *n1* plus that step's `offset_days`. Only the
**next** session is recomputed when one finishes; sessions further out keep their earlier estimate,
because a number that is not yet anchored to anything real does not get more accurate by being
recalculated.
A no-show does not burn the session: its status becomes `no_show`, its appointment link is cleared,
`total_sessions` is untouched, and the same session comes back to the front of the booking queue.
---
## DELETE `/api/v1/service-item/{uuid}/treatment-protocol`
Turn the switch off — the protocol, its steps and its staff list are removed and the service is
@@ -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;
}
}
+183
View File
@@ -0,0 +1,183 @@
<?php
namespace App\Tests\Treatment;
use App\Appointment\Entity\Appointment;
use App\Appointment\Service\AppointmentConfirmationService;
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\PracticeDomain\Entity\PracticeDomain;
use App\Staff\Entity\ClinicStaff;
use App\Tests\ApiTestCase;
use App\Treatment\Entity\TreatmentCase;
use App\Treatment\Entity\TreatmentProtocol;
use App\Treatment\Entity\TreatmentProtocolStaff;
use App\Treatment\Entity\TreatmentProtocolStep;
use App\Treatment\Entity\TreatmentSession;
/**
* تأیید نوبتِ سرویسِ پروتکل‌دار باید پروندهٔ درمان باز کند — بدون اینکه مسیر رزرو
* چیزی دربارهٔ لیزر بداند.
*/
class OpenCaseOnConfirmTest extends ApiTestCase
{
private function confirmation(): AppointmentConfirmationService
{
return static::getContainer()->get(AppointmentConfirmationService::class);
}
private function scenario(bool $withProtocol, ?string $domainCode = 'beauty'): array
{
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر آزمون');
$this->em->persist($doctor);
$clinic = new Clinic($this->createUser(['ROLE_CLINIC']));
$clinic->setName('کلینیک دورهٔ درمان');
$clinic->getDoctors()->add($doctor);
$this->em->persist($clinic);
$this->em->flush();
if ($domainCode !== null) {
$domain = $this->em->getRepository(PracticeDomain::class)->findOneBy(['code' => $domainCode])
?? new PracticeDomain($domainCode, 'حوزهٔ آزمون');
$this->em->persist($domain);
$this->em->flush();
$clinic->setPracticeDomain($domain);
}
$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, 'لیزر دست', 5_000_000);
$service->setCatalogCategory($category);
$this->em->persist($service);
$this->em->flush();
if ($withProtocol) {
$staff = new ClinicStaff('clinic', (int) $clinic->getId(), 'اپراتور');
$this->em->persist($staff);
$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),
]);
$protocol->replaceAllowedStaff([new TreatmentProtocolStaff($protocol, $staff)]);
$this->em->flush();
}
$appointment = $this->newAppointment(
$doctor,
$this->createUser(['ROLE_USER']),
1_790_000_000,
1_790_001_800,
$clinic,
);
$appointment->addServiceItem($service);
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
$this->em->persist($appointment);
$this->em->flush();
return [$appointment, $service, $clinic];
}
/** @return TreatmentCase[] */
private function casesFor(ServiceItem $service): array
{
return $this->em->getRepository(TreatmentCase::class)->findBy(['serviceItem' => $service]);
}
public function testConfirmingAProtocolServiceOpensACase(): void
{
[$appointment, $service] = $this->scenario(withProtocol: true);
$this->confirmation()->onConfirmed($appointment);
$cases = $this->casesFor($service);
self::assertCount(1, $cases);
self::assertSame(3, $cases[0]->getTotalSessions());
self::assertCount(3, $cases[0]->getSessions());
self::assertSame(['دست'], array_map(
static fn ($a) => $a->getName(),
$cases[0]->getAreas()->toArray(),
));
}
/** سرویس بدون پروتکل همان نوبت تک‌جلسه‌ای است — نباید پرونده‌ای ساخته شود. */
public function testConfirmingAPlainServiceOpensNoCase(): void
{
[$appointment, $service] = $this->scenario(withProtocol: false);
$this->confirmation()->onConfirmed($appointment);
self::assertSame([], $this->casesFor($service));
}
/** کلینیکِ بدون حوزهٔ فعالیت هم باید دوره‌اش کار کند — نال یعنی رفتار پیش‌فرض. */
public function testCaseOpensEvenWithoutAPracticeDomain(): void
{
[$appointment, $service] = $this->scenario(withProtocol: true, domainCode: null);
$this->confirmation()->onConfirmed($appointment);
self::assertCount(1, $this->casesFor($service));
}
public function testFirstSessionIsAttachedToTheAppointment(): void
{
[$appointment, $service] = $this->scenario(withProtocol: true);
$this->confirmation()->onConfirmed($appointment);
$sessions = $this->casesFor($service)[0]->getSessions()->toArray();
usort($sessions, static fn (TreatmentSession $a, TreatmentSession $b): int
=> $a->getSessionNumber() <=> $b->getSessionNumber());
self::assertSame($appointment->getId(), $sessions[0]->getAppointment()?->getId());
self::assertSame(TreatmentSession::STATUS_BOOKED, $sessions[0]->getStatus());
self::assertSame($appointment->getSlotStart(), $sessions[0]->getDueAt());
// جلسات بعدی نه نوبت دارند نه سررسید قطعی — سررسیدشان از جلسهٔ قبل می‌آید.
self::assertNull($sessions[1]->getAppointment());
self::assertNull($sessions[1]->getDueAt());
}
/** بیمار وسط دوره نوبت دیگری می‌گیرد — باید جلسهٔ همان دوره باشد نه دورهٔ موازی. */
public function testASecondAppointmentDoesNotOpenASecondCase(): void
{
[$appointment, $service, $clinic] = $this->scenario(withProtocol: true);
$this->confirmation()->onConfirmed($appointment);
$second = $this->newAppointment(
$appointment->getDoctor(),
$appointment->getUser(),
1_791_000_000,
1_791_001_800,
$clinic,
);
$second->addServiceItem($service);
$second->transitionTo(Appointment::STATUS_CONFIRMED);
$this->em->persist($second);
$this->em->flush();
$this->confirmation()->onConfirmed($second);
$cases = $this->casesFor($service);
self::assertCount(1, $cases);
$sessions = $cases[0]->getSessions()->toArray();
usort($sessions, static fn (TreatmentSession $a, TreatmentSession $b): int
=> $a->getSessionNumber() <=> $b->getSessionNumber());
self::assertSame($second->getId(), $sessions[1]->getAppointment()?->getId());
}
}
+198
View File
@@ -0,0 +1,198 @@
<?php
namespace App\Tests\Treatment;
use App\Clinic\Entity\Clinic;
use App\ClinicService\Entity\CatalogCategory;
use App\ClinicService\Entity\CatalogCategoryInclude;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\ClinicService\Service\CategoryClosureResolver;
use App\Patient\Entity\PatientRecord;
use App\Staff\Entity\ClinicStaff;
use App\Tests\ApiTestCase;
use App\Treatment\Entity\TreatmentCase;
use App\Treatment\Entity\TreatmentProtocol;
use App\Treatment\Entity\TreatmentProtocolStaff;
use App\Treatment\Entity\TreatmentProtocolStep;
use App\Treatment\Entity\TreatmentSession;
use App\Treatment\Repository\TreatmentSessionRepository;
use App\Treatment\Service\TreatmentCaseOpener;
use App\Treatment\Service\TreatmentScheduler;
/**
* سررسید نسبی: فاصله از تاریخ **واقعی** جلسهٔ قبل، نه از شروع دوره.
*/
class TreatmentSchedulerTest extends ApiTestCase
{
private const DAY = 86400;
private TreatmentScheduler $scheduler;
private TreatmentSessionRepository $sessions;
private TreatmentCase $case;
protected function setUp(): void
{
parent::setUp();
$this->sessions = $this->em->getRepository(TreatmentSession::class);
$this->scheduler = new TreatmentScheduler($this->sessions);
}
/** سناریوی بوتاکس: جلسه ۲ بعد از ۱۵ روز، بقیه ماهانه. */
private function botoxCase(): TreatmentCase
{
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($user);
$clinic->setName('کلینیک بوتاکس');
$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, 'بوتاکس پیشانی', 8_000_000);
$service->setCatalogCategory($category);
$this->em->persist($service);
$staff = new ClinicStaff('clinic', (int) $clinic->getId(), 'اپراتور');
$this->em->persist($staff);
$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),
]);
$protocol->replaceAllowedStaff([new TreatmentProtocolStaff($protocol, $staff)]);
$this->em->flush();
$record = new PatientRecord('clinic', (int) $clinic->getId(), $this->createUser(), 'clinic', (int) $clinic->getId());
$this->em->persist($record);
$this->em->flush();
$opener = new TreatmentCaseOpener(
new CategoryClosureResolver($this->em->getRepository(CatalogCategoryInclude::class)),
$this->em->getRepository(CatalogCategory::class),
$this->em,
);
return $opener->open('clinic', (int) $clinic->getId(), $record, $service, $protocol);
}
/** @return TreatmentSession[] */
private function orderedSessions(TreatmentCase $case): array
{
$sessions = $case->getSessions()->toArray();
usort($sessions, static fn (TreatmentSession $a, TreatmentSession $b): int
=> $a->getSessionNumber() <=> $b->getSessionNumber());
return $sessions;
}
private function finish(TreatmentSession $session, int $at): void
{
$session->finish();
// finish() زمان واقعی می‌گذارد؛ تست تاریخ را کنترل‌شده جا می‌اندازد.
$ref = new \ReflectionProperty(TreatmentSession::class, 'finishedAt');
$ref->setValue($session, $at);
$this->em->flush();
}
public function testUnevenIntervalsAreMeasuredFromTheLastFinishedSession(): void
{
$case = $this->botoxCase();
$sessions = $this->orderedSessions($case);
$day1 = 1_800_000_000;
$this->finish($sessions[0], $day1);
$next = $this->scheduler->scheduleNextAfter($sessions[0]);
self::assertSame($sessions[1]->getId(), $next?->getId());
self::assertSame($day1 + 15 * self::DAY, $sessions[1]->getDueAt());
// بیمار ۲۰ روز دیر آمد — جلسهٔ سوم باید از همین تاریخ حساب شود، نه از شروع دوره.
$actual = $sessions[1]->getDueAt() + 20 * self::DAY;
$this->finish($sessions[1], $actual);
$this->scheduler->scheduleNextAfter($sessions[1]);
self::assertSame($actual + 30 * self::DAY, $sessions[2]->getDueAt());
}
/** با لنگر ثابت، بیمارِ دیرآمده جلسهٔ بعدی‌اش را زودتر از موعد می‌گرفت. */
public function testALateSessionShiftsTheRestOfTheCourse(): void
{
$case = $this->botoxCase();
$sessions = $this->orderedSessions($case);
$day1 = 1_800_000_000;
$this->finish($sessions[0], $day1);
$this->scheduler->scheduleNextAfter($sessions[0]);
$late = $day1 + 60 * self::DAY;
$this->finish($sessions[1], $late);
$this->scheduler->scheduleNextAfter($sessions[1]);
self::assertSame($late + 30 * self::DAY, $sessions[2]->getDueAt());
self::assertGreaterThan($day1 + 45 * self::DAY, $sessions[2]->getDueAt());
}
/** فقط جلسهٔ بعدی بازمحاسبه می‌شود؛ جلسات دورتر هنوز لنگری ندارند. */
public function testOnlyTheNextSessionIsRescheduled(): void
{
$case = $this->botoxCase();
$sessions = $this->orderedSessions($case);
$this->finish($sessions[0], 1_800_000_000);
$this->scheduler->scheduleNextAfter($sessions[0]);
self::assertNotNull($sessions[1]->getDueAt());
self::assertNull($sessions[2]->getDueAt());
self::assertNull($sessions[3]->getDueAt());
}
/** غیبت جلسه را نمی‌سوزاند: تعداد جلسات ثابت می‌ماند و همان جلسه دوباره برنامه‌ریزی می‌شود. */
public function testNoShowKeepsTheSessionAndTheTotal(): void
{
$case = $this->botoxCase();
$sessions = $this->orderedSessions($case);
$this->finish($sessions[0], 1_800_000_000);
$this->scheduler->scheduleNextAfter($sessions[0]);
$sessions[1]->markNoShow();
$this->em->flush();
self::assertSame(4, $case->getTotalSessions());
self::assertCount(4, $case->getSessions());
self::assertSame(TreatmentSession::STATUS_NO_SHOW, $sessions[1]->getStatus());
// همان جلسه دوباره در صف رزرو می‌آید، نه جلسه‌ای تازه.
self::assertSame($sessions[1]->getId(), $this->sessions->findNextUnbooked($case)?->getId());
}
public function testLastSessionSchedulesNothing(): void
{
$case = $this->botoxCase();
$sessions = $this->orderedSessions($case);
foreach ($sessions as $index => $session) {
$this->finish($session, 1_800_000_000 + $index * 30 * self::DAY);
}
self::assertNull($this->scheduler->scheduleNextAfter($sessions[3]));
}
public function testUnfinishedSessionSchedulesNothing(): void
{
$case = $this->botoxCase();
$sessions = $this->orderedSessions($case);
self::assertNull($this->scheduler->scheduleNextAfter($sessions[0]));
self::assertNull($sessions[1]->getDueAt());
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
namespace App\Tests\Treatment;
use App\PracticeDomain\Entity\PracticeDomain;
use App\Tests\ApiTestCase;
use App\Treatment\Workflow\DefaultTreatmentWorkflow;
use App\Treatment\Workflow\LaserTreatmentWorkflow;
use App\Treatment\Workflow\TreatmentWorkflowRegistry;
class TreatmentWorkflowTest extends ApiTestCase
{
private TreatmentWorkflowRegistry $registry;
protected function setUp(): void
{
parent::setUp();
$this->registry = static::getContainer()->get(TreatmentWorkflowRegistry::class);
}
public function testBeautyResolvesToTheLaserWorkflow(): void
{
self::assertInstanceOf(LaserTreatmentWorkflow::class, $this->registry->for('beauty'));
}
/** حوزه‌ای که هنوز پیاده‌سازی ندارد باید رفتار پیش‌فرض بگیرد، نه خطا. */
public function testUnknownDomainFallsBackToTheDefaultWorkflow(): void
{
self::assertSame(
DefaultTreatmentWorkflow::class,
$this->registry->for('physiotherapy')::class,
);
}
/** نال یعنی «حوزه تنظیم نشده» و همان رفتار امروز، نه خطا. */
public function testNullDomainFallsBackToTheDefaultWorkflow(): void
{
self::assertSame(DefaultTreatmentWorkflow::class, $this->registry->for(null)::class);
}
public function testHasDedicatedWorkflowDistinguishesTheTwo(): void
{
self::assertTrue($this->registry->hasDedicatedWorkflow('beauty'));
self::assertFalse($this->registry->hasDedicatedWorkflow('physiotherapy'));
self::assertFalse($this->registry->hasDedicatedWorkflow(null));
}
/** پنل ادمین باید ببیند کدام حوزه هنوز workflow ندارد. */
public function testListExposesHasWorkflow(): void
{
$suffix = bin2hex(random_bytes(4));
foreach ([['beauty_' . $suffix, false], ['beauty', true]] as [$code, $expected]) {
if ($this->em->getRepository(PracticeDomain::class)->findOneBy(['code' => $code]) === null) {
$this->em->persist(new PracticeDomain($code, 'حوزهٔ آزمون'));
}
}
$this->em->flush();
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
$body = $this->authJson('GET', '/api/v1/practice-domains', $admin);
$byCode = array_column($body['data'], 'has_workflow', 'code');
self::assertTrue($byCode['beauty']);
self::assertFalse($byCode['beauty_' . $suffix]);
}
}