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 <= 4; $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 <= 3; $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 testASmallSampleIsNotReported(): 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']; self::assertSame([], array_values(array_filter( $rows, static fn (array $r): bool => $r['service_uuid'] === $service->getUuid(), ))); } public function testAnAccurateServiceHasNoSeverity(): void { [$user, $section, $address, $doctor] = $this->clinic(); $service = $this->service($section, 'خدمت دقیق', 60); $patient = $this->createUser(['ROLE_USER']); for ($i = 1; $i <= 3; $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']); } // ── بهره‌وری منابع ────────────────────────────────────────────────────── /** منبعی بدون تقویم «۰٪ بهره‌وری» ندارد — بهره‌وری‌اش تعریف‌نشده است. */ 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 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 <= 3; $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(), ))); } }