From 77eeefd5b4b0f2517eab48b78fbb51f1a0ec90df Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 6 Aug 2026 18:17:27 +0330 Subject: [PATCH] feat(treatment): run a session from the staff panel, area by area MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The operator opens the session, treats each body area on its own device and records what that device was set to. Readings are validated against the resource type's field schema, so a laser form and an RF form each enforce their own rules without this code naming either. Finishing is allowed with areas still open — the operator is standing in front of a patient and must not be trapped by the software — but the count comes back so the panel can warn. Session state mirrors onto the appointment (salon, then completed) while its slot times are never rewritten: those are the reservation's promise and the input to occupancy, whereas how long it actually took belongs to the session. Overwriting them would destroy the comparison between the two. Who performed it is recorded on the session rather than inferred from the appointment's planned staff: when a colleague covers a sick operator, the medical record must say who actually held the device. Endpoints live under /api/v1/dashboard/staff because StaffRouteGuardSubscriber closes everything else to staff-only users. Opening a second door through its allowlist would put the access boundary in two places. Co-Authored-By: Claude Opus 5 (1M context) --- docs/api/treatment.md | 135 ++++++- .../Controller/SessionExecutionController.php | 221 +++++++++++ src/Treatment/Service/SessionExecutor.php | 185 +++++++++ tests/Treatment/SessionExecutionTest.php | 360 ++++++++++++++++++ 4 files changed, 900 insertions(+), 1 deletion(-) create mode 100644 src/Treatment/Controller/SessionExecutionController.php create mode 100644 src/Treatment/Service/SessionExecutor.php create mode 100644 tests/Treatment/SessionExecutionTest.php 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')); + } +}