feat(treatment): run a session from the staff panel, area by area
The operator opens the session, treats each body area on its own device and records what that device was set to. Readings are validated against the resource type's field schema, so a laser form and an RF form each enforce their own rules without this code naming either. Finishing is allowed with areas still open — the operator is standing in front of a patient and must not be trapped by the software — but the count comes back so the panel can warn. Session state mirrors onto the appointment (salon, then completed) while its slot times are never rewritten: those are the reservation's promise and the input to occupancy, whereas how long it actually took belongs to the session. Overwriting them would destroy the comparison between the two. Who performed it is recorded on the session rather than inferred from the appointment's planned staff: when a colleague covers a sick operator, the medical record must say who actually held the device. Endpoints live under /api/v1/dashboard/staff because StaffRouteGuardSubscriber closes everything else to staff-only users. Opening a second door through its allowlist would put the access boundary in two places. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Treatment;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Repository\UserActiveContextRepository;
|
||||
use App\Shared\Context\EntityContext;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicService\Entity\CatalogCategory;
|
||||
use App\ClinicService\Entity\CatalogCategoryInclude;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Resource\Entity\ClinicResource;
|
||||
use App\Resource\Entity\ResourceType;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use App\Tests\ApiTestCase;
|
||||
use App\Treatment\Entity\SessionAreaRecord;
|
||||
use App\Treatment\Entity\TreatmentCase;
|
||||
use App\Treatment\Entity\TreatmentCaseArea;
|
||||
use App\Treatment\Entity\TreatmentProtocol;
|
||||
use App\Treatment\Entity\TreatmentProtocolStaff;
|
||||
use App\Treatment\Entity\TreatmentProtocolStep;
|
||||
use App\Treatment\Entity\TreatmentSession;
|
||||
|
||||
/**
|
||||
* پنل پرسنل: از «شروع جلسه» تا «انجام شد».
|
||||
*/
|
||||
class SessionExecutionTest extends ApiTestCase
|
||||
{
|
||||
private const LASER_SCHEMA = [
|
||||
['key' => 'energy', 'label' => 'انرژی', 'type' => 'select', 'required' => true, 'sort_order' => 0, 'options' => [7, 8, 18]],
|
||||
['key' => 'pulse', 'label' => 'پالس', 'type' => 'select', 'required' => true, 'sort_order' => 1, 'options' => [3, 5]],
|
||||
['key' => 'shots', 'label' => 'شات', 'type' => 'number', 'required' => true, 'sort_order' => 2],
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array{staffUser: User, staff: ClinicStaff, session: TreatmentSession,
|
||||
* resource: ClinicResource, case: TreatmentCase, appointment: Appointment}
|
||||
*/
|
||||
private function scenario(int $areaCount = 2): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||
$clinic = new Clinic($owner);
|
||||
$clinic->setName('کلینیک اجرای جلسه');
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
$doctor = new Doctor($this->createUser(['ROLE_USER', 'ROLE_DOCTOR']), 'دکتر ناظر');
|
||||
$this->em->persist($doctor);
|
||||
$clinic->getDoctors()->add($doctor);
|
||||
|
||||
$address = DoctorAddress::forClinic($clinic->getId());
|
||||
$address->setName('شعبهٔ مرکزی');
|
||||
$this->em->persist($address);
|
||||
|
||||
$type = new ResourceType('clinic', (int) $clinic->getId(), 'laser_' . bin2hex(random_bytes(3)), 'دستگاه لیزر');
|
||||
$type->setFieldSchema(self::LASER_SCHEMA);
|
||||
$this->em->persist($type);
|
||||
$this->em->flush();
|
||||
|
||||
$resource = new ClinicResource($address, $type, 'Diode Laser 808nm');
|
||||
$resource->setSupervisor($doctor);
|
||||
$this->em->persist($resource);
|
||||
|
||||
$section = new ServiceSection('clinic', (int) $clinic->getId(), 'لیزر');
|
||||
$this->em->persist($section);
|
||||
|
||||
$parent = new CatalogCategory('clinic', (int) $clinic->getId(), 'توتال');
|
||||
$this->em->persist($parent);
|
||||
|
||||
$areas = [];
|
||||
foreach (array_slice(['بیکینی', 'زیر بغل', 'دست'], 0, $areaCount) as $name) {
|
||||
$child = new CatalogCategory('clinic', (int) $clinic->getId(), $name);
|
||||
$this->em->persist($child);
|
||||
$this->em->flush();
|
||||
$this->em->persist(new CatalogCategoryInclude($parent, $child));
|
||||
$areas[] = $child;
|
||||
}
|
||||
|
||||
$service = new ServiceItem($section, 'لیزر توتال', 10_000_000);
|
||||
$service->setCatalogCategory($parent)->setDurationMinutes(30);
|
||||
$this->em->persist($service);
|
||||
|
||||
$staffUser = $this->createUser(['ROLE_USER', 'ROLE_STAFF']);
|
||||
$staff = new ClinicStaff('clinic', (int) $clinic->getId(), 'اپراتور یک');
|
||||
$staff->setUser($staffUser);
|
||||
$this->em->persist($staff);
|
||||
|
||||
$protocol = new TreatmentProtocol($service);
|
||||
$this->em->persist($protocol);
|
||||
$protocol->replaceSteps([
|
||||
new TreatmentProtocolStep($protocol, 1, 0),
|
||||
new TreatmentProtocolStep($protocol, 2, 30),
|
||||
]);
|
||||
$protocol->replaceAllowedStaff([new TreatmentProtocolStaff($protocol, $staff)]);
|
||||
|
||||
$record = new PatientRecord('clinic', (int) $clinic->getId(), $this->createUser(), 'clinic', (int) $clinic->getId());
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
|
||||
$case = new TreatmentCase('clinic', (int) $clinic->getId(), $record, $service, $protocol);
|
||||
foreach ($areas as $i => $category) {
|
||||
$case->addArea(new TreatmentCaseArea($case, $category, $i));
|
||||
}
|
||||
|
||||
$appointment = $this->newAppointment($doctor, $record->getUser(), time() + 3600, time() + 5400, $clinic);
|
||||
$appointment->setResource($resource);
|
||||
$appointment->setStaff($staff);
|
||||
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
|
||||
$this->em->persist($appointment);
|
||||
|
||||
$session = new TreatmentSession($case, 1);
|
||||
$session->attachAppointment($appointment);
|
||||
$case->addSession($session);
|
||||
$case->addSession(new TreatmentSession($case, 2));
|
||||
$this->em->persist($case);
|
||||
$this->em->flush();
|
||||
|
||||
// نقش بهتنهایی محیط نمیسازد؛ پرسنل باید محیط فعالش ست شده باشد.
|
||||
static::getContainer()->get(UserActiveContextRepository::class)
|
||||
->upsert($staffUser, $clinic->getUuid(), EntityContext::TYPE_CLINIC);
|
||||
|
||||
return [
|
||||
'staffUser' => $staffUser,
|
||||
'staff' => $staff,
|
||||
'session' => $session,
|
||||
'resource' => $resource,
|
||||
'case' => $case,
|
||||
'appointment' => $appointment,
|
||||
];
|
||||
}
|
||||
|
||||
private function areaRecords(TreatmentSession $session): array
|
||||
{
|
||||
$records = $this->em->getRepository(SessionAreaRecord::class)->findBy(['session' => $session]);
|
||||
usort($records, static fn (SessionAreaRecord $a, SessionAreaRecord $b): int
|
||||
=> $a->getCaseArea()->getSortOrder() <=> $b->getCaseArea()->getSortOrder());
|
||||
|
||||
return $records;
|
||||
}
|
||||
|
||||
public function testStartingASessionCreatesOneRecordPerArea(): 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));
|
||||
self::assertSame(TreatmentSession::STATUS_IN_PROGRESS, $body['data']['status']);
|
||||
self::assertNotNull($body['data']['started_at']);
|
||||
self::assertCount(2, $body['data']['areas']);
|
||||
self::assertSame($s['staff']->getUuid(), $body['data']['performed_by']['uuid']);
|
||||
}
|
||||
|
||||
/** شروع دوباره نباید ناحیهٔ تکراری بسازد. */
|
||||
public function testStartingTwiceIsIdempotent(): void
|
||||
{
|
||||
$s = $this->scenario();
|
||||
$uri = '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid() . '/start';
|
||||
|
||||
$this->authJson('POST', $uri, $s['staffUser']);
|
||||
$body = $this->authJson('POST', $uri, $s['staffUser']);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertCount(2, $body['data']['areas']);
|
||||
}
|
||||
|
||||
/** شروع جلسه، نوبت را به «سالن» میبرد. */
|
||||
public function testStartingASessionMovesTheAppointmentToSalon(): void
|
||||
{
|
||||
$s = $this->scenario();
|
||||
|
||||
$this->authJson('POST', '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid() . '/start', $s['staffUser']);
|
||||
|
||||
$this->em->clear();
|
||||
$appointment = $this->em->getRepository(Appointment::class)->find($s['appointment']->getId());
|
||||
self::assertSame(Appointment::STATUS_SALON, $appointment->getStatus());
|
||||
}
|
||||
|
||||
public function testCompletingAnAreaStoresTheDeviceReadings(): 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'], [
|
||||
'resource_uuid' => $s['resource']->getUuid(),
|
||||
'parameters' => ['energy' => 18, 'pulse' => 3, 'shots' => 212],
|
||||
'note' => 'بدون عارضه',
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
self::assertSame(SessionAreaRecord::STATUS_COMPLETED, $body['data']['status']);
|
||||
self::assertSame(['energy' => 18, 'pulse' => 3, 'shots' => 212], $body['data']['parameters']);
|
||||
self::assertSame('بدون عارضه', $body['data']['note']);
|
||||
self::assertSame($s['resource']->getUuid(), $body['data']['resource']['uuid']);
|
||||
self::assertNotNull($body['data']['finished_at']);
|
||||
}
|
||||
|
||||
/** مقدار خارج از گزینههای دستگاه رد میشود. */
|
||||
public function testAnOutOfRangeReadingIsRejected(): 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];
|
||||
|
||||
$this->authJson('POST', '/api/v1/dashboard/staff/session-area/' . $record->getUuid() . '/complete', $s['staffUser'], [
|
||||
'resource_uuid' => $s['resource']->getUuid(),
|
||||
'parameters' => ['energy' => 99, 'pulse' => 3, 'shots' => 10],
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
/** فیلد الزامیِ نیامده باید ۴۲۲ بدهد، نه ذخیرهٔ ناقص. */
|
||||
public function testAMissingRequiredReadingIsRejected(): 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];
|
||||
|
||||
$this->authJson('POST', '/api/v1/dashboard/staff/session-area/' . $record->getUuid() . '/complete', $s['staffUser'], [
|
||||
'resource_uuid' => $s['resource']->getUuid(),
|
||||
'parameters' => ['energy' => 18],
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testAnAreaCanBeSkipped(): void
|
||||
{
|
||||
$s = $this->scenario();
|
||||
$this->authJson('POST', '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid() . '/start', $s['staffUser']);
|
||||
$record = $this->areaRecords($s['session'])[1];
|
||||
|
||||
$body = $this->authJson('POST', '/api/v1/dashboard/staff/session-area/' . $record->getUuid() . '/skip', $s['staffUser']);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame(SessionAreaRecord::STATUS_SKIPPED, $body['data']['status']);
|
||||
}
|
||||
|
||||
public function testAnAlreadySettledAreaCannotBeCompletedAgain(): 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];
|
||||
|
||||
$this->authJson('POST', '/api/v1/dashboard/staff/session-area/' . $record->getUuid() . '/skip', $s['staffUser']);
|
||||
$this->authJson('POST', '/api/v1/dashboard/staff/session-area/' . $record->getUuid() . '/complete', $s['staffUser'], [
|
||||
'resource_uuid' => $s['resource']->getUuid(),
|
||||
'parameters' => ['energy' => 18, 'pulse' => 3, 'shots' => 10],
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
/** اپراتور جلوی بیمار ایستاده — ناحیهٔ ناتمام نباید بستن جلسه را قفل کند. */
|
||||
public function testASessionCanBeFinishedWithUnsettledAreasButReportsThem(): void
|
||||
{
|
||||
$s = $this->scenario();
|
||||
$this->authJson('POST', '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid() . '/start', $s['staffUser']);
|
||||
|
||||
$body = $this->authJson('POST', '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid() . '/finish', $s['staffUser'], [
|
||||
'note' => 'جلسه ناقص ماند',
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
self::assertSame(TreatmentSession::STATUS_DONE, $body['data']['status']);
|
||||
self::assertSame(2, $body['data']['unsettled_areas']);
|
||||
self::assertSame('جلسه ناقص ماند', $body['data']['note']);
|
||||
}
|
||||
|
||||
/** بستن جلسه سررسید جلسهٔ بعد را از تاریخ واقعی همین جلسه حساب میکند. */
|
||||
public function testFinishingSchedulesTheNextSession(): void
|
||||
{
|
||||
$s = $this->scenario();
|
||||
$this->authJson('POST', '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid() . '/start', $s['staffUser']);
|
||||
$this->authJson('POST', '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid() . '/finish', $s['staffUser']);
|
||||
|
||||
$this->em->clear();
|
||||
$case = $this->em->getRepository(TreatmentCase::class)->find($s['case']->getId());
|
||||
$sessions = $case->getSessions()->toArray();
|
||||
usort($sessions, static fn (TreatmentSession $a, TreatmentSession $b): int
|
||||
=> $a->getSessionNumber() <=> $b->getSessionNumber());
|
||||
|
||||
self::assertSame(TreatmentSession::STATUS_DONE, $sessions[0]->getStatus());
|
||||
self::assertNotNull($sessions[1]->getDueAt());
|
||||
self::assertSame(
|
||||
$sessions[0]->getFinishedAt() + 30 * 86400,
|
||||
$sessions[1]->getDueAt(),
|
||||
);
|
||||
}
|
||||
|
||||
/** اتمام جلسه، نوبت را «انجام شد» میکند و زمانش را دست نمیزند. */
|
||||
public function testFinishingCompletesTheAppointmentWithoutRewritingItsTimes(): void
|
||||
{
|
||||
$s = $this->scenario();
|
||||
$start = $s['appointment']->getSlotStart();
|
||||
$end = $s['appointment']->getSlotEnd();
|
||||
|
||||
$this->authJson('POST', '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid() . '/start', $s['staffUser']);
|
||||
$this->authJson('POST', '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid() . '/finish', $s['staffUser']);
|
||||
|
||||
$this->em->clear();
|
||||
$appointment = $this->em->getRepository(Appointment::class)->find($s['appointment']->getId());
|
||||
|
||||
self::assertSame(Appointment::STATUS_COMPLETED, $appointment->getStatus());
|
||||
self::assertSame($start, $appointment->getSlotStart());
|
||||
self::assertSame($end, $appointment->getSlotEnd());
|
||||
}
|
||||
|
||||
public function testAFinishedSessionCannotBeFinishedTwice(): void
|
||||
{
|
||||
$s = $this->scenario();
|
||||
$uri = '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid() . '/finish';
|
||||
|
||||
$this->authJson('POST', '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid() . '/start', $s['staffUser']);
|
||||
$this->authJson('POST', $uri, $s['staffUser']);
|
||||
$this->authJson('POST', $uri, $s['staffUser']);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
/** جلسهٔ محیط دیگر برای این پرسنل وجود ندارد. */
|
||||
public function testASessionFromAnotherTenantIsNotFound(): void
|
||||
{
|
||||
$mine = $this->scenario();
|
||||
$other = $this->scenario();
|
||||
|
||||
$this->authJson('POST', '/api/v1/dashboard/staff/treatment-session/' . $other['session']->getUuid() . '/start', $mine['staffUser']);
|
||||
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
/** نقش staff بدون ردیف فعالِ پرسنل کافی نیست. */
|
||||
public function testAUserWithoutAStaffRowIsForbidden(): void
|
||||
{
|
||||
$s = $this->scenario();
|
||||
$orphan = $this->createUser(['ROLE_USER', 'ROLE_STAFF']);
|
||||
|
||||
$this->authJson('POST', '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid() . '/start', $orphan);
|
||||
|
||||
self::assertContains($this->responseCode(), [403, 404]);
|
||||
}
|
||||
|
||||
public function testTodayListsThisStaffsSessions(): void
|
||||
{
|
||||
$s = $this->scenario();
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/dashboard/staff/treatment-sessions', $s['staffUser']);
|
||||
|
||||
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
self::assertContains($s['session']->getUuid(), array_column($body['data'], 'uuid'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user