fix(treatment): let the operator actually record an area

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) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-07 11:08:48 +03:30
co-authored by Claude Opus 5
parent c7fdb92df4
commit a63de2a52c
11 changed files with 412 additions and 37 deletions
@@ -18,6 +18,8 @@ interface ProtocolStaff {
interface TreatmentProtocol { interface TreatmentProtocol {
uuid: string; uuid: string;
/** آیا هیچ منبعی این سرویس را ارائه می‌دهد؛ نبودش رزرو را قفل نمی‌کند ولی باید دیده شود. */
service_has_resources?: boolean;
active: boolean; active: boolean;
total_sessions: number; total_sessions: number;
supervisor: { uuid: string; name: string } | null; supervisor: { uuid: string; name: string } | null;
@@ -158,6 +160,19 @@ export default function TreatmentProtocolTab({ serviceUuid, canEdit }: {
hint="سرویس‌هایی که در چند جلسه انجام می‌شوند — لیزر، بوتاکس، مزوتراپی. خاموش یعنی تک‌جلسه‌ای." hint="سرویس‌هایی که در چند جلسه انجام می‌شوند — لیزر، بوتاکس، مزوتراپی. خاموش یعنی تک‌جلسه‌ای."
/> />
{enabled && protocol !== null && protocol.service_has_resources === false && (
<div
className="card card-pad"
style={{ background: 'var(--warning-bg)', display: 'grid', gap: 4 }}
>
<strong style={{ fontSize: 13 }}>هیچ دستگاهی به این سرویس وصل نیست</strong>
<span style={{ fontSize: 12.5, color: 'var(--text-2)', lineHeight: 1.9 }}>
رزرو قفل نمیشود، ولی منشی میتواند این سرویس را روی هر منبعی ثبت کند و اپراتور فرم
دستگاه درست را نمیبیند. در «منابع» مشخص کنید کدام دستگاهها این سرویس را میدهند.
</span>
</div>
)}
{enabled && ( {enabled && (
<> <>
<section style={{ display: 'grid', gap: 10 }}> <section style={{ display: 'grid', gap: 10 }}>
+40
View File
@@ -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)}`;
}
+45 -10
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -7,7 +7,8 @@ import PageHeader from '../components/ui/PageHeader';
import StatusBadge from '../components/ui/StatusBadge'; import StatusBadge from '../components/ui/StatusBadge';
import SearchableSelect from '../components/ui/SearchableSelect'; import SearchableSelect from '../components/ui/SearchableSelect';
import { formatDate } from '../lib/utils'; 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'; const BASE = '/api/v1/dashboard/staff';
@@ -70,6 +71,9 @@ export default function StaffSessionDetailPage() {
onError: (e) => fail(e, 'ثبت اطلاعات ناحیه ناموفق بود'), onError: (e) => fail(e, 'ثبت اطلاعات ناحیه ناموفق بود'),
}); });
// پیش از هر return زودهنگام: ترتیب hookها باید در هر رندر یکی باشد.
const elapsed = useElapsed(session?.started_at ?? null, session?.finished_at ?? null);
if (isLoading) { if (isLoading) {
return <div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</div>; return <div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</div>;
} }
@@ -100,6 +104,11 @@ export default function StaffSessionDetailPage() {
{session.appointment && <span>تاریخ: {formatDate(session.appointment.slot_start)}</span>} {session.appointment && <span>تاریخ: {formatDate(session.appointment.slot_start)}</span>}
{session.performed_by && <span>اپراتور: {session.performed_by.name}</span>} {session.performed_by && <span>اپراتور: {session.performed_by.name}</span>}
<span>{settled} از {areas.length} ناحیه انجام شده</span> <span>{settled} از {areas.length} ناحیه انجام شده</span>
{elapsed && (
<span style={{ fontVariantNumeric: 'tabular-nums' }}>
{session.finished_at === null ? 'در حال انجام: ' : 'مدت جلسه: '}{elapsed}
</span>
)}
</div> </div>
{!started && !finished && ( {!started && !finished && (
@@ -128,6 +137,7 @@ export default function StaffSessionDetailPage() {
<AreaCard <AreaCard
key={area.uuid} key={area.uuid}
area={area} area={area}
devices={session.devices}
forms={session.forms} forms={session.forms}
disabled={finished} disabled={finished}
onSkip={() => skipArea.mutate(area.uuid)} onSkip={() => 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; area: SessionAreaRecord;
devices: TreatmentDevice[];
forms: Record<string, TreatmentFormField[]>; forms: Record<string, TreatmentFormField[]>;
disabled: boolean; disabled: boolean;
onSkip: () => void; onSkip: () => void;
onComplete: (body: Record<string, unknown>) => void; onComplete: (body: Record<string, unknown>) => void;
saving: boolean; saving: boolean;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [values, setValues] = useState<Record<string, string>>({}); const [values, setValues] = useState<Record<string, string>>({});
const [areaNote, setAreaNote] = useState(''); const [areaNote, setAreaNote] = useState('');
const resourceUuid = area.resource?.uuid ?? null; // پیش‌فرض همان دستگاهی است که از نوبت به ارث رسیده؛ اپراتور فقط اگر لازم شد عوضش می‌کند.
const fields = resourceUuid ? forms[resourceUuid] ?? [] : []; const [resourceUuid, setResourceUuid] = useState<string | null>(area.resource?.uuid ?? null);
const settled = area.status === 'completed' || area.status === 'skipped'; 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 submit = () => {
const parameters: Record<string, string> = {}; const parameters: Record<string, string> = {};
@@ -203,6 +218,11 @@ function AreaCard({ area, forms, disabled, onSkip, onComplete, saving }: {
{area.resource && ( {area.resource && (
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>دستگاه: {area.resource.name}</span> <span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>دستگاه: {area.resource.name}</span>
)} )}
{elapsed && (
<span style={{ fontSize: 12.5, color: 'var(--text-3)', fontVariantNumeric: 'tabular-nums' }}>
{area.finished_at === null ? 'در حال انجام: ' : 'مدت: '}{elapsed}
</span>
)}
</div> </div>
{settled && area.parameters && ( {settled && area.parameters && (
@@ -232,7 +252,22 @@ function AreaCard({ area, forms, disabled, onSkip, onComplete, saving }: {
{!settled && open && ( {!settled && open && (
<div style={{ display: 'grid', gap: 10 }}> <div style={{ display: 'grid', gap: 10 }}>
{fields.length === 0 && ( <label className="field" style={{ display: 'grid', gap: 4 }}>
<span style={{ fontSize: 12.5 }}>دستگاه</span>
<SearchableSelect
options={devices.map((d) => ({ value: d.uuid, label: d.name }))}
value={resourceUuid}
onChange={(v) => setResourceUuid(v === null ? null : String(v))}
placeholder="بدون دستگاه"
isClearable
ariaLabel={`دستگاه ناحیهٔ ${area.area.name}`}
/>
<span className="field-hint">
پیشفرض همان دستگاه نوبت است. فرم زیر از روی همین دستگاه ساخته میشود.
</span>
</label>
{resourceUuid !== null && fields.length === 0 && (
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}> <span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
برای این دستگاه فرمی تعریف نشده است. در «تنظیمات انواع منابع» میتوانید فیلدها را تعریف کنید. برای این دستگاه فرمی تعریف نشده است. در «تنظیمات انواع منابع» میتوانید فیلدها را تعریف کنید.
</span> </span>
+76 -5
View File
@@ -1,12 +1,13 @@
import { useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { api } from '../lib/api'; import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api'; import type { ApiResponse } from '../lib/api';
import PageHeader from '../components/ui/PageHeader'; import PageHeader from '../components/ui/PageHeader';
import StatusBadge from '../components/ui/StatusBadge'; import StatusBadge from '../components/ui/StatusBadge';
import { formatDate } from '../lib/utils'; import { formatDate, formatDateTime } from '../lib/utils';
import { useUrlState } from '../hooks/useUrlState'; import { useUrlState } from '../hooks/useUrlState';
import type { TreatmentCaseSummary, StaffTreatmentSession } from '../types'; import type { TreatmentCaseSummary, StaffTreatmentSession, SlotSuggestionResponse } from '../types';
const TABS = [ const TABS = [
{ id: 'cases', label: 'پرونده‌های درمان' }, { id: 'cases', label: 'پرونده‌های درمان' },
@@ -161,9 +162,7 @@ function UnbookedTab() {
</span> </span>
)} )}
<Link to="/admin/appointments/new" className="btn primary sm" style={{ justifySelf: 'start' }}> <SlotSuggestions sessionUuid={s.uuid} />
ثبت نوبت این جلسه
</Link>
</div> </div>
))} ))}
</div> </div>
@@ -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<ApiResponse<SlotSuggestionResponse>>(
`/api/v1/treatment-session/${sessionUuid}/slot-suggestions?days=14`,
),
enabled: open,
staleTime: 60_000,
});
if (!open) {
return (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button type="button" className="btn secondary sm" onClick={() => setOpen(true)}>
پیشنهاد وقت
</button>
<Link to="/admin/appointments/new" className="btn primary sm">ثبت نوبت این جلسه</Link>
</div>
);
}
const days = data?.data?.days ?? [];
return (
<div style={{ display: 'grid', gap: 8 }}>
{isLoading && <span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>در حال جستوجوی وقت...</span>}
{isError && (
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
دستگاهی برای پیشنهاد وقت مشخص نیست این جلسه هنوز روی هیچ دستگاهی انجام نشده.
</span>
)}
{!isLoading && !isError && days.length === 0 && (
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
در دو هفتهٔ آینده وقت آزادی روی این دستگاه نیست.
</span>
)}
{days.slice(0, 3).map((day) => (
<div key={day.date} style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
<span style={{ fontSize: 12.5, minWidth: 96, color: 'var(--text-2)' }}>
{formatDate(Math.floor(new Date(day.date).getTime() / 1000))}
</span>
{day.slots.slice(0, 6).map((slot) => (
<Link
key={slot.start}
to={`/admin/appointments/new?slot_start=${slot.start}&resource_uuid=${data?.data?.resource_uuid ?? ''}`}
className="btn secondary sm"
title={formatDateTime(slot.start)}
>
{slot.start_time}
</Link>
))}
</div>
))}
<button type="button" className="btn ghost sm" onClick={() => setOpen(false)} style={{ justifySelf: 'start' }}>
بستن
</button>
</div>
);
}
+19
View File
@@ -1342,8 +1342,27 @@ export interface TreatmentFormField {
sort_order?: number; 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 { export interface StaffSessionDetail extends TreatmentSessionSummary {
case: TreatmentCaseSummary; case: TreatmentCaseSummary;
/** دستگاه‌های فعالِ همین محیط — ناحیه‌ها می‌توانند دستگاه متفاوت داشته باشند. */
devices: TreatmentDevice[];
/** uuid منبع => فیلدهای فرمش */ /** uuid منبع => فیلدهای فرمش */
forms: Record<string, TreatmentFormField[]>; forms: Record<string, TreatmentFormField[]>;
} }
+27 -1
View File
@@ -44,6 +44,16 @@ late shifts the rest of their course rather than getting the next session too ea
`data: null` یعنی سوییچ خاموش است، نه اینکه چیزی پیدا نشد. `data: null` یعنی سوییچ خاموش است، نه اینکه چیزی پیدا نشد.
وقتی پروتکل هست، یک کلید کمکی هم می‌آید:
| Field | توضیح |
|---|---|
| `service_has_resources` | آیا هیچ `ResourceServiceOffering` برای این سرویس هست |
`false` رزرو را **قفل نمی‌کند** — قاعدهٔ «سرویس بدون offering روی هر منبعی مجاز است» عمدی و
تست‌شده است. ولی مدیر باید ببیند، وگرنه تازه وقتی اپراتور جلوی بیمار می‌رسد معلوم می‌شود هیچ
دستگاهی وصل نشده و فرم درست نمی‌آید.
### Response `200` ### Response `200`
```json ```json
{ {
@@ -266,7 +276,7 @@ single-session again. Idempotent: deleting a service that has no protocol still
| Method | Path | کار | | Method | Path | کار |
|---|---|---| |---|---|---|
| GET | `/dashboard/staff/treatment-sessions` | جلسات امروزِ همین پرسنل | | 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}/start` | شروع جلسه |
| POST | `/dashboard/staff/treatment-session/{uuid}/finish` | اتمام جلسه | | POST | `/dashboard/staff/treatment-session/{uuid}/finish` | اتمام جلسه |
| POST | `/dashboard/staff/session-area/{uuid}/start` | شروع یک ناحیه | | 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`ِ **نوع همان منبع** سنجیده می‌شود — قواعدش در `parameters` با `field_schema`ِ **نوع همان منبع** سنجیده می‌شود — قواعدش در
[resource.md](./resource.md#فرم-ثبت-درمان). کلید ناشناخته، مقدار خارج از گزینه‌ها و فیلد [resource.md](./resource.md#فرم-ثبت-درمان). کلید ناشناخته، مقدار خارج از گزینه‌ها و فیلد
الزامیِ نیامده هر سه `422` می‌گیرند. الزامیِ نیامده هر سه `422` می‌گیرند.
@@ -74,10 +74,23 @@ class SessionExecutionController extends BaseController
{ {
$session = $this->requireSession($user, $uuid); $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) + [ return $this->success($session->toArray(withAreas: true) + [
'case' => $session->getTreatmentCase()->toArray(), 'case' => $session->getTreatmentCase()->toArray(),
// فرمِ هر ناحیه از نوع منبعش می‌آید؛ پنل نباید فیلدها را حدس بزند. // دستگاه‌های قابل انتخاب — اپراتور باید بتواند دستگاهِ یک ناحیه را عوض کند،
'forms' => $this->formsFor($session), // مثل بیکینی با الکساندرایت و زیر بغل با دایود در همان جلسه.
'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 $this->success($this->executor->skipArea($this->requireAreaRecord($user, $uuid))->toArray());
} }
/** @return array<string, list<array<string, mixed>>> uuid منبع => تعریف فیلدها */ /**
private function formsFor(TreatmentSession $session): array * @param list<ClinicResource> $devices
* @return array<string, list<array<string, mixed>>> uuid منبع => تعریف فیلدها
*/
private function formsFor(array $devices): array
{ {
$forms = []; $forms = [];
foreach ($session->getAreaRecords() as $record) { foreach ($devices as $device) {
$resource = $record->getResource(); $forms[$device->getUuid()] = $device->getType()->getFieldSchema() ?? [];
if ($resource !== null) {
$forms[$resource->getUuid()] = $resource->getType()->getFieldSchema() ?? [];
}
} }
return $forms; return $forms;
@@ -9,6 +9,7 @@ use App\Doctor\Service\AddressResolver;
use App\Shared\Constant\ErrorCodes; use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController; use App\Shared\Controller\BaseController;
use App\Shared\Exception\AppException; use App\Shared\Exception\AppException;
use App\Resource\Repository\ResourceServiceOfferingRepository;
use App\Treatment\Repository\TreatmentProtocolRepository; use App\Treatment\Repository\TreatmentProtocolRepository;
use App\Treatment\Service\TreatmentProtocolWriter; use App\Treatment\Service\TreatmentProtocolWriter;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@@ -27,6 +28,7 @@ class TreatmentProtocolController extends BaseController
private readonly TreatmentProtocolRepository $protocols, private readonly TreatmentProtocolRepository $protocols,
private readonly ServiceItemRepository $items, private readonly ServiceItemRepository $items,
private readonly TreatmentProtocolWriter $writer, private readonly TreatmentProtocolWriter $writer,
private readonly ResourceServiceOfferingRepository $offerings,
private readonly AddressResolver $branches, private readonly AddressResolver $branches,
private readonly EntityManagerInterface $em, 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'])] #[Route('/api/v1/service-item/{uuid}/treatment-protocol', name: 'treatment_protocol_show', methods: ['GET'])]
public function show(#[CurrentUser] User $user, string $uuid): JsonResponse 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'])] #[Route('/api/v1/service-item/{uuid}/treatment-protocol', name: 'treatment_protocol_replace', methods: ['PUT'])]
@@ -95,6 +95,15 @@ class SessionAreaRecord
public function getFinishedAt(): ?int { return $this->finishedAt; } public function getFinishedAt(): ?int { return $this->finishedAt; }
public function getNote(): ?string { return $this->note; } 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 public function start(?ClinicResource $resource = null): self
{ {
$this->status = self::STATUS_IN_PROGRESS; $this->status = self::STATUS_IN_PROGRESS;
+30 -6
View File
@@ -84,11 +84,18 @@ final class SessionExecutor
$resource ??= $record->getResource(); $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); $record->complete($resource, $clean, $note);
$this->em->flush(); $this->em->flush();
@@ -141,7 +148,14 @@ final class SessionExecutor
return ['session' => $session, 'unsettled_areas' => $unsettled]; return ['session' => $session, 'unsettled_areas' => $unsettled];
} }
/** رکورد هر ناحیهٔ پرونده، یک بار per جلسه. */ /**
* رکورد هر ناحیهٔ پرونده، یک بار per جلسه.
*
* دستگاه از منبعِ خودِ نوبت به ارث می‌رسد: منشی همان لحظهٔ رزرو انتخابش کرده و
* پرسیدن دوباره‌اش از اپراتور یعنی یک تصمیم را دو بار گرفتن. اپراتور می‌تواند
* per ناحیه عوضش کند — همان کاری که وقتی بیکینی با الکساندرایت و زیر بغل با
* دایود انجام می‌شود لازم است.
*/
private function ensureAreaRecords(TreatmentSession $session): void private function ensureAreaRecords(TreatmentSession $session): void
{ {
$existing = []; $existing = [];
@@ -149,10 +163,20 @@ final class SessionExecutor
$existing[(int) $record->getCaseArea()->getId()] = true; $existing[(int) $record->getCaseArea()->getId()] = true;
} }
$inherited = $session->getAppointment()?->getResource();
foreach ($session->getTreatmentCase()->getAreas() as $area) { foreach ($session->getTreatmentCase()->getAreas() as $area) {
if (!isset($existing[(int) $area->getId()])) { if (isset($existing[(int) $area->getId()])) {
$session->addAreaRecord(new SessionAreaRecord($session, $area)); continue;
} }
$record = new SessionAreaRecord($session, $area);
if ($inherited !== null) {
$record->assignResource($inherited);
}
$session->addAreaRecord($record);
} }
} }
+112 -2
View File
@@ -41,7 +41,7 @@ class SessionExecutionTest extends ApiTestCase
* @return array{staffUser: User, staff: ClinicStaff, session: TreatmentSession, * @return array{staffUser: User, staff: ClinicStaff, session: TreatmentSession,
* resource: ClinicResource, case: TreatmentCase, appointment: Appointment} * 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']); $owner = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($owner); $clinic = new Clinic($owner);
@@ -108,7 +108,9 @@ class SessionExecutionTest extends ApiTestCase
} }
$appointment = $this->newAppointment($doctor, $record->getUser(), time() + 3600, time() + 5400, $clinic); $appointment = $this->newAppointment($doctor, $record->getUser(), time() + 3600, time() + 5400, $clinic);
$appointment->setResource($resource); if ($withResource) {
$appointment->setResource($resource);
}
$appointment->setStaff($staff); $appointment->setStaff($staff);
$appointment->transitionTo(Appointment::STATUS_CONFIRMED); $appointment->transitionTo(Appointment::STATUS_CONFIRMED);
$this->em->persist($appointment); $this->em->persist($appointment);
@@ -125,6 +127,9 @@ class SessionExecutionTest extends ApiTestCase
->upsert($staffUser, $clinic->getUuid(), EntityContext::TYPE_CLINIC); ->upsert($staffUser, $clinic->getUuid(), EntityContext::TYPE_CLINIC);
return [ return [
'clinic' => $clinic,
'address' => $address,
'doctor' => $doctor,
'staffUser' => $staffUser, 'staffUser' => $staffUser,
'staff' => $staff, 'staff' => $staff,
'session' => $session, '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 private function areaRecords(TreatmentSession $session): array
{ {
$records = $this->em->getRepository(SessionAreaRecord::class)->findBy(['session' => $session]); $records = $this->em->getRepository(SessionAreaRecord::class)->findBy(['session' => $session]);
@@ -181,6 +206,91 @@ class SessionExecutionTest extends ApiTestCase
self::assertSame(Appointment::STATUS_SALON, $appointment->getStatus()); 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 public function testCompletingAnAreaStoresTheDeviceReadings(): void
{ {
$s = $this->scenario(); $s = $this->scenario();