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
@@ -50,6 +50,7 @@ class AppointmentController extends BaseController
private readonly \App\Appointment\Service\ServiceBookingCalculator $serviceCalculator,
private readonly \App\Appointment\Service\ServiceRescheduleService $rescheduleService,
private readonly \App\Appointment\Availability\Service\ResourceOccupier $occupier,
private readonly \App\Treatment\Service\SessionBookingLink $sessionLink,
private readonly \Psr\Log\LoggerInterface $logger,
) {}
@@ -1067,6 +1068,18 @@ class AppointmentController extends BaseController
if ($appointment->getResource() !== null) {
$this->occupier->releaseForAppointment((int) $appointment->getId());
}
}
/**
* جلسهٔ درمان با لغو **و** غیبت آزاد می‌شود.
*
* غیبت در `CANCEL_STATUSES` نیست چون نوبت را لغو نمی‌کند، ولی برای دوره فرقی
* ندارد: بیمار نیامده و آن جلسه باید دوباره قابل رزرو باشد. سابقهٔ غیبت روی
* خودِ نوبت می‌ماند، پس چیزی از دست نمی‌رود.
*/
if (in_array($newStatus, [...self::CANCEL_STATUSES, Appointment::STATUS_NO_SHOW], true)) {
$this->sessionLink->release($appointment);
}
if ($newStatus === Appointment::STATUS_COMPLETED) {
@@ -50,6 +50,7 @@ class MyAppointmentsController extends BaseController
private readonly \App\Resource\Repository\ClinicResourceRepository $resourceRepo,
private readonly \App\Resource\Service\ResourceBookingSlotService $resourceSlots,
private readonly \App\Appointment\Availability\Service\ResourceOccupier $occupier,
private readonly \App\Treatment\Service\SessionBookingLink $sessionLink,
) {}
/**
@@ -355,6 +356,20 @@ class MyAppointmentsController extends BaseController
}
}
/**
* جلسهٔ درمانِ صریح.
*
* یک بیمار می‌تواند چند دورهٔ باز داشته باشد؛ بدون این، اتصال از روی سرویس
* حدس زده می‌شود و سرویسِ اشتباه بی‌صدا یک پروندهٔ موازی می‌سازد. بعد از
* `bookAtomically` می‌آید چون شناسهٔ نوبت لازم است.
*/
$sessionUuid = trim((string) ($data['treatment_session_uuid'] ?? ''));
if ($sessionUuid !== '') {
$this->sessionLink->bind($appointment, $sessionUuid, ...$this->branches->pair($user));
$this->appointmentRepo->save($appointment);
}
return $this->success([
'uuid' => $appointment->getUuid(),
'slot_start' => $slotStart,
@@ -140,6 +140,28 @@ class TreatmentCaseController extends BaseController
));
}
/**
* یک جلسه به‌تنهایی — برای فرمِ «ثبت نوبت این جلسه».
*
* فرم باید بگوید نوبت برای کدام دوره و کدام بیمار ثبت می‌شود؛ بیمارِ چنددوره‌ای
* بدون این، اتصال را به حدسِ سرویس می‌سپارد.
*/
#[Route('/api/v1/treatment-session/{uuid}', name: 'treatment_session_show', methods: ['GET'])]
public function showSession(#[CurrentUser] User $user, string $uuid): JsonResponse
{
$session = $this->requireSession($user, $uuid);
$case = $session->getTreatmentCase();
return $this->success($session->toArray() + [
'case_uuid' => $case->getUuid(),
'service' => ['uuid' => $case->getServiceItem()->getUuid(), 'name' => $case->getServiceItem()->getName()],
'patient' => [
'record_uuid' => $case->getPatientRecord()->getUuid(),
'name' => $case->getPatientRecord()->getUser()->getRealName(),
],
]);
}
/**
* اسلات‌های پیشنهادی برای این جلسه.
*
@@ -0,0 +1,108 @@
<?php
namespace App\Treatment\Service;
use App\Appointment\Entity\Appointment;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use App\Treatment\Entity\TreatmentCase;
use App\Treatment\Entity\TreatmentSession;
use App\Treatment\Repository\TreatmentSessionRepository;
use Doctrine\ORM\EntityManagerInterface;
/**
* پیوندِ «این نوبت مالِ کدام جلسهٔ درمان است».
*
* یک بیمار می‌تواند چند دورهٔ باز داشته باشد. تا امروز اتصال **ضمنی** بود — از روی
* سرویسِ نوبت، پروندهٔ باز پیدا می‌شد — و انتخابِ سرویسِ اشتباه بی‌سروصدا یک پروندهٔ
* موازی می‌ساخت. حالا منشی می‌تواند صریح بگوید کدام جلسه، و همان جلسه رزرو می‌شود.
*
* جدا شدن هم اینجاست: نوبتِ لغوشده یا غیبت‌خورده باید جلسه را آزاد کند، وگرنه جلسه تا
* ابد `booked` می‌ماند و چون `findNextUnbooked` شرطش «نوبت ندارد» است، هرگز به صف
* برنمی‌گردد.
*/
final class SessionBookingLink
{
public function __construct(
private readonly TreatmentSessionRepository $sessions,
private readonly EntityManagerInterface $em,
) {}
/**
* رزروِ صریحِ یک جلسه با این نوبت.
*
* فقط اعتبارسنجی و اتصال؛ فراخوان مسئول flush است تا با تراکنشِ ساختِ نوبت یکی بماند.
*/
public function bind(Appointment $appointment, string $sessionUuid, string $entityType, int $entityId): TreatmentSession
{
$session = $this->sessions->findByUuid($sessionUuid);
if ($session === null
|| $session->getEntityType() !== $entityType
|| $session->getEntityId() !== $entityId
) {
throw new AppException(
ErrorCodes::ERR_NOT_FOUND_001,
'جلسهٔ درمان یافت نشد',
404,
'treatment_session_uuid',
);
}
if ($session->getAppointment() !== null) {
throw new AppException(
ErrorCodes::ERR_CONFLICT_001,
'این جلسه از قبل نوبت دارد',
409,
'treatment_session_uuid',
);
}
if ($session->getTreatmentCase()->getStatus() !== TreatmentCase::STATUS_ACTIVE) {
throw new AppException(
ErrorCodes::ERR_CONFLICT_001,
'پروندهٔ این جلسه باز نیست',
409,
'treatment_session_uuid',
);
}
// بیمارِ نوبت باید همان بیمارِ پرونده باشد، وگرنه جلسهٔ یک نفر به نوبت
// دیگری وصل می‌شود و سابقهٔ درمان دروغ می‌گوید.
$recordUser = $session->getTreatmentCase()->getPatientRecord()->getUser();
if ($recordUser->getId() !== $appointment->getUser()->getId()) {
throw new AppException(
ErrorCodes::ERR_CONFLICT_001,
'این جلسه مالِ بیمار دیگری است',
409,
'treatment_session_uuid',
);
}
$session->attachAppointment($appointment);
$session->setDueAt($appointment->getSlotStart());
return $session;
}
/**
* آزاد کردن جلسه‌ای که نوبتش لغو شد یا بیمار نیامد.
*
* غیبت و لغو یک رفتار دارند: جلسه دوباره قابل رزرو می‌شود. سابقهٔ غیبت روی خودِ
* نوبت می‌ماند، پس چیزی از دست نمی‌رود.
*/
public function release(Appointment $appointment): ?TreatmentSession
{
$session = $this->sessions->findOneBy(['appointment' => $appointment]);
if ($session === null || $session->getStatus() === TreatmentSession::STATUS_DONE) {
return null;
}
$session->attachAppointment(null);
$this->em->flush();
return $session;
}
}
@@ -69,9 +69,18 @@ class DefaultTreatmentWorkflow implements TreatmentWorkflow
$this->em->flush();
}
/** نوبت به اولین جلسهٔ بدونِ نوبت وصل می‌شود، نه لزوماً به جلسهٔ اول. */
/**
* نوبت به اولین جلسهٔ بدونِ نوبت وصل می‌شود، نه لزوماً به جلسهٔ اول.
*
* مگر اینکه منشی هنگام ثبت، جلسه را صریح انتخاب کرده باشد؛ آن‌وقت نوبت از قبل
* وصل است و حدسِ «اولین بدون نوبت» فقط جلسهٔ دیگری را اشتباهی رزرو می‌کند.
*/
protected function attachToNextOpenSession(TreatmentCase $case, Appointment $appointment): void
{
if ($this->sessions->findOneBy(['appointment' => $appointment]) !== null) {
return;
}
$session = $this->sessions->findNextUnbooked($case);
if ($session === null) {