'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, bool $withResource = true): 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); if ($withResource) { $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 [ 'clinic' => $clinic, 'address' => $address, 'doctor' => $doctor, 'staffUser' => $staffUser, 'staff' => $staff, 'session' => $session, 'resource' => $resource, 'case' => $case, 'appointment' => $appointment, ]; } /** دستگاه دومِ همان محیط — برای سنجیدن تعویض دستگاه در سطح ناحیه. */ 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]); 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 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(); $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 testSkippingAnAreaStoresTheReason(): 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'], ['note' => 'پوست این ناحیه تحریک بود'], ); self::assertSame(200, $this->responseCode()); self::assertSame('پوست این ناحیه تحریک بود', $body['data']['note']); } /** اپراتور وسط لیزر می‌فهمد اشتباه زده؛ نباید تا پایان جلسه با آن بماند. */ public function testASettledAreaCanBeReopenedWhileTheSessionIsOpen(): 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']); $body = $this->authJson('POST', '/api/v1/dashboard/staff/session-area/' . $record->getUuid() . '/reopen', $s['staffUser']); self::assertSame(200, $this->responseCode()); self::assertNotSame(SessionAreaRecord::STATUS_SKIPPED, $body['data']['status']); self::assertNull($body['data']['finished_at']); // و بعد از باز شدن، دوباره قابل ثبت است. $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(200, $this->responseCode()); } /** بعد از بستن جلسه، رکورد سابقه است و ویرایشش یعنی بازنویسی سند. */ public function testAnAreaCannotBeReopenedAfterTheSessionIsFinished(): 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/treatment-session/' . $s['session']->getUuid() . '/finish', $s['staffUser']); $this->authJson('POST', '/api/v1/dashboard/staff/session-area/' . $record->getUuid() . '/reopen', $s['staffUser']); self::assertSame(409, $this->responseCode()); } /** ناحیهٔ باز چیزی برای برگرداندن ندارد. */ public function testAnOpenAreaCannotBeReopened(): 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() . '/reopen', $s['staffUser']); self::assertSame(422, $this->responseCode()); } 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')); } }