createUser(['ROLE_USER', 'ROLE_CLINIC']); $clinic = new Clinic($user); $clinic->setName('کلینیک گزارش'); $this->em->persist($clinic); $this->em->flush(); $section = new ServiceSection('clinic', $clinic->getId(), 'لیزر'); $this->em->persist($section); $address = DoctorAddress::forClinic($clinic->getId()); $address->setName('شعبهٔ مرکزی'); $this->em->persist($address); $doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']); $doctor = new Doctor($doctorUser, 'دکتر گزارش'); $this->em->persist($doctor); $this->em->flush(); return [$user, $section, $address, $doctor]; } private function service(ServiceSection $section, string $name, int $solo): ServiceItem { $item = new ServiceItem($section, $name); $item->setSoloDurationMinutes($solo); $item->setPriceRials(1_000_000); $this->em->persist($item); $this->em->flush(); return $item; } /** نوبت انجام‌شده با مدت پیش‌بینی و مدت واقعی مشخص. */ private function completed( Doctor $doctor, User $patient, ServiceItem $service, int $clinicId, int $plannedMinutes, int $actualMinutes, int $daysAgo, ): Appointment { $em = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class); $start = time() - $daysAgo * 86400 + (++$this->slotCursor) * 60; $appointment = new Appointment( $em->getRepository(Doctor::class)->find($doctor->getId()), $em->getRepository(User::class)->find($patient->getId()), $start, $start + $actualMinutes * 60, ); $appointment->assignTenantPair('clinic', $clinicId); $appointment->setServiceItem($em->getRepository(ServiceItem::class)->find($service->getId())); $appointment->setPatientName('بیمار گزارش'); $appointment->setServiceDuration($plannedMinutes, 0); $appointment->transitionTo(Appointment::STATUS_CONFIRMED); $appointment->transitionTo(Appointment::STATUS_COMPLETED); $em->persist($appointment); $em->flush(); return $appointment; } // ── دقت برنامه ────────────────────────────────────────────────────────── /** ⭐ سرویسی که ۶۰ دقیقه پیش‌بینی شده ولی ۹۰ دقیقه طول می‌کشد. */ public function testAServiceThatRunsLongIsFlaggedHigh(): void { [$user, $section, $address, $doctor] = $this->clinic(); $service = $this->service($section, 'لیزر فول‌بادی', 60); $patient = $this->createUser(['ROLE_USER']); for ($i = 1; $i <= 10; $i++) { $this->completed($doctor, $patient, $service, (int) $address->getClinicId(), 60, 90, $i); } $body = $this->authJson( 'GET', sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time() - 10 * 86400, time()), $user, ); self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); $row = $body['data']['rows'][0]; self::assertSame($service->getUuid(), $row['service_uuid']); self::assertSame(60, $row['planned_minutes']); self::assertSame(90, $row['actual_minutes']); self::assertSame(50, $row['deviation_percent']); self::assertSame('high', $row['severity']); } /** انحراف منفی هم غلط است: ظرفیتی که می‌شد فروخت، خالی مانده. */ public function testAServiceThatRunsShortIsAlsoFlagged(): void { [$user, $section, $address, $doctor] = $this->clinic(); $service = $this->service($section, 'مشاوره', 60); $patient = $this->createUser(['ROLE_USER']); for ($i = 1; $i <= 10; $i++) { $this->completed($doctor, $patient, $service, (int) $address->getClinicId(), 60, 30, $i); } $rows = $this->authJson( 'GET', sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time() - 10 * 86400, time()), $user, )['data']['rows']; self::assertSame(-50, $rows[0]['deviation_percent']); self::assertSame('high', $rows[0]['severity']); } /** زیر سه نمونه، میانگین معنا ندارد. */ /** * ⭐ زیر آستانه **حذف نمی‌شود، بی‌شدت برمی‌گردد**. * * میانگین دو نمونه معنا ندارد و نباید کسی رویش تصمیم بگیرد؛ ولی حذف کاملش یعنی * کلینیک کوچک گزارشی خالی می‌بیند و فکر می‌کند همه‌چیز درست است. */ public function testASmallSampleIsShownWithoutASeverity(): void { [$user, $section, $address, $doctor] = $this->clinic(); $service = $this->service($section, 'خدمت کم‌تکرار', 60); $patient = $this->createUser(['ROLE_USER']); $this->completed($doctor, $patient, $service, (int) $address->getClinicId(), 60, 120, 1); $this->completed($doctor, $patient, $service, (int) $address->getClinicId(), 60, 120, 2); $rows = $this->authJson( 'GET', sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time() - 10 * 86400, time()), $user, )['data']['rows']; $mine = array_values(array_filter( $rows, static fn (array $r): bool => $r['service_uuid'] === $service->getUuid(), )); self::assertCount(1, $mine); self::assertNull($mine[0]['severity'], 'با دو نمونه نباید شدتی ادعا شود'); self::assertTrue($mine[0]['below_min_sample']); self::assertSame(2, $mine[0]['sample_size']); } public function testAnAccurateServiceHasNoSeverity(): void { [$user, $section, $address, $doctor] = $this->clinic(); $service = $this->service($section, 'خدمت دقیق', 60); $patient = $this->createUser(['ROLE_USER']); for ($i = 1; $i <= 10; $i++) { $this->completed($doctor, $patient, $service, (int) $address->getClinicId(), 60, 60, $i); } $rows = $this->authJson( 'GET', sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time() - 10 * 86400, time()), $user, )['data']['rows']; $row = current(array_filter($rows, static fn (array $r): bool => $r['service_uuid'] === $service->getUuid())); self::assertSame(0, $row['deviation_percent']); self::assertSame('none', $row['severity']); } // ── بهره‌وری منابع ────────────────────────────────────────────────────── /** منبعی بدون تقویم «۰٪ بهره‌وری» ندارد — بهره‌وری‌اش تعریف‌نشده است. */ /** * ⭐ چهار آستانه، هر کدام روی مرز خودش. * * آستانه‌ای که یک درجه اشتباه بیفتد، یا همه‌چیز را قرمز می‌کند (و کسی دیگر نگاه * نمی‌کند) یا هیچ‌چیز را (و گزارش بی‌فایده است). * * @param int $planned مدت برنامه * @param int $actual مدت واقعی */ #[\PHPUnit\Framework\Attributes\DataProvider('severityCases')] public function testEachSeverityThresholdIsHitExactly(int $planned, int $actual, string $expected): void { [$user, $section, $address, $doctor] = $this->clinic(); $service = $this->service($section, 'خدمت آستانه', $planned); $patient = $this->createUser(['ROLE_USER']); for ($i = 1; $i <= 10; $i++) { $this->completed($doctor, $patient, $service, (int) $address->getClinicId(), $planned, $actual, $i); } $body = $this->authJson( 'GET', sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time() - 10 * 86400, time()), $user, ); $row = $body['data']['rows'][0]; self::assertSame($expected, $row['severity'], sprintf( 'انحراف %d%%', $row['deviation_percent'], )); } /** مرزها: ۳۰ · ۱۵ · ۵ درصد، روی قدر مطلق. */ public static function severityCases(): array { return [ 'دقیقاً روی مرز high' => [100, 130, 'high'], 'یک قدم زیر high' => [100, 129, 'medium'], 'دقیقاً روی مرز medium' => [100, 115, 'medium'], 'یک قدم زیر medium' => [100, 114, 'low'], 'دقیقاً روی مرز low' => [100, 105, 'low'], 'یک قدم زیر low' => [100, 104, 'none'], 'کوتاه‌تر هم شمرده می‌شود' => [100, 70, 'high'], ]; } public function testAResourceWithoutACalendarHasNullUtilization(): void { [$user, , $address] = $this->clinic(); $type = $this->authJson('POST', '/api/v1/resource-types', $user, [ 'address_uuid' => $address->getUuid(), 'code' => 'device', 'name' => 'دستگاه', ]); self::assertSame(201, $this->responseCode(), json_encode($type, JSON_UNESCAPED_UNICODE)); $this->authJson('POST', '/api/v1/resource', $user, [ 'address_uuid' => $address->getUuid(), 'type_uuid' => $type['data']['uuid'], 'name' => 'لیزر ۱', ]); self::assertSame(201, $this->responseCode()); $body = $this->authJson( 'GET', sprintf( '/api/v1/reports/resource-utilization?branch_uuid=%s&from=%d&to=%d', $address->getUuid(), time() - 7 * 86400, time(), ), $user, ); self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); $row = $body['data']['rows'][0]; self::assertSame('لیزر ۱', $row['resource_name']); self::assertSame(0, $row['available_minutes']); self::assertNull($row['utilization'], 'تقسیم بر صفر معنای متفاوتی دارد'); self::assertNull($row['active_ratio']); self::assertFalse($row['wasted_capacity']); } /** * ⭐ سنجه‌های واقعی: اشغال شامل انتظار است، «کار مفید» نه. * * فاصلهٔ این دو همان چیزی است که تعریف غلط بخش‌ها را لو می‌دهد؛ اگر هر دو یکی * برگردند، گزارش بی‌فایده است و کسی متوجه نمی‌شود. */ public function testOccupiedIncludesTheWaitingSegmentButActiveDoesNot(): void { [$user, , $address] = $this->clinic(); $type = $this->authJson('POST', '/api/v1/resource-types', $user, [ 'address_uuid' => $address->getUuid(), 'code' => 'room', 'name' => 'اتاق', ]); self::assertSame(201, $this->responseCode(), json_encode($type, JSON_UNESCAPED_UNICODE)); $created = $this->authJson('POST', '/api/v1/resource', $user, [ 'address_uuid' => $address->getUuid(), 'type_uuid' => $type['data']['uuid'], 'name' => 'اتاق ۱', ]); self::assertSame(201, $this->responseCode()); $resource = $this->em->getRepository(\App\Resource\Entity\ClinicResource::class) ->findOneBy(['uuid' => $created['data']['uuid']]); $from = time() - 2 * 86400; $start = $from + 3600; $appointment = $this->bookedAppointment($address, $start, 60); // یک ساعت اشغال؛ ولی بیمار فقط ۲۰ دقیقهٔ اولش حاضر است. $this->occupy($resource, $appointment, $start, $start + 3600); $this->segment($appointment, 1, 'ویزیت', $start, $start + 1200, true); $this->segment($appointment, 2, 'انتظار', $start + 1200, $start + 3600, false); $body = $this->authJson( 'GET', sprintf( '/api/v1/reports/resource-utilization?branch_uuid=%s&from=%d&to=%d', $address->getUuid(), $from, time(), ), $user, ); self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); $row = $body['data']['rows'][0]; self::assertSame(60, $row['occupied_minutes'], 'انتظار هم اشغال است'); self::assertSame(20, $row['active_minutes'], 'ولی کار مفید نیست'); } private function bookedAppointment(\App\Doctor\Entity\DoctorAddress $address, int $start, int $minutes): \App\Appointment\Entity\Appointment { $doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']); $doctor = new \App\Doctor\Entity\Doctor($doctorUser, 'دکتر گزارش'); $this->em->persist($doctor); $appointment = new \App\Appointment\Entity\Appointment( $doctor, $this->createUser(['ROLE_USER']), $start, $start + $minutes * 60, ); $appointment->assignTenantPair('clinic', (int) $address->getClinicId()); $appointment->setAddressId($address->getId()); $appointment->setPatientName('بیمار گزارش'); $appointment->transitionTo(\App\Appointment\Entity\Appointment::STATUS_CONFIRMED); $this->em->persist($appointment); $this->em->flush(); return $appointment; } private function occupy( \App\Resource\Entity\ClinicResource $resource, \App\Appointment\Entity\Appointment $appointment, int $from, int $to, ): void { $row = new \App\Appointment\Availability\Entity\ResourceOccupancy( $resource, $from, $to, \App\Appointment\Availability\Entity\ResourceOccupancy::STATUS_BOOKED, ); $row->setAppointmentId($appointment->getId()); $this->em->persist($row); $this->em->flush(); } private function segment( \App\Appointment\Entity\Appointment $appointment, int $sequence, string $name, int $from, int $to, bool $present, ): void { $this->em->persist(new \App\Appointment\Booking\Entity\AppointmentSegment( $appointment, $sequence, $name, $from, $to, $present, )); $this->em->flush(); } /** * ⭐ اشغال و کار مفید هرکدام **یک** کوئری‌اند، مستقل از تعداد منبع. * * پیمایش per منبع روی کلینیکی با ۴۰ منبع یعنی ۸۰ کوئری برای یک گزارش. تعداد * دقیقش مهم نیست؛ چیزی که این تست نگه می‌دارد این است که با سه برابر شدن منابع، * تعداد کوئری‌ها سه برابر **نشود**. */ public function testQueryCountDoesNotGrowWithTheNumberOfResources(): void { [$user, , $address] = $this->clinic(); $type = $this->authJson('POST', '/api/v1/resource-types', $user, [ 'address_uuid' => $address->getUuid(), 'code' => 'room', 'name' => 'اتاق', ]); self::assertSame(201, $this->responseCode(), json_encode($type, JSON_UNESCAPED_UNICODE)); $reporter = static::getContainer()->get(\App\Report\Service\ResourceUtilizationReporter::class); $count = function (int $resources) use ($user, $address, $type, $reporter): int { for ($i = 0; $i < $resources; $i++) { $this->authJson('POST', '/api/v1/resource', $user, [ 'address_uuid' => $address->getUuid(), 'type_uuid' => $type['data']['uuid'], 'name' => sprintf('اتاق %d', $i + 1), ]); self::assertSame(201, $this->responseCode()); } $all = $this->em->getRepository(\App\Resource\Entity\ClinicResource::class) ->findBy(['address' => $address]); $connection = $this->em->getConnection(); $before = $this->queryCount($connection); $reporter->report($all, $address, time() - 7 * 86400, time()); return $this->queryCount($connection) - $before; }; $withOne = $count(1); $withMany = $count(5); self::assertLessThan( $withOne * 3, $withMany, sprintf('یک منبع %d کوئری، شش منبع %d کوئری — رشد خطی است', $withOne, $withMany), ); } /** شمار کوئری از خودِ سرور — `SHOW SESSION STATUS` روی همان اتصال. */ private function queryCount(\Doctrine\DBAL\Connection $connection): int { return (int) ($connection->fetchAssociative("SHOW SESSION STATUS LIKE 'Questions'")['Value'] ?? 0); } // ── محدودیت بازه و دسترسی ─────────────────────────────────────────────── public function testARangeLongerThanNinetyDaysIsRejected(): void { [$user] = $this->clinic(); $this->authJson( 'GET', sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time() - 200 * 86400, time()), $user, ); self::assertSame(422, $this->responseCode()); } public function testAnInvertedRangeIsRejected(): void { [$user] = $this->clinic(); $this->authJson( 'GET', sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time(), time() - 86400), $user, ); self::assertSame(422, $this->responseCode()); } public function testAnotherClinicSeesItsOwnNumbersOnly(): void { [$owner, $section, $address, $doctor] = $this->clinic(); [$other] = $this->clinic(); $service = $this->service($section, 'لیزر', 60); $patient = $this->createUser(['ROLE_USER']); for ($i = 1; $i <= 10; $i++) { $this->completed($doctor, $patient, $service, (int) $address->getClinicId(), 60, 90, $i); } $rows = $this->authJson( 'GET', sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time() - 10 * 86400, time()), $other, )['data']['rows']; self::assertSame([], array_values(array_filter( $rows, static fn (array $r): bool => $r['service_uuid'] === $service->getUuid(), ))); } }