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 && (
+
+ دستگاه
+ ({ value: d.uuid, label: d.name }))}
+ value={resourceUuid}
+ onChange={(v) => setResourceUuid(v === null ? null : String(v))}
+ placeholder="بدون دستگاه"
+ isClearable
+ ariaLabel={`دستگاه ناحیهٔ ${area.area.name}`}
+ />
+
+ پیشفرض همان دستگاه نوبت است. فرم زیر از روی همین دستگاه ساخته میشود.
+
+
+
+ {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 (
+
+ setOpen(true)}>
+ پیشنهاد وقت
+
+ ثبت نوبت این جلسه
+
+ );
+ }
+
+ 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}
+
+ ))}
+
+ ))}
+
+
setOpen(false)} style={{ justifySelf: 'start' }}>
+ بستن
+
+
+ );
+}
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();