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:
@@ -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 && (
|
||||
<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 && (
|
||||
<>
|
||||
<section style={{ display: 'grid', gap: 10 }}>
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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 <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.performed_by && <span>اپراتور: {session.performed_by.name}</span>}
|
||||
<span>{settled} از {areas.length} ناحیه انجام شده</span>
|
||||
{elapsed && (
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
{session.finished_at === null ? 'در حال انجام: ' : 'مدت جلسه: '}{elapsed}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!started && !finished && (
|
||||
@@ -128,6 +137,7 @@ export default function StaffSessionDetailPage() {
|
||||
<AreaCard
|
||||
key={area.uuid}
|
||||
area={area}
|
||||
devices={session.devices}
|
||||
forms={session.forms}
|
||||
disabled={finished}
|
||||
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;
|
||||
devices: TreatmentDevice[];
|
||||
forms: Record<string, TreatmentFormField[]>;
|
||||
disabled: boolean;
|
||||
onSkip: () => void;
|
||||
onComplete: (body: Record<string, unknown>) => void;
|
||||
saving: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
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<Record<string, string>>({});
|
||||
const [areaNote, setAreaNote] = useState('');
|
||||
// پیشفرض همان دستگاهی است که از نوبت به ارث رسیده؛ اپراتور فقط اگر لازم شد عوضش میکند.
|
||||
const [resourceUuid, setResourceUuid] = useState<string | null>(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<string, string> = {};
|
||||
@@ -203,6 +218,11 @@ function AreaCard({ area, forms, disabled, onSkip, onComplete, saving }: {
|
||||
{area.resource && (
|
||||
<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>
|
||||
|
||||
{settled && area.parameters && (
|
||||
@@ -232,7 +252,22 @@ function AreaCard({ area, forms, disabled, onSkip, onComplete, saving }: {
|
||||
|
||||
{!settled && open && (
|
||||
<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>
|
||||
|
||||
@@ -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() {
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Link to="/admin/appointments/new" className="btn primary sm" style={{ justifySelf: 'start' }}>
|
||||
ثبت نوبت این جلسه
|
||||
</Link>
|
||||
<SlotSuggestions sessionUuid={s.uuid} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, TreatmentFormField[]>;
|
||||
}
|
||||
|
||||
+27
-1
@@ -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` میگیرند.
|
||||
|
||||
@@ -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<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 = [];
|
||||
|
||||
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;
|
||||
|
||||
@@ -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'])]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user