Files
clinicpro/tests/Report/ReportTest.php
T
hamedandClaude Opus 5 3c43955800 feat(events): domain event outbox and the two reports that close the loop
Tasks 07 through 13 each changed something the rest of the system might want
to know about, with no contract for saying so. And task 05 shipped a powerful
segment editor with no feedback on whether a clinic defined its segments right.

Events
- A closed list of names, because a consumer branches on the string and a
  one-letter typo would produce an event nobody hears and no error either
- Payloads carry uuids and scalars only; non-scalars are dropped, not
  serialised, so a consumer always fetches fresh rather than reading a stale
  detached entity
- record() deliberately does not flush: the event row commits with the change
  it describes, so a rolled-back transaction leaves no event behind. A test
  pins exactly that
- app:events:publish drains the outbox; five failed attempts park a row with
  its error rather than deleting it, because a silently dropped event is a
  loss with no trace. app:events:prune only ever removes published rows

Reports
- Resource utilisation separates available, occupied and active minutes.
  The gap between occupied and active is what exposes a bad segment
  definition, and available is multiplied by capacity so a three-chair room
  does not read as permanently over 100%
- A resource with no calendar reports utilization: null, not zero — dividing
  by zero means something different from being idle
- Plan accuracy compares planned against actual duration per service and
  flags both directions: running short wastes capacity that could have been
  sold. Its row links straight to editing that service's segments, because a
  report with no route to a fix does not get read

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:27:54 +03:30

281 lines
10 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 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(),
)));
}
}