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()); + } +}