From a63de2a52c9577da7a88a3713e9005907fd801b5 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 7 Aug 2026 11:08:48 +0330 Subject: [PATCH] fix(treatment): let the operator actually record an area MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Starting a session created area records with no device, and the panel only ever read the device it never set — so every "اتمام این ناحیه" came back 422 with "دستگاه این ناحیه مشخص نیست". The backend tests passed because they sent resource_uuid explicitly; from the UI the flow was unusable end to end. The device now inherits from the appointment's resource, which the secretary already chose at booking; asking the operator again is taking one decision twice. The session screen offers a picker per area on top of that, because one session really does run bikini on an alexandrite and underarms on a diode. Treating without a device is allowed: botox is an injection, and requiring a device would make clinics invent a fake resource per injection. Sending readings with no device is still rejected — there would be no schema to validate against. A protocol whose service has no ResourceServiceOffering rows now says so in the tab where the manager is standing. It does not block booking: "no offering means any resource" is a deliberate, tested rule. But silence meant the gap surfaced only when the operator was already in front of a patient. Also adds the live timer the spec asked for, and wires slot-suggestions into the unbooked queue — the endpoint existed and tested green but no screen called it. Co-Authored-By: Claude Opus 5 (1M context) --- .../admin/components/TreatmentProtocolTab.tsx | 15 +++ assets/admin/hooks/useElapsed.ts | 40 ++++++ assets/admin/pages/StaffSessionDetailPage.tsx | 55 +++++++-- assets/admin/pages/TreatmentCasesPage.tsx | 81 ++++++++++++- assets/admin/types/index.ts | 19 +++ docs/api/treatment.md | 28 ++++- .../Controller/SessionExecutionController.php | 34 ++++-- .../TreatmentProtocolController.php | 18 ++- src/Treatment/Entity/SessionAreaRecord.php | 9 ++ src/Treatment/Service/SessionExecutor.php | 36 +++++- tests/Treatment/SessionExecutionTest.php | 114 +++++++++++++++++- 11 files changed, 412 insertions(+), 37 deletions(-) create mode 100644 assets/admin/hooks/useElapsed.ts diff --git a/assets/admin/components/TreatmentProtocolTab.tsx b/assets/admin/components/TreatmentProtocolTab.tsx index 9d2d3ad1..af6a1847 100644 --- a/assets/admin/components/TreatmentProtocolTab.tsx +++ b/assets/admin/components/TreatmentProtocolTab.tsx @@ -18,6 +18,8 @@ interface ProtocolStaff { interface TreatmentProtocol { uuid: string; + /** آیا هیچ منبعی این سرویس را ارائه می‌دهد؛ نبودش رزرو را قفل نمی‌کند ولی باید دیده شود. */ + service_has_resources?: boolean; active: boolean; total_sessions: number; supervisor: { uuid: string; name: string } | null; @@ -158,6 +160,19 @@ export default function TreatmentProtocolTab({ serviceUuid, canEdit }: { hint="سرویس‌هایی که در چند جلسه انجام می‌شوند — لیزر، بوتاکس، مزوتراپی. خاموش یعنی تک‌جلسه‌ای." /> + {enabled && protocol !== null && protocol.service_has_resources === false && ( +
+ هیچ دستگاهی به این سرویس وصل نیست + + رزرو قفل نمی‌شود، ولی منشی می‌تواند این سرویس را روی هر منبعی ثبت کند و اپراتور فرم + دستگاه درست را نمی‌بیند. در «منابع» مشخص کنید کدام دستگاه‌ها این سرویس را می‌دهند. + +
+ )} + {enabled && ( <>
diff --git a/assets/admin/hooks/useElapsed.ts b/assets/admin/hooks/useElapsed.ts new file mode 100644 index 00000000..038580b8 --- /dev/null +++ b/assets/admin/hooks/useElapsed.ts @@ -0,0 +1,40 @@ +import { useEffect, useState } from 'react'; + +/** + * مدت سپری‌شده از یک لحظه، به‌صورت زنده. + * + * `startedAt` تایم‌استمپ **سرور** است و مبنا همان می‌ماند؛ اینجا فقط هر ثانیه دوباره + * رندر می‌شود. ساعت مرورگر ممکن است چند ثانیه جلو یا عقب باشد، ولی زمانِ ثبت‌شده + * همان است که سرور نوشته — این عدد فقط برای دیدن است، نه برای ذخیره. + * + * `null` یعنی هنوز شروع نشده؛ `finishedAt` که بیاید تایمر می‌ایستد. + */ +export function useElapsed(startedAt: number | null, finishedAt: number | null = null): string | null { + const [now, setNow] = useState(() => Math.floor(Date.now() / 1000)); + + const running = startedAt !== null && finishedAt === null; + + useEffect(() => { + if (!running) return; + + const id = setInterval(() => setNow(Math.floor(Date.now() / 1000)), 1000); + + return () => clearInterval(id); + }, [running]); + + if (startedAt === null) return null; + + const seconds = Math.max(0, (finishedAt ?? now) - startedAt); + + return formatDuration(seconds); +} + +/** «۰۵:۳۲» یا «۱:۱۲:۰۴» — ساعت فقط وقتی واقعاً از یک ساعت گذشته باشد. */ +export function formatDuration(seconds: number): string { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + const pad = (n: number) => String(n).padStart(2, '0'); + + return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`; +} diff --git a/assets/admin/pages/StaffSessionDetailPage.tsx b/assets/admin/pages/StaffSessionDetailPage.tsx index 13d6ff3c..dba65b7f 100644 --- a/assets/admin/pages/StaffSessionDetailPage.tsx +++ b/assets/admin/pages/StaffSessionDetailPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; @@ -7,7 +7,8 @@ import PageHeader from '../components/ui/PageHeader'; import StatusBadge from '../components/ui/StatusBadge'; import SearchableSelect from '../components/ui/SearchableSelect'; import { formatDate } from '../lib/utils'; -import type { SessionAreaRecord, StaffSessionDetail, TreatmentFormField } from '../types'; +import { useElapsed } from '../hooks/useElapsed'; +import type { SessionAreaRecord, StaffSessionDetail, TreatmentDevice, TreatmentFormField } from '../types'; const BASE = '/api/v1/dashboard/staff'; @@ -70,6 +71,9 @@ export default function StaffSessionDetailPage() { onError: (e) => fail(e, 'ثبت اطلاعات ناحیه ناموفق بود'), }); + // پیش از هر return زودهنگام: ترتیب hookها باید در هر رندر یکی باشد. + const elapsed = useElapsed(session?.started_at ?? null, session?.finished_at ?? null); + if (isLoading) { return
در حال بارگذاری...
; } @@ -100,6 +104,11 @@ export default function StaffSessionDetailPage() { {session.appointment && تاریخ: {formatDate(session.appointment.slot_start)}} {session.performed_by && اپراتور: {session.performed_by.name}} {settled} از {areas.length} ناحیه انجام شده + {elapsed && ( + + {session.finished_at === null ? 'در حال انجام: ' : 'مدت جلسه: '}{elapsed} + + )} {!started && !finished && ( @@ -128,6 +137,7 @@ export default function StaffSessionDetailPage() { skipArea.mutate(area.uuid)} @@ -167,20 +177,25 @@ export default function StaffSessionDetailPage() { ); } -function AreaCard({ area, forms, disabled, onSkip, onComplete, saving }: { +function AreaCard({ area, devices, forms, disabled, onSkip, onComplete, saving }: { area: SessionAreaRecord; + devices: TreatmentDevice[]; forms: Record; disabled: boolean; onSkip: () => void; onComplete: (body: Record) => void; saving: boolean; }) { - const [open, setOpen] = useState(false); - const [values, setValues] = useState>({}); - const [areaNote, setAreaNote] = useState(''); - const resourceUuid = area.resource?.uuid ?? null; - const fields = resourceUuid ? forms[resourceUuid] ?? [] : []; - const settled = area.status === 'completed' || area.status === 'skipped'; + const [open, setOpen] = useState(false); + const [values, setValues] = useState>({}); + const [areaNote, setAreaNote] = useState(''); + // پیش‌فرض همان دستگاهی است که از نوبت به ارث رسیده؛ اپراتور فقط اگر لازم شد عوضش می‌کند. + const [resourceUuid, setResourceUuid] = useState(area.resource?.uuid ?? null); + const fields = resourceUuid ? forms[resourceUuid] ?? [] : []; + const settled = area.status === 'completed' || area.status === 'skipped'; + const elapsed = useElapsed(area.started_at, area.finished_at); + + useEffect(() => setResourceUuid(area.resource?.uuid ?? null), [area.resource?.uuid]); const submit = () => { const parameters: Record = {}; @@ -203,6 +218,11 @@ function AreaCard({ area, forms, disabled, onSkip, onComplete, saving }: { {area.resource && ( دستگاه: {area.resource.name} )} + {elapsed && ( + + {area.finished_at === null ? 'در حال انجام: ' : 'مدت: '}{elapsed} + + )} {settled && area.parameters && ( @@ -232,7 +252,22 @@ function AreaCard({ area, forms, disabled, onSkip, onComplete, saving }: { {!settled && open && (
- {fields.length === 0 && ( + + + {resourceUuid !== null && fields.length === 0 && ( برای این دستگاه فرمی تعریف نشده است. در «تنظیمات ← انواع منابع» می‌توانید فیلدها را تعریف کنید. diff --git a/assets/admin/pages/TreatmentCasesPage.tsx b/assets/admin/pages/TreatmentCasesPage.tsx index 48522ed9..c7bce745 100644 --- a/assets/admin/pages/TreatmentCasesPage.tsx +++ b/assets/admin/pages/TreatmentCasesPage.tsx @@ -1,12 +1,13 @@ +import { useState } from 'react'; import { Link } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; import PageHeader from '../components/ui/PageHeader'; import StatusBadge from '../components/ui/StatusBadge'; -import { formatDate } from '../lib/utils'; +import { formatDate, formatDateTime } from '../lib/utils'; import { useUrlState } from '../hooks/useUrlState'; -import type { TreatmentCaseSummary, StaffTreatmentSession } from '../types'; +import type { TreatmentCaseSummary, StaffTreatmentSession, SlotSuggestionResponse } from '../types'; const TABS = [ { id: 'cases', label: 'پرونده‌های درمان' }, @@ -161,9 +162,7 @@ function UnbookedTab() { )} - - ثبت نوبت این جلسه - +
))} @@ -171,3 +170,75 @@ function UnbookedTab() { ); } + +/** + * وقت‌های آزادِ همان دستگاهی که جلسهٔ قبلی رویش انجام شد. + * + * پیشنهاد است نه رزرو: منشی با بیمار هماهنگ می‌کند و بعد از فرم عادی نوبت ثبتش + * می‌کند. خودکار رزرو کردن یعنی سیستم به‌جای بیمار تصمیم بگیرد و بعد او نیاید. + */ +function SlotSuggestions({ sessionUuid }: { sessionUuid: string }) { + const [open, setOpen] = useState(false); + + const { data, isLoading, isError } = useQuery({ + queryKey: ['session-slot-suggestions', sessionUuid], + queryFn: () => api.get>( + `/api/v1/treatment-session/${sessionUuid}/slot-suggestions?days=14`, + ), + enabled: open, + staleTime: 60_000, + }); + + if (!open) { + return ( +
+ + ثبت نوبت این جلسه +
+ ); + } + + const days = data?.data?.days ?? []; + + return ( +
+ {isLoading && در حال جست‌وجوی وقت...} + + {isError && ( + + دستگاهی برای پیشنهاد وقت مشخص نیست — این جلسه هنوز روی هیچ دستگاهی انجام نشده. + + )} + + {!isLoading && !isError && days.length === 0 && ( + + در دو هفتهٔ آینده وقت آزادی روی این دستگاه نیست. + + )} + + {days.slice(0, 3).map((day) => ( +
+ + {formatDate(Math.floor(new Date(day.date).getTime() / 1000))} + + {day.slots.slice(0, 6).map((slot) => ( + + {slot.start_time} + + ))} +
+ ))} + + +
+ ); +} diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index 1bad0d27..e691a83f 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -1342,8 +1342,27 @@ export interface TreatmentFormField { sort_order?: number; } +/** وقت‌های آزادِ پیشنهادی برای جلسهٔ بعد. */ +export interface SlotSuggestionResponse { + resource_uuid: string; + from: number; + days: Array<{ + date: string; + slots: Array<{ start: number; end: number; start_time: string; end_time: string }>; + }>; +} + +/** دستگاهی که اپراتور می‌تواند برای یک ناحیه انتخاب کند. */ +export interface TreatmentDevice { + uuid: string; + name: string; + type: string; +} + export interface StaffSessionDetail extends TreatmentSessionSummary { case: TreatmentCaseSummary; + /** دستگاه‌های فعالِ همین محیط — ناحیه‌ها می‌توانند دستگاه متفاوت داشته باشند. */ + devices: TreatmentDevice[]; /** uuid منبع => فیلدهای فرمش */ forms: Record; } diff --git a/docs/api/treatment.md b/docs/api/treatment.md index 5e6f456d..6a20f67b 100644 --- a/docs/api/treatment.md +++ b/docs/api/treatment.md @@ -44,6 +44,16 @@ late shifts the rest of their course rather than getting the next session too ea `data: null` یعنی سوییچ خاموش است، نه اینکه چیزی پیدا نشد. +وقتی پروتکل هست، یک کلید کمکی هم می‌آید: + +| Field | توضیح | +|---|---| +| `service_has_resources` | آیا هیچ `ResourceServiceOffering` برای این سرویس هست | + +`false` رزرو را **قفل نمی‌کند** — قاعدهٔ «سرویس بدون offering روی هر منبعی مجاز است» عمدی و +تست‌شده است. ولی مدیر باید ببیند، وگرنه تازه وقتی اپراتور جلوی بیمار می‌رسد معلوم می‌شود هیچ +دستگاهی وصل نشده و فرم درست نمی‌آید. + ### Response `200` ```json { @@ -266,7 +276,7 @@ single-session again. Idempotent: deleting a service that has no protocol still | Method | Path | کار | |---|---|---| | GET | `/dashboard/staff/treatment-sessions` | جلسات امروزِ همین پرسنل | -| GET | `/dashboard/staff/treatment-session/{uuid}` | جزئیات جلسه + نواحی + `forms` | +| GET | `/dashboard/staff/treatment-session/{uuid}` | جزئیات جلسه + نواحی + `devices` + `forms` | | POST | `/dashboard/staff/treatment-session/{uuid}/start` | شروع جلسه | | POST | `/dashboard/staff/treatment-session/{uuid}/finish` | اتمام جلسه | | POST | `/dashboard/staff/session-area/{uuid}/start` | شروع یک ناحیه | @@ -276,6 +286,11 @@ single-session again. Idempotent: deleting a service that has no protocol still ### شروع جلسه رکوردِ هر ناحیهٔ پرونده یک بار ساخته می‌شود، پس فراخوانی دوباره ناحیهٔ تکراری نمی‌سازد. + +**دستگاه از نوبت به ارث می‌رسد.** هر رکورد ناحیه با `Appointment.resource` ساخته می‌شود؛ منشی +همان لحظهٔ رزرو انتخابش کرده و پرسیدن دوباره‌اش از اپراتور یعنی یک تصمیم را دو بار گرفتن. +اپراتور می‌تواند per ناحیه عوضش کند — همان کاری که لازم است وقتی بیکینی با الکساندرایت و زیر بغل +با دایود انجام می‌شود. پرسنلِ فراخوان به‌عنوان **انجام‌دهندهٔ واقعی** ثبت می‌شود — ممکن است با پرسنلِ برنامه‌ریزی‌شدهٔ نوبت فرق کند، و سابقهٔ پزشکی باید بگوید چه کسی واقعاً دستگاه را دست گرفت. @@ -293,6 +308,17 @@ single-session again. Idempotent: deleting a service that has no protocol still } ``` +`resource_uuid` اختیاری است: نبودنش یعنی همان دستگاهِ ارث‌رسیده، و فرستادنش یعنی اپراتور برای این +ناحیه دستگاه دیگری گذاشته. + +**درمانِ بی‌دستگاه مجاز است.** بوتاکس تزریق است نه دستگاه؛ اجبارِ دستگاه یعنی کلینیک برای هر +تزریق یک منبع ساختگی بسازد. پس ناحیه‌ای که نه دستگاه دارد و نه مقداری برایش آمده، بسته می‌شود. +ولی فرستادن `parameters` بدون دستگاه ⇒ `422` — با چه schemaیی سنجیده شود؟ + +پاسخِ `GET` دو کلید کمکی دارد: `devices` فهرست دستگاه‌های فعالِ محیط، و `forms` نگاشت +`uuid دستگاه → فیلدهایش`. پنل با همین دو، انتخابگر دستگاه و فرم متناظرش را می‌سازد بدون اینکه +چیزی دربارهٔ لیزر بداند. + `parameters` با `field_schema`ِ **نوع همان منبع** سنجیده می‌شود — قواعدش در [resource.md](./resource.md#فرم-ثبت-درمان). کلید ناشناخته، مقدار خارج از گزینه‌ها و فیلد الزامیِ نیامده هر سه `422` می‌گیرند. diff --git a/src/Treatment/Controller/SessionExecutionController.php b/src/Treatment/Controller/SessionExecutionController.php index dcdb23e4..c43ec31a 100644 --- a/src/Treatment/Controller/SessionExecutionController.php +++ b/src/Treatment/Controller/SessionExecutionController.php @@ -74,10 +74,23 @@ class SessionExecutionController extends BaseController { $session = $this->requireSession($user, $uuid); + [$entityType, $entityId] = $this->pair($user); + $devices = $this->resources->findForPair($entityType, $entityId, ['active' => true]); + return $this->success($session->toArray(withAreas: true) + [ - 'case' => $session->getTreatmentCase()->toArray(), - // فرمِ هر ناحیه از نوع منبعش می‌آید؛ پنل نباید فیلدها را حدس بزند. - 'forms' => $this->formsFor($session), + 'case' => $session->getTreatmentCase()->toArray(), + // دستگاه‌های قابل انتخاب — اپراتور باید بتواند دستگاهِ یک ناحیه را عوض کند، + // مثل بیکینی با الکساندرایت و زیر بغل با دایود در همان جلسه. + 'devices' => array_map( + static fn (ClinicResource $r): array => [ + 'uuid' => $r->getUuid(), + 'name' => $r->getName(), + 'type' => $r->getType()->getCode(), + ], + $devices, + ), + // فرمِ هر دستگاه از نوعش می‌آید؛ پنل نباید فیلدها را حدس بزند. + 'forms' => $this->formsFor($devices), ]); } @@ -137,17 +150,16 @@ class SessionExecutionController extends BaseController return $this->success($this->executor->skipArea($this->requireAreaRecord($user, $uuid))->toArray()); } - /** @return array>> uuid منبع => تعریف فیلدها */ - private function formsFor(TreatmentSession $session): array + /** + * @param list $devices + * @return array>> uuid منبع => تعریف فیلدها + */ + private function formsFor(array $devices): array { $forms = []; - foreach ($session->getAreaRecords() as $record) { - $resource = $record->getResource(); - - if ($resource !== null) { - $forms[$resource->getUuid()] = $resource->getType()->getFieldSchema() ?? []; - } + foreach ($devices as $device) { + $forms[$device->getUuid()] = $device->getType()->getFieldSchema() ?? []; } return $forms; diff --git a/src/Treatment/Controller/TreatmentProtocolController.php b/src/Treatment/Controller/TreatmentProtocolController.php index 85b96568..8873d639 100644 --- a/src/Treatment/Controller/TreatmentProtocolController.php +++ b/src/Treatment/Controller/TreatmentProtocolController.php @@ -9,6 +9,7 @@ use App\Doctor\Service\AddressResolver; use App\Shared\Constant\ErrorCodes; use App\Shared\Controller\BaseController; use App\Shared\Exception\AppException; +use App\Resource\Repository\ResourceServiceOfferingRepository; use App\Treatment\Repository\TreatmentProtocolRepository; use App\Treatment\Service\TreatmentProtocolWriter; use Doctrine\ORM\EntityManagerInterface; @@ -27,6 +28,7 @@ class TreatmentProtocolController extends BaseController private readonly TreatmentProtocolRepository $protocols, private readonly ServiceItemRepository $items, private readonly TreatmentProtocolWriter $writer, + private readonly ResourceServiceOfferingRepository $offerings, private readonly AddressResolver $branches, private readonly EntityManagerInterface $em, ) {} @@ -35,9 +37,21 @@ class TreatmentProtocolController extends BaseController #[Route('/api/v1/service-item/{uuid}/treatment-protocol', name: 'treatment_protocol_show', methods: ['GET'])] public function show(#[CurrentUser] User $user, string $uuid): JsonResponse { - $protocol = $this->protocols->findForService($this->requireItem($user, $uuid)); + $service = $this->requireItem($user, $uuid); + $protocol = $this->protocols->findForService($service); - return $this->success($protocol?->toArray()); + if ($protocol === null) { + return $this->success(null); + } + + /** + * سرویسی که هیچ منبعی ارائه‌اش نمی‌دهد قفل نمی‌شود — قاعدهٔ «بدون offering + * یعنی همه مجاز» عمدی و مستند است. ولی مدیر باید ببیند، وگرنه تازه وقتی + * اپراتور جلوی بیمار می‌رسد معلوم می‌شود دستگاهی وصل نشده. + */ + return $this->success($protocol->toArray() + [ + 'service_has_resources' => $this->offerings->hasAnyFor($service), + ]); } #[Route('/api/v1/service-item/{uuid}/treatment-protocol', name: 'treatment_protocol_replace', methods: ['PUT'])] diff --git a/src/Treatment/Entity/SessionAreaRecord.php b/src/Treatment/Entity/SessionAreaRecord.php index c03ec9ae..10500f8e 100644 --- a/src/Treatment/Entity/SessionAreaRecord.php +++ b/src/Treatment/Entity/SessionAreaRecord.php @@ -95,6 +95,15 @@ class SessionAreaRecord public function getFinishedAt(): ?int { return $this->finishedAt; } public function getNote(): ?string { return $this->note; } + /** دستگاهِ پیش‌فرض، پیش از آنکه اپراتور کاری کند — از منبعِ نوبت. */ + public function assignResource(?ClinicResource $resource): self + { + $this->resource = $resource; + $this->touch(); + + return $this; + } + public function start(?ClinicResource $resource = null): self { $this->status = self::STATUS_IN_PROGRESS; diff --git a/src/Treatment/Service/SessionExecutor.php b/src/Treatment/Service/SessionExecutor.php index b3a349a2..f1e2437c 100644 --- a/src/Treatment/Service/SessionExecutor.php +++ b/src/Treatment/Service/SessionExecutor.php @@ -84,11 +84,18 @@ final class SessionExecutor $resource ??= $record->getResource(); - if ($resource === null) { - throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'دستگاه این ناحیه مشخص نیست', 422, 'resource_uuid'); + // درمانِ بی‌دستگاه واقعی است: بوتاکس تزریق است، نه دستگاه. اجبارِ دستگاه یعنی + // کلینیک برای هر تزریق یک منبع ساختگی بسازد تا مدل راضی شود. + if ($resource === null && $parameters !== null && $parameters !== []) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_002, + 'برای ثبت این اطلاعات باید دستگاه مشخص شود', + 422, + 'resource_uuid', + ); } - $clean = $this->fieldSchema->validateValues($resource->getType()->getFieldSchema(), $parameters); + $clean = $this->fieldSchema->validateValues($resource?->getType()->getFieldSchema(), $parameters); $record->complete($resource, $clean, $note); $this->em->flush(); @@ -141,7 +148,14 @@ final class SessionExecutor return ['session' => $session, 'unsettled_areas' => $unsettled]; } - /** رکورد هر ناحیهٔ پرونده، یک بار per جلسه. */ + /** + * رکورد هر ناحیهٔ پرونده، یک بار per جلسه. + * + * دستگاه از منبعِ خودِ نوبت به ارث می‌رسد: منشی همان لحظهٔ رزرو انتخابش کرده و + * پرسیدن دوباره‌اش از اپراتور یعنی یک تصمیم را دو بار گرفتن. اپراتور می‌تواند + * per ناحیه عوضش کند — همان کاری که وقتی بیکینی با الکساندرایت و زیر بغل با + * دایود انجام می‌شود لازم است. + */ private function ensureAreaRecords(TreatmentSession $session): void { $existing = []; @@ -149,10 +163,20 @@ final class SessionExecutor $existing[(int) $record->getCaseArea()->getId()] = true; } + $inherited = $session->getAppointment()?->getResource(); + foreach ($session->getTreatmentCase()->getAreas() as $area) { - if (!isset($existing[(int) $area->getId()])) { - $session->addAreaRecord(new SessionAreaRecord($session, $area)); + if (isset($existing[(int) $area->getId()])) { + continue; } + + $record = new SessionAreaRecord($session, $area); + + if ($inherited !== null) { + $record->assignResource($inherited); + } + + $session->addAreaRecord($record); } } diff --git a/tests/Treatment/SessionExecutionTest.php b/tests/Treatment/SessionExecutionTest.php index f826832f..eec9f3bb 100644 --- a/tests/Treatment/SessionExecutionTest.php +++ b/tests/Treatment/SessionExecutionTest.php @@ -41,7 +41,7 @@ class SessionExecutionTest extends ApiTestCase * @return array{staffUser: User, staff: ClinicStaff, session: TreatmentSession, * resource: ClinicResource, case: TreatmentCase, appointment: Appointment} */ - private function scenario(int $areaCount = 2): array + private function scenario(int $areaCount = 2, bool $withResource = true): array { $owner = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']); $clinic = new Clinic($owner); @@ -108,7 +108,9 @@ class SessionExecutionTest extends ApiTestCase } $appointment = $this->newAppointment($doctor, $record->getUser(), time() + 3600, time() + 5400, $clinic); - $appointment->setResource($resource); + if ($withResource) { + $appointment->setResource($resource); + } $appointment->setStaff($staff); $appointment->transitionTo(Appointment::STATUS_CONFIRMED); $this->em->persist($appointment); @@ -125,6 +127,9 @@ class SessionExecutionTest extends ApiTestCase ->upsert($staffUser, $clinic->getUuid(), EntityContext::TYPE_CLINIC); return [ + 'clinic' => $clinic, + 'address' => $address, + 'doctor' => $doctor, 'staffUser' => $staffUser, 'staff' => $staff, 'session' => $session, @@ -134,6 +139,26 @@ class SessionExecutionTest extends ApiTestCase ]; } + /** دستگاه دومِ همان محیط — برای سنجیدن تعویض دستگاه در سطح ناحیه. */ + private function otherDevice(array $s): ClinicResource + { + $type = new ResourceType('clinic', (int) $s['clinic']->getId(), 'laser_' . bin2hex(random_bytes(3)), 'لیزر دوم'); + $type->setFieldSchema(self::LASER_SCHEMA); + $this->em->persist($type); + $this->em->flush(); + + // درخواست‌های کرنل نمونه‌های محلی را جدا می‌کنند؛ دوباره از همین EM خوانده می‌شوند. + $address = $this->em->getRepository(DoctorAddress::class)->find($s['address']->getId()); + $doctor = $this->em->getRepository(Doctor::class)->find($s['doctor']->getId()); + + $resource = new ClinicResource($address, $type, 'Alexandrite Laser'); + $resource->setSupervisor($doctor); + $this->em->persist($resource); + $this->em->flush(); + + return $resource; + } + private function areaRecords(TreatmentSession $session): array { $records = $this->em->getRepository(SessionAreaRecord::class)->findBy(['session' => $session]); @@ -181,6 +206,91 @@ class SessionExecutionTest extends ApiTestCase self::assertSame(Appointment::STATUS_SALON, $appointment->getStatus()); } + /** دستگاه از نوبت به ارث می‌رسد؛ اپراتور نباید همان تصمیم را دوباره بگیرد. */ + public function testAreaRecordsInheritTheAppointmentDevice(): 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)); + foreach ($body['data']['areas'] as $area) { + self::assertSame($s['resource']->getUuid(), $area['resource']['uuid']); + } + } + + /** با دستگاه ارث‌رسیده، اپراتور بدون فرستادن resource_uuid هم می‌تواند ببندد. */ + public function testAnAreaCanBeCompletedWithoutResendingTheDevice(): 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'], [ + 'parameters' => ['energy' => 18, 'pulse' => 3, 'shots' => 100], + ]); + + self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertSame($s['resource']->getUuid(), $body['data']['resource']['uuid']); + } + + /** اپراتور می‌تواند دستگاه یک ناحیه را عوض کند — بیکینی با یک دستگاه، زیر بغل با دیگری. */ + public function testTheOperatorCanOverrideTheDevicePerArea(): void + { + $s = $this->scenario(); + $this->authJson('POST', '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid() . '/start', $s['staffUser']); + + // دستگاه پیش از خواندن صفحه ساخته می‌شود، وگرنه در فهرست همان درخواست نیست. + $second = $this->otherDevice($s); + $body = $this->authJson('GET', '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid(), $s['staffUser']); + + self::assertContains($second->getUuid(), array_column($body['data']['devices'], 'uuid')); + + $record = $this->areaRecords($s['session'])[1]; + $body = $this->authJson('POST', '/api/v1/dashboard/staff/session-area/' . $record->getUuid() . '/complete', $s['staffUser'], [ + 'resource_uuid' => $second->getUuid(), + 'parameters' => ['energy' => 8, 'pulse' => 5, 'shots' => 50], + ]); + + self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertSame($second->getUuid(), $body['data']['resource']['uuid']); + } + + /** + * بوتاکس تزریق است، نه دستگاه — نوبتِ بی‌منبع باید بتواند ناحیه‌اش را ببندد. + * + * اجبارِ دستگاه یعنی کلینیک برای هر تزریق یک منبع ساختگی بسازد. + */ + public function testAnAreaWithoutAnyDeviceCanStillBeCompleted(): void + { + $s = $this->scenario(withResource: false); + $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'], [ + 'note' => 'تزریق انجام شد', + ]); + + self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertNull($body['data']['resource']); + self::assertNull($body['data']['parameters']); + self::assertSame('تزریق انجام شد', $body['data']['note']); + } + + /** ولی مقدار بدون دستگاه پذیرفته نمی‌شود — با چه چیزی سنجیده شود؟ */ + public function testReadingsWithoutADeviceAreRejected(): void + { + $s = $this->scenario(withResource: false); + $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'], [ + 'parameters' => ['energy' => 18], + ]); + + self::assertSame(422, $this->responseCode()); + } + public function testCompletingAnAreaStoresTheDeviceReadings(): void { $s = $this->scenario();