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 {
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 }}>
+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 { 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>
+76 -5
View File
@@ -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>
);
}
+19
View File
@@ -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
View File
@@ -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;
+30 -6
View File
@@ -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);
}
}
+112 -2
View File
@@ -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();