Files
clinicpro/tests/Report/ReportTest.php
T
hamedandClaude Opus 5 2baa2ce7ca test: cover the paths that were reasoned about but never executed
Fifteen rows across five tasks said the mechanism was there and the test was
not. Each of these is a case where being wrong would be silent.

- the price rows must add up to the final amount. The chain test checks every
  number individually, which stays green if a new row is added and left out of
  the total; this checks the relationship itself.
- a fixed deposit beats a percentage one, and neither can exceed the final
  amount — charging a deposit larger than the bill puts the patient in debt
  before the visit.
- an appointment booked without a service still gets an invoice. Slot mode has
  no service, and without this the financial report is short a row with nothing
  to say which.
- the four accuracy thresholds, each tested on its own boundary. One step off
  and either everything is red (so nobody looks) or nothing is (so the report
  is pointless). Includes a short-running service, since the deviation is
  measured on its absolute value.
- all six policy templates build a policy that survives the normal validation,
  simulation and activation path. A template is a shortcut, not a second road:
  if one of them produced something the validator rejects, a user could create
  a rule in one click that never works.
- simulation leaves nothing pending for a later flush in the same request. That
  is what the finally-rollback-clear is for, and the failure would surface in
  the next operation rather than in the sandbox.

The course controller was reading $this->credits without it being injected —
phpstan caught it; the package-shortfall path had no test yet and would have
500'd on the first course that had a package.

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

502 lines
19 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']);
}
// ── بهره‌وری منابع ──────────────────────────────────────────────────────
/** منبعی بدون تقویم «۰٪ بهره‌وری» ندارد — بهره‌وری‌اش تعریف‌نشده است. */
/**
* ⭐ چهار آستانه، هر کدام روی مرز خودش.
*
* آستانه‌ای که یک درجه اشتباه بیفتد، یا همه‌چیز را قرمز می‌کند (و کسی دیگر نگاه
* نمی‌کند) یا هیچ‌چیز را (و گزارش بی‌فایده است).
*
* @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 <= 3; $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 <= 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(),
)));
}
}