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>
454 lines
17 KiB
PHP
454 lines
17 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 <= 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(),
|
||
)));
|
||
}
|
||
}
|