diff --git a/assets/admin/pages/AppointmentCreatePage.tsx b/assets/admin/pages/AppointmentCreatePage.tsx index f0d379ce..4838ba18 100644 --- a/assets/admin/pages/AppointmentCreatePage.tsx +++ b/assets/admin/pages/AppointmentCreatePage.tsx @@ -19,7 +19,7 @@ import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker'; import SlotPicker, { type PickedSlot } from '../components/appointments/SlotPicker'; import { tehranWallClockToUnix, rialToToman, tomanToRial } from '../lib/utils'; import BackButton from '../components/ui/BackButton'; -import { digitsOnly, todayIso } from '../lib/utils'; +import { digitsOnly, todayIso, formatNumber } from '../lib/utils'; import Switch from '../components/ui/Switch'; /** @@ -104,6 +104,26 @@ export default function AppointmentCreatePage() { // ورود از تب «نوبت‌ها»ی پروندهٔ بیمار: مراجعه‌کننده از قبل معلوم است، پس به‌جای // جستجو، خودِ پرونده خوانده و قفل می‌شود. const fromRecordUuid = params.get('record') ?? ''; + + /** + * «ثبت نوبت این جلسه» — بیمار می‌تواند چند دورهٔ باز داشته باشد، پس فرم باید بگوید + * این نوبت به کدام جلسه می‌چسبد و همان را به سرور بفرستد. بدونش اتصال به حدسِ + * سرویس سپرده می‌شود و سرویسِ اشتباه یک پروندهٔ موازی می‌سازد. + */ + const targetSessionUuid = params.get('session') ?? ''; + const targetSessionQ = useQuery>({ + queryKey: ['create-appt-session', targetSessionUuid], + queryFn: () => api.get(`/api/v1/treatment-session/${targetSessionUuid}`), + enabled: !!targetSessionUuid, + }); + const targetSession = targetSessionQ.data?.data; const recordQ = useQuery>({ queryKey: ['create-appt-record', fromRecordUuid], queryFn: () => api.get(`/api/v1/patient/${fromRecordUuid}`), @@ -208,6 +228,7 @@ export default function AppointmentCreatePage() { // و در حالت منبع، منبعِ کلینیک اصلاً «متعلق به این محیط» شناخته نمی‌شود (۴۲۲). ...(clinicUuid ? { clinic_uuid: clinicUuid } : {}), ...(resourceMode ? { resource_uuid: activeResource!.uuid } : {}), + ...(targetSessionUuid ? { treatment_session_uuid: targetSessionUuid } : {}), ...(serviceMode ? { service_item_uuids: servicePick.serviceUuids, duration_from_services: true, service_durations: servicePick.durations } : { @@ -255,6 +276,38 @@ export default function AppointmentCreatePage() { ثبت نوبت جدید + {/* فرم باید صریح بگوید این نوبت برای کدام دوره است؛ بیمارِ چنددوره‌ای بدون این، + اتصال را به حدسِ سرویس می‌سپارد. */} + {targetSessionUuid !== '' && ( +
+ {targetSessionQ.isLoading ? ( + در حال خواندن جلسهٔ درمان… + ) : targetSession ? ( + <> + + این نوبت برای: + جلسهٔ {formatNumber(targetSession.session_number)} از {formatNumber(targetSession.total_sessions)} + + + دوره: + {targetSession.service.name} + + + بیمار: + {targetSession.patient.name || 'بدون نام'} + + + ) : ( + + جلسهٔ درمان یافت نشد — نوبت بدون اتصال به دوره ثبت می‌شود. + + )} +
+ )} +
{/* پزشک — فقط برای admin/clinic */} {!isDoctor && ( diff --git a/assets/admin/pages/TreatmentCasesPage.tsx b/assets/admin/pages/TreatmentCasesPage.tsx index a89f57a1..2aba9493 100644 --- a/assets/admin/pages/TreatmentCasesPage.tsx +++ b/assets/admin/pages/TreatmentCasesPage.tsx @@ -452,7 +452,11 @@ function SlotSuggestions({ sessionUuid }: { sessionUuid: string }) { - ثبت نوبت این جلسه + {/* لینک باید جلسه را ببرد، وگرنه منشی بیمار و سرویس را دستی می‌زند و اتصال + به حدسِ سرویس سپرده می‌شود. */} + + ثبت نوبت این جلسه +
); } @@ -483,7 +487,7 @@ function SlotSuggestions({ sessionUuid }: { sessionUuid: string }) { {day.slots.slice(0, 6).map((slot) => ( diff --git a/docs/api/treatment.md b/docs/api/treatment.md index ad17ad02..7c90b87d 100644 --- a/docs/api/treatment.md +++ b/docs/api/treatment.md @@ -281,6 +281,41 @@ single-session again. Idempotent: deleting a service that has no protocol still --- +## GET `/api/v1/treatment-session/{uuid}` + +یک جلسه به‌تنهایی، به‌علاوهٔ `case_uuid`، `service` و `patient` — برای فرمِ «ثبت نوبت این +جلسه». جلسهٔ محیط دیگر `404` می‌گیرد. + +--- + +## بستنِ نوبت به یک جلسهٔ مشخص + +یک بیمار می‌تواند چند دورهٔ باز داشته باشد. تا پیش از این، اتصال **ضمنی** بود: هنگام +قطعی‌شدن، از روی سرویسِ نوبت پروندهٔ باز پیدا می‌شد و نوبت به اولین جلسهٔ بدون‌نوبتِ آن +می‌چسبید. انتخابِ سرویسِ اشتباه بی‌صدا یک پروندهٔ موازی می‌ساخت. + +`POST /api/v1/my/appointment` حالا `treatment_session_uuid` اختیاری می‌گیرد: + +| کد | HTTP | شرط | +|---|---|---| +| ERR_NOT_FOUND_001 | 404 | جلسه یافت نشد یا مال محیط دیگری است | +| ERR_CONFLICT_001 | 409 | جلسه از قبل نوبت دارد | +| ERR_CONFLICT_001 | 409 | پروندهٔ جلسه باز نیست | +| ERR_CONFLICT_001 | 409 | جلسه مالِ بیمار دیگری است | + +وقتی فرستاده شود، همان جلسه رزرو می‌شود و منطقِ «اولین جلسهٔ بدون نوبت» هنگام تأیید +کنار می‌رود. نبودنش یعنی همان رفتار قبلی. + +### آزاد شدن جلسه + +`cancelled_by_user` · `cancelled_by_doctor` · `no_show` جلسه را از نوبت جدا می‌کنند و به +`planned` برمی‌گردانند، پس دوباره در «جلسات بدون نوبت» دیده می‌شود. غیبت در +`CANCEL_STATUSES` نیست ولی برای دوره فرقی ندارد — سابقهٔ غیبت روی خودِ نوبت می‌ماند. + +جلسهٔ `done` استثناست: سابقه است و آزاد نمی‌شود. + +--- + ## GET `/api/v1/treatment-sessions/unbooked` صفِ «جلسات بدون نوبت» — جلسه‌ای که سررسیدش رسیده و کسی رزروش نکرده. diff --git a/src/Appointment/Controller/AppointmentController.php b/src/Appointment/Controller/AppointmentController.php index 9c09be61..990e1f39 100644 --- a/src/Appointment/Controller/AppointmentController.php +++ b/src/Appointment/Controller/AppointmentController.php @@ -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) { diff --git a/src/Appointment/Controller/MyAppointmentsController.php b/src/Appointment/Controller/MyAppointmentsController.php index 0fede77b..12d7c8b4 100644 --- a/src/Appointment/Controller/MyAppointmentsController.php +++ b/src/Appointment/Controller/MyAppointmentsController.php @@ -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, diff --git a/src/Treatment/Controller/TreatmentCaseController.php b/src/Treatment/Controller/TreatmentCaseController.php index 2065182f..68f6811c 100644 --- a/src/Treatment/Controller/TreatmentCaseController.php +++ b/src/Treatment/Controller/TreatmentCaseController.php @@ -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(), + ], + ]); + } + /** * اسلات‌های پیشنهادی برای این جلسه. * diff --git a/src/Treatment/Service/SessionBookingLink.php b/src/Treatment/Service/SessionBookingLink.php new file mode 100644 index 00000000..a2a2ae5a --- /dev/null +++ b/src/Treatment/Service/SessionBookingLink.php @@ -0,0 +1,108 @@ +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; + } +} diff --git a/src/Treatment/Workflow/DefaultTreatmentWorkflow.php b/src/Treatment/Workflow/DefaultTreatmentWorkflow.php index c025f012..6235d8f7 100644 --- a/src/Treatment/Workflow/DefaultTreatmentWorkflow.php +++ b/src/Treatment/Workflow/DefaultTreatmentWorkflow.php @@ -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) { diff --git a/tests/Treatment/OpenCaseOnConfirmTest.php b/tests/Treatment/OpenCaseOnConfirmTest.php index f0106cf8..421b33c5 100644 --- a/tests/Treatment/OpenCaseOnConfirmTest.php +++ b/tests/Treatment/OpenCaseOnConfirmTest.php @@ -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 {