Files
clinicpro/tests/Report/ReportTest.php
T
hamedandClaude Opus 5 b3c331f0cb perf(reports): read every resource's calendar in one batch, and close the owed tests
Writing the query-count test that task 14 owed showed the growth was real: one
resource cost 10 queries, six cost 33 — about five per resource, because the
available-minutes figure walked each resource's calendar on its own.

Holidays, tenant overrides and branch hours are identical for every resource in
a report, so they now load once outside the loop; shifts and exceptions load for
all resources in one query each. The batched path is a new method rather than a
change to rawAvailability, which the booking engine also calls. The test pins
the shape of the growth, not an exact count.

Also landed:

- app:segment:seed-templates with beauty, dental and physio presets. Building
  four segments and their requirements by hand is the first thing a new clinic
  must do and the most tedious; this gives them something to edit instead of an
  empty page. It refuses to touch a service that already has segments unless
  --force, and it will not invent resource types the tenant never defined.
- book-all is all-or-nothing, proven rather than asserted: with a calendar open
  one day a week and a 1-2 day protocol gap, session one finds a slot and
  session two cannot, and every session must come back planned.
- credit_refundable: false takes the credit back with a negative adjustment and
  deletes nothing — the ledger stays append-only.
- the segments editor has frontend tests, including that it sends back what the
  user sees and renders read-only without the permission.

useBranches now returns [] for a non-array payload instead of throwing
"branches.map is not a function" and taking the page down with it.

BookingLocationsScanTest built a Clinic around a Doctor loaded from a different
manager, which Doctrine treats as a new entity; it flushed fine most runs and
failed on cascade in others. It now loads the doctor from the same manager.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 14:22:22 +03:30

454 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Tests\Report;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Tests\ApiTestCase;
/**
* گزارش بهره‌وری منابع و دقت برنامه — تسک ۱۴.
*
* گزارش دقت برنامه تنها بازخوردی است که به کلینیک می‌گوید تعریف بخش‌هایش درست است یا
* نه؛ بدون آن، ابزار قدرتمند تسک ۰۵ کور کار می‌کند.
*/
class ReportTest extends ApiTestCase
{
private int $slotCursor = 0;
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress, 3: Doctor} */
private function clinic(): array
{
$user = $this->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 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 <= 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(),
)));
}
}