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