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
+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();