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
@@ -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);
}
}