diff --git a/docs/api/treatment.md b/docs/api/treatment.md index 1918e303..5e6f456d 100644 --- a/docs/api/treatment.md +++ b/docs/api/treatment.md @@ -1,6 +1,7 @@ # Treatment API -> **Prefix:** `/api/v1/service-item/{uuid}/treatment-protocol` +> **Prefixes:** `/api/v1/service-item/{uuid}/treatment-protocol` · `/api/v1/treatment-case[s]` · +> `/api/v1/treatment-session[s]` · `/api/v1/dashboard/staff/…` A **treatment protocol** is the "طول درمان" of a service: it says a course of that service runs over several sessions, when each one falls due, which doctor supervises it, and which staff may perform @@ -184,3 +185,135 @@ single-session again. Idempotent: deleting a service that has no protocol still ```json { "success": true, "data": null } ``` + +--- + +# Treatment cases + +## GET `/api/v1/treatment-cases` + +پرونده‌های درمانِ محیط جاری، تازه‌ترین اول. + +**Permission:** `IS_AUTHENTICATED_FULLY`, محدود به محیط جاری. + +| Query | توضیح | +|---|---| +| `status` | `active` \| `completed` \| `abandoned` — نبودش یعنی همه | + +```json +{ + "uuid": "…", + "status": "active", + "total_sessions": 4, + "completed_sessions": 1, + "opened_at": 1785660000, + "closed_at": null, + "service": { "uuid": "…", "name": "لیزر توتال" }, + "supervisor": { "uuid": "…", "name": "دکتر ناظر" }, + "areas": [ { "uuid": "…", "name": "بیکینی" } ] +} +``` + +## GET `/api/v1/treatment-case/{uuid}` + +همان شکل، به‌علاوهٔ `sessions`. پروندهٔ محیط دیگر `404` می‌گیرد. + +--- + +## GET `/api/v1/treatment-sessions/unbooked` + +صفِ «جلسات بدون نوبت» — جلسه‌ای که سررسیدش رسیده و کسی رزروش نکرده. + +رزرو جلسهٔ بعد عمداً خودکار نیست: سیستم نمی‌داند بیمار پنجشنبه‌ها سر کار است و «اولین +وقت آزاد» معمولاً بدترین وقت است چون کسی نخواسته‌اش. پس کار در صفی دیده می‌شود که منشی +از رویش عمل می‌کند، نه رفتاری که بی‌صدا اتفاق بیفتد. + +| Query | پیش‌فرض | توضیح | +|---|---|---| +| `within_days` | `7` | تا چند روز آینده؛ سقف ۹۰ | + +جلسه‌ای که از قبل نوبت دارد در این فهرست نمی‌آید. + +--- + +## GET `/api/v1/treatment-session/{uuid}/slot-suggestions` + +اسلات‌های آزادِ منبع، از سررسید جلسه به بعد. **پیشنهاد است، نه رزرو**؛ ثبت نوبت از مسیر +عادی انجام می‌شود. + +| Query | پیش‌فرض | توضیح | +|---|---|---| +| `resource_uuid` | منبعِ جلسهٔ قبلی | ادامهٔ دوره روی همان دستگاه، هم یکنواخت‌تر است هم یک انتخاب کمتر | +| `days` | `14` | افق جست‌وجو؛ سقف ۶۰ | + +سررسیدِ گذشته یعنی بیمار دیر کرده، پس جست‌وجو از امروز شروع می‌شود نه از تاریخی که رد شده. +مدتِ نوبت روی همان منبع حل می‌شود، نه از پیش‌فرض خام سرویس. + +**۴۲۲** وقتی نه `resource_uuid` آمده و نه جلسه‌ای از قبل رزرو شده — فهرست خالی برنمی‌گردد، +چون «منبعی مشخص نیست» با «وقتی نیست» یکی نیست. **۴۰۴** برای منبع محیط دیگر. + +--- + +# Staff panel — running a session + +همهٔ این مسیرها زیر `/api/v1/dashboard/staff` هستند چون `StaffRouteGuardSubscriber` کاربرِ +فقط-پرسنل را بیرون از همان پیشوند می‌بندد؛ باز کردن راهِ تازه با allowlist یعنی مرزِ +دسترسی در دو جا تعریف شود. + +**Permission:** `ROLE_STAFF` **به‌علاوهٔ** ردیف فعالِ پرسنل در محیط جاری. نقش به‌تنهایی کافی +نیست: توکن تا انقضا معتبر می‌ماند و غیرفعال‌شدنِ پرسنل باید همان لحظه دسترسی را ببندد. + +| Method | Path | کار | +|---|---|---| +| GET | `/dashboard/staff/treatment-sessions` | جلسات امروزِ همین پرسنل | +| GET | `/dashboard/staff/treatment-session/{uuid}` | جزئیات جلسه + نواحی + `forms` | +| POST | `/dashboard/staff/treatment-session/{uuid}/start` | شروع جلسه | +| POST | `/dashboard/staff/treatment-session/{uuid}/finish` | اتمام جلسه | +| POST | `/dashboard/staff/session-area/{uuid}/start` | شروع یک ناحیه | +| POST | `/dashboard/staff/session-area/{uuid}/complete` | ثبت خوانده‌های دستگاه | +| POST | `/dashboard/staff/session-area/{uuid}/skip` | صرف‌نظر از ناحیه | + +### شروع جلسه + +رکوردِ هر ناحیهٔ پرونده یک بار ساخته می‌شود، پس فراخوانی دوباره ناحیهٔ تکراری نمی‌سازد. +پرسنلِ فراخوان به‌عنوان **انجام‌دهندهٔ واقعی** ثبت می‌شود — ممکن است با پرسنلِ +برنامه‌ریزی‌شدهٔ نوبت فرق کند، و سابقهٔ پزشکی باید بگوید چه کسی واقعاً دستگاه را دست گرفت. + +وضعیت نوبت به `salon` می‌رود. **زمان نوبت هرگز بازنویسی نمی‌شود**: `slot_start`/`slot_end` +تعهدِ رزرو و ورودیِ محاسبهٔ اشغال‌اند، و «چقدر طول کشید» در `started_at`/`finished_at` جلسه +می‌نشیند. بازنویسی گذشته یعنی مقایسهٔ پیش‌بینی با واقعیت برای همیشه از بین می‌رود. + +### ثبت یک ناحیه + +```json +{ + "resource_uuid": "…", + "parameters": { "energy": 18, "pulse": 3, "shots": 212 }, + "note": "بدون عارضه" +} +``` + +`parameters` با `field_schema`ِ **نوع همان منبع** سنجیده می‌شود — قواعدش در +[resource.md](./resource.md#فرم-ثبت-درمان). کلید ناشناخته، مقدار خارج از گزینه‌ها و فیلد +الزامیِ نیامده هر سه `422` می‌گیرند. + +ناحیه‌ای که یک بار بسته شده (`completed` یا `skipped`) دوباره بسته نمی‌شود ⇒ `422`. + +### اتمام جلسه + +```json +{ "note": "یادداشت کلی جلسه" } +``` + +**ناحیهٔ ناتمام مانع نیست** — اپراتور جلوی بیمار ایستاده و نباید در نرم‌افزار گیر کند — ولی +تعدادشان در پاسخ می‌آید تا پنل هشدار بدهد: + +```json +{ "unsettled_areas": 2 } +``` + +وضعیت نوبت `completed` می‌شود. اگر گذار مجاز نباشد (منشی وضعیت را دستی عوض کرده) جلسه +بسته می‌شود و نوبت دست‌نخورده می‌ماند؛ ماجرا لاگ می‌شود، خطای ۵۰۰ داده نمی‌شود. + +سپس `TreatmentWorkflow` حوزهٔ فعالیت سررسید جلسهٔ بعد را از **تاریخ واقعیِ همین جلسه** +حساب می‌کند، و اگر جلسهٔ دیگری نمانده باشد دوره بسته می‌شود. diff --git a/src/Treatment/Controller/SessionExecutionController.php b/src/Treatment/Controller/SessionExecutionController.php new file mode 100644 index 00000000..dcdb23e4 --- /dev/null +++ b/src/Treatment/Controller/SessionExecutionController.php @@ -0,0 +1,221 @@ +requireStaff($user); + + return $this->success(array_map( + static fn (TreatmentSession $s): array => $s->toArray(withAreas: true) + [ + 'case_uuid' => $s->getTreatmentCase()->getUuid(), + 'service_name' => $s->getTreatmentCase()->getServiceItem()->getName(), + ], + $this->sessions->findTodayForStaff( + $staff, + strtotime('today midnight'), + strtotime('tomorrow midnight') - 1, + ), + )); + } + + #[Route('/api/v1/dashboard/staff/treatment-session/{uuid}', name: 'staff_treatment_session_show', methods: ['GET'])] + public function show(#[CurrentUser] User $user, string $uuid): JsonResponse + { + $session = $this->requireSession($user, $uuid); + + return $this->success($session->toArray(withAreas: true) + [ + 'case' => $session->getTreatmentCase()->toArray(), + // فرمِ هر ناحیه از نوع منبعش می‌آید؛ پنل نباید فیلدها را حدس بزند. + 'forms' => $this->formsFor($session), + ]); + } + + #[Route('/api/v1/dashboard/staff/treatment-session/{uuid}/start', name: 'staff_treatment_session_start', methods: ['POST'])] + public function start(#[CurrentUser] User $user, string $uuid): JsonResponse + { + $staff = $this->requireStaff($user); + $session = $this->requireSession($user, $uuid); + + return $this->success($this->executor->startSession($session, $staff)->toArray(withAreas: true)); + } + + #[Route('/api/v1/dashboard/staff/treatment-session/{uuid}/finish', name: 'staff_treatment_session_finish', methods: ['POST'])] + public function finish(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse + { + $session = $this->requireSession($user, $uuid); + $data = json_decode($request->getContent(), true) ?? []; + $note = is_string($data['note'] ?? null) ? trim($data['note']) : null; + + $result = $this->executor->finishSession($session, $note !== '' ? $note : null); + + return $this->success($result['session']->toArray(withAreas: true) + [ + // بستن با ناحیهٔ ناتمام مجاز است؛ پنل با همین عدد هشدار می‌دهد. + 'unsettled_areas' => $result['unsettled_areas'], + ]); + } + + #[Route('/api/v1/dashboard/staff/session-area/{uuid}/start', name: 'staff_session_area_start', methods: ['POST'])] + public function startArea(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse + { + $record = $this->requireAreaRecord($user, $uuid); + $data = json_decode($request->getContent(), true) ?? []; + + return $this->success( + $this->executor->startArea($record, $this->resolveResource($user, $data))->toArray(), + ); + } + + #[Route('/api/v1/dashboard/staff/session-area/{uuid}/complete', name: 'staff_session_area_complete', methods: ['POST'])] + public function completeArea(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse + { + $record = $this->requireAreaRecord($user, $uuid); + $data = json_decode($request->getContent(), true) ?? []; + $note = is_string($data['note'] ?? null) ? trim($data['note']) : null; + + return $this->success($this->executor->completeArea( + $record, + $this->resolveResource($user, $data), + is_array($data['parameters'] ?? null) ? $data['parameters'] : null, + $note !== '' ? $note : null, + )->toArray()); + } + + #[Route('/api/v1/dashboard/staff/session-area/{uuid}/skip', name: 'staff_session_area_skip', methods: ['POST'])] + public function skipArea(#[CurrentUser] User $user, string $uuid): JsonResponse + { + return $this->success($this->executor->skipArea($this->requireAreaRecord($user, $uuid))->toArray()); + } + + /** @return array>> uuid منبع => تعریف فیلدها */ + private function formsFor(TreatmentSession $session): array + { + $forms = []; + + foreach ($session->getAreaRecords() as $record) { + $resource = $record->getResource(); + + if ($resource !== null) { + $forms[$resource->getUuid()] = $resource->getType()->getFieldSchema() ?? []; + } + } + + return $forms; + } + + private function resolveResource(User $user, array $data): ?ClinicResource + { + $uuid = is_string($data['resource_uuid'] ?? null) ? trim($data['resource_uuid']) : ''; + + if ($uuid === '') { + return null; + } + + $resource = $this->resources->findByUuid($uuid); + [$entityType, $entityId] = $this->pair($user); + + if (!$this->ownership->belongsToPair($entityType, $entityId, $resource)) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'منبع یافت نشد', 404, 'resource_uuid'); + } + + return $resource; + } + + private function requireStaff(User $user): ClinicStaff + { + [$entityType, $entityId] = $this->pair($user); + $staff = $this->staffRepo->findActiveByUserAndEntity($user, $entityType, $entityId); + + if ($staff === null) { + throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی پرسنل تنظیم نشده', 403); + } + + return $staff; + } + + private function requireSession(User $user, string $uuid): TreatmentSession + { + [$entityType, $entityId] = $this->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; + } + + private function requireAreaRecord(User $user, string $uuid): SessionAreaRecord + { + [$entityType, $entityId] = $this->pair($user); + $record = $this->areaRecords->findByUuid($uuid); + + if (!$this->ownership->belongsToPair($entityType, $entityId, $record)) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'ناحیهٔ جلسه یافت نشد', 404); + } + + return $record; + } + + /** @return array{0: string, 1: int} */ + private function pair(User $user): array + { + $context = $this->contextResolver->resolve($user); + + if (!$context->isResolved()) { + throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, 'محیط کاری پرسنل تنظیم نشده', 403); + } + + return $context->toEntityPair(); + } +} diff --git a/src/Treatment/Service/SessionExecutor.php b/src/Treatment/Service/SessionExecutor.php new file mode 100644 index 00000000..b3a349a2 --- /dev/null +++ b/src/Treatment/Service/SessionExecutor.php @@ -0,0 +1,185 @@ +getStatus() === TreatmentSession::STATUS_DONE) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'این جلسه قبلاً تمام شده است', 422); + } + + if ($session->getStartedAt() === null) { + $session->start(); + } + + if ($performedBy !== null) { + $session->setPerformedBy($performedBy); + } + + $this->ensureAreaRecords($session); + $this->syncAppointment($session, Appointment::STATUS_SALON); + + $this->em->flush(); + + return $session; + } + + public function startArea(SessionAreaRecord $record, ?ClinicResource $resource): SessionAreaRecord + { + if ($record->isSettled()) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'این ناحیه قبلاً بسته شده است', 422); + } + + $record->start($resource); + $this->em->flush(); + + return $record; + } + + /** + * بستن یک ناحیه با خوانده‌های دستگاه. + * + * مقادیر با `field_schema`ِ همان نوع منبع سنجیده می‌شوند، پس فرمِ لیزر و فرمِ RF + * هرکدام قواعد خودشان را دارند بدون اینکه اینجا نامی از دستگاه بیاید. + */ + public function completeArea( + SessionAreaRecord $record, + ?ClinicResource $resource, + ?array $parameters, + ?string $note, + ): SessionAreaRecord { + if ($record->isSettled()) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'این ناحیه قبلاً بسته شده است', 422); + } + + $resource ??= $record->getResource(); + + if ($resource === null) { + throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'دستگاه این ناحیه مشخص نیست', 422, 'resource_uuid'); + } + + $clean = $this->fieldSchema->validateValues($resource->getType()->getFieldSchema(), $parameters); + + $record->complete($resource, $clean, $note); + $this->em->flush(); + + return $record; + } + + /** بیمار امروز فقط یک ناحیه می‌خواهد — بقیه صرف‌نظر می‌شوند، نه ناتمام رها. */ + public function skipArea(SessionAreaRecord $record): SessionAreaRecord + { + if ($record->isSettled()) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'این ناحیه قبلاً بسته شده است', 422); + } + + $record->skip(); + $this->em->flush(); + + return $record; + } + + /** + * بستن جلسه. + * + * ناحیهٔ ناتمام مانع نیست — اپراتور جلوی بیمار ایستاده و نباید در نرم‌افزار گیر + * کند — ولی تعدادش برگردانده می‌شود تا پنل هشدار بدهد. + * + * @return array{session: TreatmentSession, unsettled_areas: int} + */ + public function finishSession(TreatmentSession $session, ?string $note): array + { + if ($session->getStatus() === TreatmentSession::STATUS_DONE) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'این جلسه قبلاً تمام شده است', 422); + } + + $unsettled = 0; + foreach ($session->getAreaRecords() as $record) { + if (!$record->isSettled()) { + $unsettled++; + } + } + + $session->finish($note); + $this->syncAppointment($session, Appointment::STATUS_COMPLETED); + $this->em->flush(); + + // سررسید جلسهٔ بعد و بستن دوره کارِ workflow حوزهٔ فعالیت است، نه این سرویس. + $domainCode = $session->getAppointment()?->getClinic()?->getPracticeDomain()?->getCode(); + $this->workflows->for($domainCode)->onSessionFinished($session); + + return ['session' => $session, 'unsettled_areas' => $unsettled]; + } + + /** رکورد هر ناحیهٔ پرونده، یک بار per جلسه. */ + private function ensureAreaRecords(TreatmentSession $session): void + { + $existing = []; + foreach ($session->getAreaRecords() as $record) { + $existing[(int) $record->getCaseArea()->getId()] = true; + } + + foreach ($session->getTreatmentCase()->getAreas() as $area) { + if (!isset($existing[(int) $area->getId()])) { + $session->addAreaRecord(new SessionAreaRecord($session, $area)); + } + } + } + + /** + * وضعیت نوبت را دنبال وضعیت جلسه می‌برد. + * + * گذارِ نامجاز جلسه را زمین نمی‌زند: اپراتور کارش را کرده و نباید به‌خاطر وضعیتی + * که منشی دستی عوض کرده خطای ۵۰۰ ببیند. لاگ می‌شود تا دیده شود. + */ + private function syncAppointment(TreatmentSession $session, string $status): void + { + $appointment = $session->getAppointment(); + + if ($appointment === null || $appointment->getStatus() === $status) { + return; + } + + if (!$appointment->canTransitionTo($status)) { + $this->logger->warning('Session status could not be mirrored onto its appointment', [ + 'session_uuid' => $session->getUuid(), + 'from' => $appointment->getStatus(), + 'to' => $status, + ]); + + return; + } + + $appointment->transitionTo($status); + } +} diff --git a/tests/Treatment/SessionExecutionTest.php b/tests/Treatment/SessionExecutionTest.php new file mode 100644 index 00000000..f826832f --- /dev/null +++ b/tests/Treatment/SessionExecutionTest.php @@ -0,0 +1,360 @@ + 'energy', 'label' => 'انرژی', 'type' => 'select', 'required' => true, 'sort_order' => 0, 'options' => [7, 8, 18]], + ['key' => 'pulse', 'label' => 'پالس', 'type' => 'select', 'required' => true, 'sort_order' => 1, 'options' => [3, 5]], + ['key' => 'shots', 'label' => 'شات', 'type' => 'number', 'required' => true, 'sort_order' => 2], + ]; + + /** + * @return array{staffUser: User, staff: ClinicStaff, session: TreatmentSession, + * resource: ClinicResource, case: TreatmentCase, appointment: Appointment} + */ + private function scenario(int $areaCount = 2): array + { + $owner = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']); + $clinic = new Clinic($owner); + $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)), 'دستگاه لیزر'); + $type->setFieldSchema(self::LASER_SCHEMA); + $this->em->persist($type); + $this->em->flush(); + + $resource = new ClinicResource($address, $type, 'Diode Laser 808nm'); + $resource->setSupervisor($doctor); + $this->em->persist($resource); + + $section = new ServiceSection('clinic', (int) $clinic->getId(), 'لیزر'); + $this->em->persist($section); + + $parent = new CatalogCategory('clinic', (int) $clinic->getId(), 'توتال'); + $this->em->persist($parent); + + $areas = []; + foreach (array_slice(['بیکینی', 'زیر بغل', 'دست'], 0, $areaCount) as $name) { + $child = new CatalogCategory('clinic', (int) $clinic->getId(), $name); + $this->em->persist($child); + $this->em->flush(); + $this->em->persist(new CatalogCategoryInclude($parent, $child)); + $areas[] = $child; + } + + $service = new ServiceItem($section, 'لیزر توتال', 10_000_000); + $service->setCatalogCategory($parent)->setDurationMinutes(30); + $this->em->persist($service); + + $staffUser = $this->createUser(['ROLE_USER', 'ROLE_STAFF']); + $staff = new ClinicStaff('clinic', (int) $clinic->getId(), 'اپراتور یک'); + $staff->setUser($staffUser); + $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); + foreach ($areas as $i => $category) { + $case->addArea(new TreatmentCaseArea($case, $category, $i)); + } + + $appointment = $this->newAppointment($doctor, $record->getUser(), time() + 3600, time() + 5400, $clinic); + $appointment->setResource($resource); + $appointment->setStaff($staff); + $appointment->transitionTo(Appointment::STATUS_CONFIRMED); + $this->em->persist($appointment); + + $session = new TreatmentSession($case, 1); + $session->attachAppointment($appointment); + $case->addSession($session); + $case->addSession(new TreatmentSession($case, 2)); + $this->em->persist($case); + $this->em->flush(); + + // نقش به‌تنهایی محیط نمی‌سازد؛ پرسنل باید محیط فعالش ست شده باشد. + static::getContainer()->get(UserActiveContextRepository::class) + ->upsert($staffUser, $clinic->getUuid(), EntityContext::TYPE_CLINIC); + + return [ + 'staffUser' => $staffUser, + 'staff' => $staff, + 'session' => $session, + 'resource' => $resource, + 'case' => $case, + 'appointment' => $appointment, + ]; + } + + private function areaRecords(TreatmentSession $session): array + { + $records = $this->em->getRepository(SessionAreaRecord::class)->findBy(['session' => $session]); + usort($records, static fn (SessionAreaRecord $a, SessionAreaRecord $b): int + => $a->getCaseArea()->getSortOrder() <=> $b->getCaseArea()->getSortOrder()); + + return $records; + } + + public function testStartingASessionCreatesOneRecordPerArea(): void + { + $s = $this->scenario(); + + $body = $this->authJson('POST', '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid() . '/start', $s['staffUser']); + + self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertSame(TreatmentSession::STATUS_IN_PROGRESS, $body['data']['status']); + self::assertNotNull($body['data']['started_at']); + self::assertCount(2, $body['data']['areas']); + self::assertSame($s['staff']->getUuid(), $body['data']['performed_by']['uuid']); + } + + /** شروع دوباره نباید ناحیهٔ تکراری بسازد. */ + public function testStartingTwiceIsIdempotent(): void + { + $s = $this->scenario(); + $uri = '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid() . '/start'; + + $this->authJson('POST', $uri, $s['staffUser']); + $body = $this->authJson('POST', $uri, $s['staffUser']); + + self::assertSame(200, $this->responseCode()); + self::assertCount(2, $body['data']['areas']); + } + + /** شروع جلسه، نوبت را به «سالن» می‌برد. */ + public function testStartingASessionMovesTheAppointmentToSalon(): void + { + $s = $this->scenario(); + + $this->authJson('POST', '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid() . '/start', $s['staffUser']); + + $this->em->clear(); + $appointment = $this->em->getRepository(Appointment::class)->find($s['appointment']->getId()); + self::assertSame(Appointment::STATUS_SALON, $appointment->getStatus()); + } + + public function testCompletingAnAreaStoresTheDeviceReadings(): void + { + $s = $this->scenario(); + $this->authJson('POST', '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid() . '/start', $s['staffUser']); + + $record = $this->areaRecords($s['session'])[0]; + + $body = $this->authJson('POST', '/api/v1/dashboard/staff/session-area/' . $record->getUuid() . '/complete', $s['staffUser'], [ + 'resource_uuid' => $s['resource']->getUuid(), + 'parameters' => ['energy' => 18, 'pulse' => 3, 'shots' => 212], + 'note' => 'بدون عارضه', + ]); + + self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertSame(SessionAreaRecord::STATUS_COMPLETED, $body['data']['status']); + self::assertSame(['energy' => 18, 'pulse' => 3, 'shots' => 212], $body['data']['parameters']); + self::assertSame('بدون عارضه', $body['data']['note']); + self::assertSame($s['resource']->getUuid(), $body['data']['resource']['uuid']); + self::assertNotNull($body['data']['finished_at']); + } + + /** مقدار خارج از گزینه‌های دستگاه رد می‌شود. */ + public function testAnOutOfRangeReadingIsRejected(): void + { + $s = $this->scenario(); + $this->authJson('POST', '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid() . '/start', $s['staffUser']); + $record = $this->areaRecords($s['session'])[0]; + + $this->authJson('POST', '/api/v1/dashboard/staff/session-area/' . $record->getUuid() . '/complete', $s['staffUser'], [ + 'resource_uuid' => $s['resource']->getUuid(), + 'parameters' => ['energy' => 99, 'pulse' => 3, 'shots' => 10], + ]); + + self::assertSame(422, $this->responseCode()); + } + + /** فیلد الزامیِ نیامده باید ۴۲۲ بدهد، نه ذخیرهٔ ناقص. */ + public function testAMissingRequiredReadingIsRejected(): void + { + $s = $this->scenario(); + $this->authJson('POST', '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid() . '/start', $s['staffUser']); + $record = $this->areaRecords($s['session'])[0]; + + $this->authJson('POST', '/api/v1/dashboard/staff/session-area/' . $record->getUuid() . '/complete', $s['staffUser'], [ + 'resource_uuid' => $s['resource']->getUuid(), + 'parameters' => ['energy' => 18], + ]); + + self::assertSame(422, $this->responseCode()); + } + + public function testAnAreaCanBeSkipped(): void + { + $s = $this->scenario(); + $this->authJson('POST', '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid() . '/start', $s['staffUser']); + $record = $this->areaRecords($s['session'])[1]; + + $body = $this->authJson('POST', '/api/v1/dashboard/staff/session-area/' . $record->getUuid() . '/skip', $s['staffUser']); + + self::assertSame(200, $this->responseCode()); + self::assertSame(SessionAreaRecord::STATUS_SKIPPED, $body['data']['status']); + } + + public function testAnAlreadySettledAreaCannotBeCompletedAgain(): void + { + $s = $this->scenario(); + $this->authJson('POST', '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid() . '/start', $s['staffUser']); + $record = $this->areaRecords($s['session'])[0]; + + $this->authJson('POST', '/api/v1/dashboard/staff/session-area/' . $record->getUuid() . '/skip', $s['staffUser']); + $this->authJson('POST', '/api/v1/dashboard/staff/session-area/' . $record->getUuid() . '/complete', $s['staffUser'], [ + 'resource_uuid' => $s['resource']->getUuid(), + 'parameters' => ['energy' => 18, 'pulse' => 3, 'shots' => 10], + ]); + + self::assertSame(422, $this->responseCode()); + } + + /** اپراتور جلوی بیمار ایستاده — ناحیهٔ ناتمام نباید بستن جلسه را قفل کند. */ + public function testASessionCanBeFinishedWithUnsettledAreasButReportsThem(): void + { + $s = $this->scenario(); + $this->authJson('POST', '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid() . '/start', $s['staffUser']); + + $body = $this->authJson('POST', '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid() . '/finish', $s['staffUser'], [ + 'note' => 'جلسه ناقص ماند', + ]); + + self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertSame(TreatmentSession::STATUS_DONE, $body['data']['status']); + self::assertSame(2, $body['data']['unsettled_areas']); + self::assertSame('جلسه ناقص ماند', $body['data']['note']); + } + + /** بستن جلسه سررسید جلسهٔ بعد را از تاریخ واقعی همین جلسه حساب می‌کند. */ + public function testFinishingSchedulesTheNextSession(): void + { + $s = $this->scenario(); + $this->authJson('POST', '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid() . '/start', $s['staffUser']); + $this->authJson('POST', '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid() . '/finish', $s['staffUser']); + + $this->em->clear(); + $case = $this->em->getRepository(TreatmentCase::class)->find($s['case']->getId()); + $sessions = $case->getSessions()->toArray(); + usort($sessions, static fn (TreatmentSession $a, TreatmentSession $b): int + => $a->getSessionNumber() <=> $b->getSessionNumber()); + + self::assertSame(TreatmentSession::STATUS_DONE, $sessions[0]->getStatus()); + self::assertNotNull($sessions[1]->getDueAt()); + self::assertSame( + $sessions[0]->getFinishedAt() + 30 * 86400, + $sessions[1]->getDueAt(), + ); + } + + /** اتمام جلسه، نوبت را «انجام شد» می‌کند و زمانش را دست نمی‌زند. */ + public function testFinishingCompletesTheAppointmentWithoutRewritingItsTimes(): void + { + $s = $this->scenario(); + $start = $s['appointment']->getSlotStart(); + $end = $s['appointment']->getSlotEnd(); + + $this->authJson('POST', '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid() . '/start', $s['staffUser']); + $this->authJson('POST', '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid() . '/finish', $s['staffUser']); + + $this->em->clear(); + $appointment = $this->em->getRepository(Appointment::class)->find($s['appointment']->getId()); + + self::assertSame(Appointment::STATUS_COMPLETED, $appointment->getStatus()); + self::assertSame($start, $appointment->getSlotStart()); + self::assertSame($end, $appointment->getSlotEnd()); + } + + public function testAFinishedSessionCannotBeFinishedTwice(): void + { + $s = $this->scenario(); + $uri = '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid() . '/finish'; + + $this->authJson('POST', '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid() . '/start', $s['staffUser']); + $this->authJson('POST', $uri, $s['staffUser']); + $this->authJson('POST', $uri, $s['staffUser']); + + self::assertSame(422, $this->responseCode()); + } + + /** جلسهٔ محیط دیگر برای این پرسنل وجود ندارد. */ + public function testASessionFromAnotherTenantIsNotFound(): void + { + $mine = $this->scenario(); + $other = $this->scenario(); + + $this->authJson('POST', '/api/v1/dashboard/staff/treatment-session/' . $other['session']->getUuid() . '/start', $mine['staffUser']); + + self::assertSame(404, $this->responseCode()); + } + + /** نقش staff بدون ردیف فعالِ پرسنل کافی نیست. */ + public function testAUserWithoutAStaffRowIsForbidden(): void + { + $s = $this->scenario(); + $orphan = $this->createUser(['ROLE_USER', 'ROLE_STAFF']); + + $this->authJson('POST', '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid() . '/start', $orphan); + + self::assertContains($this->responseCode(), [403, 404]); + } + + public function testTodayListsThisStaffsSessions(): void + { + $s = $this->scenario(); + + $body = $this->authJson('GET', '/api/v1/dashboard/staff/treatment-sessions', $s['staffUser']); + + self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertContains($s['session']->getUuid(), array_column($body['data'], 'uuid')); + } +}