From 1cdd62979f7d24ea48c4a388c93ab3e60666d4cf Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 6 Aug 2026 18:03:16 +0330 Subject: [PATCH] feat(treatment): expose treatment cases, the unbooked queue and slot suggestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Booking the next session stays a decision, not an automation: the system offers free slots and the secretary picks one with the patient in front of them. Booking automatically would fill the worst slot in the calendar — the one nobody wanted — and produce a no-show. A session whose due date has passed with nobody booking it surfaces in an explicit queue instead of waiting silently for the patient to call. Suggestions default to the resource the previous session ran on, since continuing a course on the same device is both clinically steadier and one less choice to make; with no previous booking the caller must name a resource rather than get an empty list. Slot maths is reused from ResourceBookingSlotService; this only decides which resource, from which day, and how far ahead to look. Co-Authored-By: Claude Opus 5 (1M context) --- .../Controller/TreatmentCaseController.php | 141 ++++++++++++ .../Service/NextSessionSlotFinder.php | 97 ++++++++ .../Treatment/TreatmentCaseEndpointsTest.php | 207 ++++++++++++++++++ 3 files changed, 445 insertions(+) create mode 100644 src/Treatment/Controller/TreatmentCaseController.php create mode 100644 src/Treatment/Service/NextSessionSlotFinder.php create mode 100644 tests/Treatment/TreatmentCaseEndpointsTest.php diff --git a/src/Treatment/Controller/TreatmentCaseController.php b/src/Treatment/Controller/TreatmentCaseController.php new file mode 100644 index 00000000..b2763356 --- /dev/null +++ b/src/Treatment/Controller/TreatmentCaseController.php @@ -0,0 +1,141 @@ +branches->pair($user); + + $status = $request->query->get('status'); + $status = is_string($status) && $status !== '' ? $status : null; + + return $this->success(array_map( + static fn (TreatmentCase $c): array => $c->toArray(), + $this->cases->findForTenant($entityType, $entityId, $status), + )); + } + + #[Route('/api/v1/treatment-case/{uuid}', name: 'treatment_case_show', methods: ['GET'])] + public function show(#[CurrentUser] User $user, string $uuid): JsonResponse + { + return $this->success($this->requireCase($user, $uuid)->toArray(withSessions: true)); + } + + /** + * صفِ «جلسات بدون نوبت» — کاری که منشی باید انجام دهد، نه رفتاری که خودکار اتفاق بیفتد. + * + * جلسه‌ای که سررسیدش رسیده و کسی رزروش نکرده اینجا دیده می‌شود؛ بدون این، جلسهٔ + * فراموش‌شده در سکوت می‌ماند تا بیمار خودش زنگ بزند. + */ + #[Route('/api/v1/treatment-sessions/unbooked', name: 'treatment_sessions_unbooked', methods: ['GET'])] + public function unbooked(#[CurrentUser] User $user, Request $request): JsonResponse + { + [$entityType, $entityId] = $this->branches->pair($user); + + $withinDays = (int) $request->query->get('within_days', 7); + $until = time() + max(0, min($withinDays, 90)) * 86400; + + return $this->success(array_map( + static fn (TreatmentSession $s): array => $s->toArray() + [ + 'case_uuid' => $s->getTreatmentCase()->getUuid(), + 'service_name' => $s->getTreatmentCase()->getServiceItem()->getName(), + ], + $this->sessions->findUnbookedDue($entityType, $entityId, $until), + )); + } + + /** + * اسلات‌های پیشنهادی برای این جلسه. + * + * پیشنهاد است، نه رزرو: منشی با بیمار هماهنگ می‌کند و بعد از مسیر عادی ثبت نوبت + * یکی را می‌گیرد. + */ + #[Route('/api/v1/treatment-session/{uuid}/slot-suggestions', name: 'treatment_session_slots', methods: ['GET'])] + public function slotSuggestions(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse + { + $session = $this->requireSession($user, $uuid); + + $resourceUuid = $request->query->get('resource_uuid'); + $resource = is_string($resourceUuid) && $resourceUuid !== '' + ? $this->resources->findByUuid($resourceUuid) + : $this->slotFinder->preferredResource($session->getTreatmentCase()); + + if ($resource === null) { + return $this->error( + ErrorCodes::ERR_VALIDATION_002, + 'منبعی برای پیشنهاد وقت مشخص نیست؛ resource_uuid بفرستید', + 422, + 'resource_uuid', + ); + } + + [$entityType, $entityId] = $this->branches->pair($user); + + if (!$this->ownership->belongsToPair($entityType, $entityId, $resource)) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'منبع یافت نشد', 404, 'resource_uuid'); + } + + $days = (int) $request->query->get('days', NextSessionSlotFinder::DEFAULT_HORIZON_DAYS); + + return $this->success($this->slotFinder->suggest($session, $resource, $days) + [ + 'session' => $session->toArray(), + ]); + } + + private function requireCase(User $user, string $uuid): TreatmentCase + { + [$entityType, $entityId] = $this->branches->pair($user); + $case = $this->cases->findByUuid($uuid); + + if (!$this->ownership->belongsToPair($entityType, $entityId, $case)) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'پروندهٔ درمان یافت نشد', 404); + } + + return $case; + } + + private function requireSession(User $user, string $uuid): TreatmentSession + { + [$entityType, $entityId] = $this->branches->pair($user); + $session = $this->sessions->findByUuid($uuid); + + if (!$this->ownership->belongsToPair($entityType, $entityId, $session)) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'جلسهٔ درمان یافت نشد', 404); + } + + return $session; + } +} diff --git a/src/Treatment/Service/NextSessionSlotFinder.php b/src/Treatment/Service/NextSessionSlotFinder.php new file mode 100644 index 00000000..e8d30e6e --- /dev/null +++ b/src/Treatment/Service/NextSessionSlotFinder.php @@ -0,0 +1,97 @@ +}> + * } + */ + public function suggest(TreatmentSession $session, ClinicResource $resource, int $days): array + { + $days = max(1, min($days, self::MAX_HORIZON_DAYS)); + + // سررسید گذشته باشد یعنی بیمار دیر کرده؛ جست‌وجو از امروز شروع می‌شود نه از + // تاریخی که رد شده. + $from = max($session->getDueAt() ?? time(), time()); + $minutes = $this->durationFor($session->getTreatmentCase(), $resource); + + $out = []; + + for ($i = 0; $i < $days; $i++) { + $date = date('Y-m-d', $from + $i * 86400); + $slots = $this->slots->startTimes($resource, $date, $minutes); + + if ($slots !== []) { + $out[] = ['date' => $date, 'slots' => $slots]; + } + } + + return [ + 'resource_uuid' => $resource->getUuid(), + 'from' => $from, + 'days' => $out, + ]; + } + + /** + * منبعِ پیش‌فرضِ جلسهٔ بعد: همان منبعی که جلسهٔ قبلی روی آن انجام شد. + * + * ادامهٔ دوره روی همان دستگاه، هم نتیجهٔ درمانی یکنواخت‌تری می‌دهد هم انتخاب را از + * دوش منشی برمی‌دارد. `null` یعنی هنوز هیچ جلسه‌ای نوبت نگرفته و باید صریح انتخاب شود. + */ + public function preferredResource(TreatmentCase $case): ?ClinicResource + { + $sessions = $case->getSessions()->toArray(); + usort($sessions, static fn (TreatmentSession $a, TreatmentSession $b): int + => $b->getSessionNumber() <=> $a->getSessionNumber()); + + foreach ($sessions as $session) { + $resource = $session->getAppointment()?->getResource(); + + if ($resource !== null) { + return $resource; + } + } + + return null; + } + + /** مدت روی همان منبع حل می‌شود، نه از پیش‌فرض خام سرویس. */ + private function durationFor(TreatmentCase $case, ClinicResource $resource): int + { + $service = $case->getServiceItem(); + + try { + return $this->slots->resolveDuration($resource, [$service->getUuid()])['minutes']; + } catch (\Throwable) { + // منبعی که این سرویس را ارائه نمی‌دهد هنوز می‌تواند اسلات نشان دهد؛ مدت + // پیش‌فرض سرویس بهتر از هیچ پیشنهادی است. + return $service->getDurationMinutes() ?? 30; + } + } +} diff --git a/tests/Treatment/TreatmentCaseEndpointsTest.php b/tests/Treatment/TreatmentCaseEndpointsTest.php new file mode 100644 index 00000000..98f1c75f --- /dev/null +++ b/tests/Treatment/TreatmentCaseEndpointsTest.php @@ -0,0 +1,207 @@ +createUser(['ROLE_USER', 'ROLE_CLINIC']); + $clinic = new Clinic($user); + $clinic->setName('کلینیک پرونده'); + $this->em->persist($clinic); + $this->em->flush(); + + $doctor = new Doctor($this->createUser(['ROLE_USER', 'ROLE_DOCTOR']), 'دکتر ناظر'); + $this->em->persist($doctor); + $clinic->getDoctors()->add($doctor); + + $address = DoctorAddress::forClinic($clinic->getId()); + $address->setName('شعبهٔ مرکزی'); + $this->em->persist($address); + + $type = new ResourceType('clinic', (int) $clinic->getId(), 'laser_' . bin2hex(random_bytes(3)), 'لیزر'); + $this->em->persist($type); + $this->em->flush(); + + $resource = new ClinicResource($address, $type, 'دستگاه لیزر'); + $resource->setSupervisor($doctor); + $this->em->persist($resource); + + $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)->setDurationMinutes(30); + $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, 30), + ]); + $protocol->replaceAllowedStaff([new TreatmentProtocolStaff($protocol, $staff)]); + + $record = new PatientRecord('clinic', (int) $clinic->getId(), $this->createUser(), 'clinic', (int) $clinic->getId()); + $this->em->persist($record); + $this->em->flush(); + + $case = new TreatmentCase('clinic', (int) $clinic->getId(), $record, $service, $protocol); + $case->addArea(new TreatmentCaseArea($case, $category, 0)); + $first = new TreatmentSession($case, 1); + $second = new TreatmentSession($case, 2); + $case->addSession($first); + $case->addSession($second); + $this->em->persist($case); + $this->em->flush(); + + return ['user' => $user, 'clinic' => $clinic, 'case' => $case, 'resource' => $resource, 'doctor' => $doctor]; + } + + public function testListReturnsCasesOfTheCurrentTenantOnly(): void + { + $mine = $this->scenario(); + $other = $this->scenario(); + + $body = $this->authJson('GET', '/api/v1/treatment-cases', $mine['user']); + + self::assertSame(200, $this->responseCode()); + $uuids = array_column($body['data'], 'uuid'); + self::assertContains($mine['case']->getUuid(), $uuids); + self::assertNotContains($other['case']->getUuid(), $uuids); + } + + public function testShowReturnsSessionsAndAreas(): void + { + $s = $this->scenario(); + + $body = $this->authJson('GET', '/api/v1/treatment-case/' . $s['case']->getUuid(), $s['user']); + + self::assertSame(200, $this->responseCode()); + self::assertSame(2, $body['data']['total_sessions']); + self::assertSame(0, $body['data']['completed_sessions']); + self::assertCount(2, $body['data']['sessions']); + self::assertSame(['دست'], array_column($body['data']['areas'], 'name')); + } + + public function testACaseFromAnotherTenantIsNotFound(): void + { + $mine = $this->scenario(); + $other = $this->scenario(); + + $this->authJson('GET', '/api/v1/treatment-case/' . $other['case']->getUuid(), $mine['user']); + + self::assertSame(404, $this->responseCode()); + } + + /** جلسه‌ای که سررسیدش رسیده و رزرو نشده باید در صف دیده شود. */ + public function testUnbookedQueueListsDueSessions(): void + { + $s = $this->scenario(); + $sessions = $s['case']->getSessions()->toArray(); + $sessions[0]->setDueAt(time() - 3600); + $this->em->flush(); + + $body = $this->authJson('GET', '/api/v1/treatment-sessions/unbooked', $s['user']); + + self::assertSame(200, $this->responseCode()); + $uuids = array_column($body['data'], 'uuid'); + self::assertContains($sessions[0]->getUuid(), $uuids); + // جلسهٔ دوم هنوز سررسید ندارد — لنگرش جلسهٔ اولِ انجام‌نشده است. + self::assertNotContains($sessions[1]->getUuid(), $uuids); + } + + public function testUnbookedQueueSkipsSessionsThatAlreadyHaveAnAppointment(): void + { + $s = $this->scenario(); + $sessions = $s['case']->getSessions()->toArray(); + + $appointment = $this->newAppointment( + $s['doctor'], + $this->createUser(['ROLE_USER']), + time() + 3600, + time() + 5400, + $s['clinic'], + ); + $this->em->persist($appointment); + $this->em->flush(); + + $sessions[0]->setDueAt(time() - 3600); + $sessions[0]->attachAppointment($appointment); + $this->em->flush(); + + $body = $this->authJson('GET', '/api/v1/treatment-sessions/unbooked', $s['user']); + + self::assertNotContains($sessions[0]->getUuid(), array_column($body['data'], 'uuid')); + } + + /** بدون منبع پیش‌فرض و بدون resource_uuid، باید صریح خطا بدهد نه فهرست خالی. */ + public function testSlotSuggestionsWithoutAResourceIsRejected(): void + { + $s = $this->scenario(); + $session = $s['case']->getSessions()->first(); + + $body = $this->authJson('GET', '/api/v1/treatment-session/' . $session->getUuid() . '/slot-suggestions', $s['user']); + + self::assertSame(422, $this->responseCode()); + self::assertSame('resource_uuid', $body['errors'][0]['field']); + } + + public function testSlotSuggestionsAcceptAnExplicitResource(): void + { + $s = $this->scenario(); + $session = $s['case']->getSessions()->first(); + + $body = $this->authJson( + 'GET', + '/api/v1/treatment-session/' . $session->getUuid() . '/slot-suggestions?resource_uuid=' . $s['resource']->getUuid(), + $s['user'], + ); + + self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertSame($s['resource']->getUuid(), $body['data']['resource_uuid']); + self::assertArrayHasKey('days', $body['data']); + self::assertSame($session->getUuid(), $body['data']['session']['uuid']); + } + + public function testAResourceFromAnotherTenantIsNotFound(): void + { + $mine = $this->scenario(); + $other = $this->scenario(); + $session = $mine['case']->getSessions()->first(); + + $this->authJson( + 'GET', + '/api/v1/treatment-session/' . $session->getUuid() . '/slot-suggestions?resource_uuid=' . $other['resource']->getUuid(), + $mine['user'], + ); + + self::assertSame(404, $this->responseCode()); + } +}