feat(treatment): bind an appointment to a chosen session, and free it on cancel

Two holes in how a course's later appointments were made.

The link from the unbooked queue carried nothing — `/admin/appointments/new`
with no parameters — so the secretary retyped the patient and the service, and
which case the appointment joined was inferred from the service they happened to
pick. A patient with two open courses had no way to say which one they meant,
and picking the wrong service silently opened a third case. (The suggestion link
did pass slot_start and resource_uuid, but the create page never read either.)

POST /api/v1/my/appointment now takes an optional treatment_session_uuid.
SessionBookingLink validates it — same tenant, still unbooked, case open, same
patient — and reserves that session. Confirm-time attachment steps aside when
the appointment already holds a session. The booking form states in words which
session, which course and which patient it is about to book, read from a new
GET /api/v1/treatment-session/{uuid}.

Nothing ever detached a session from its appointment, so a cancelled booking
left the session `booked` forever, and since findNextUnbooked requires
"has no appointment", it could never return to the queue. Cancellation and
no-show now release it back to `planned`. A finished session is history and is
left alone.

The system still never books the next appointment by itself — it only suggests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-07 15:44:22 +03:30
co-authored by Claude Opus 5
parent d857242145
commit c7a3b88b32
9 changed files with 308 additions and 4 deletions
+45
View File
@@ -179,6 +179,51 @@ class OpenCaseOnConfirmTest extends ApiTestCase
self::assertNull($sessions[1]->getDueAt());
}
/**
* نوبتِ لغوشده باید جلسه را آزاد کند.
*
* بدون این، جلسه تا ابد `booked` می‌ماند و چون `findNextUnbooked` شرطش «نوبت
* ندارد» است، هرگز به صفِ «جلسات بدون نوبت» برنمی‌گردد.
*/
public function testCancellingAnAppointmentReleasesItsSession(): 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(TreatmentSession::STATUS_BOOKED, $sessions[0]->getStatus());
static::getContainer()->get(\App\Treatment\Service\SessionBookingLink::class)->release($appointment);
self::assertNull($sessions[0]->getAppointment());
self::assertSame(TreatmentSession::STATUS_PLANNED, $sessions[0]->getStatus());
}
/** جلسهٔ انجام‌شده سابقه است؛ لغوِ بعدیِ نوبت نباید آن را باز کند. */
public function testReleasingDoesNotTouchAFinishedSession(): 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());
$sessions[0]->start();
$sessions[0]->finish();
$this->em->flush();
static::getContainer()->get(\App\Treatment\Service\SessionBookingLink::class)->release($appointment);
self::assertNotNull($sessions[0]->getAppointment());
self::assertSame(TreatmentSession::STATUS_DONE, $sessions[0]->getStatus());
}
/** بیمار وسط دوره نوبت دیگری می‌گیرد — باید جلسهٔ همان دوره باشد نه دورهٔ موازی. */
public function testASecondAppointmentDoesNotOpenASecondCase(): void
{