MIN_SAMPLE goes back to the specified 10. The reason it had been lowered to 3 was real — a small clinic saw an empty report — but the fix was wrong: three samples do not make an average, and calling that "accurate" is worse than saying nothing. Rows below the threshold are now returned rather than dropped, with severity null and below_min_sample true. That refuses both mistakes: it claims no severity it cannot support, and it does not show a small clinic an empty page that implies everything is fine. They sort after the usable rows and render faded with a "small sample" badge. The utilization page gets its Recharts bar chart. The table stays underneath — six numeric columns are not something a chart answers — but the one question the table is bad at, "which resource is behind", is exactly what a chart is for. Colours come from the design tokens rather than hex, which is where a chart usually breaks in dark mode, and a resource with no calendar is left out entirely: null is not zero, and a zero bar would be a lie. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
513 lines
20 KiB
PHP
513 lines
20 KiB
PHP
<?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 <= 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(),
|
||
)));
|
||
}
|
||
}
|