perf(reports): read every resource's calendar in one batch, and close the owed tests
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>
This commit is contained in:
@@ -435,6 +435,51 @@ class AppointmentPlanTest extends ApiTestCase
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ الگوی نمونه نقطهٔ شروع است، نه پیکربندی نهایی — و **بازنویسی خاموش نمیکند**.
|
||||
*/
|
||||
public function testSeedingCreatesAStarterSetAndRefusesToOverwrite(): void
|
||||
{
|
||||
[$user, $section, $address] = $this->clinicWithBranch();
|
||||
$service = $this->service($section, 'لیزر', 20);
|
||||
$room = $this->resourceType($address, 'room', 'اتاق');
|
||||
$operator = $this->resourceType($address, 'operator', 'اپراتور');
|
||||
$this->resource($user, $address, $room, 'اتاق ۱');
|
||||
$this->resource($user, $address, $operator, 'اپراتور ۱');
|
||||
|
||||
$seed = static::getContainer()->get(\App\Appointment\Plan\Command\SeedSegmentTemplatesCommand::class);
|
||||
$run = static function (array $input) use ($seed): array {
|
||||
$tester = new \Symfony\Component\Console\Tester\CommandTester($seed);
|
||||
$tester->execute($input);
|
||||
|
||||
return [$tester->getStatusCode(), $tester->getDisplay()];
|
||||
};
|
||||
|
||||
[$code] = $run(['--service' => $service->getUuid(), '--preset' => 'beauty']);
|
||||
self::assertSame(0, $code);
|
||||
|
||||
$plan = $this->preview($user, $service, $address);
|
||||
self::assertCount(5, $plan['data']['segments']);
|
||||
self::assertSame('آمادهسازی', $plan['data']['segments'][0]['name']);
|
||||
|
||||
// بخشِ تمیزکاری بدون حضور بیمار است — همان چیزی که گزارش بهرهوری با آن کار میکند.
|
||||
$cleanup = end($plan['data']['segments']);
|
||||
self::assertFalse($cleanup['patient_present']);
|
||||
|
||||
// اجرای دوباره بدون `--force` دست به چیزی نمیزند.
|
||||
[, $display] = $run(['--service' => $service->getUuid(), '--preset' => 'dental']);
|
||||
self::assertStringContainsString('--force', $display);
|
||||
|
||||
$unchanged = $this->preview($user, $service, $address);
|
||||
self::assertCount(5, $unchanged['data']['segments'], 'بدون --force بازنویسی نمیشود');
|
||||
|
||||
[, $forced] = $run(['--service' => $service->getUuid(), '--preset' => 'dental', '--force' => true]);
|
||||
self::assertStringNotContainsString('--force', $forced);
|
||||
|
||||
$replaced = $this->preview($user, $service, $address);
|
||||
self::assertCount(3, $replaced['data']['segments']);
|
||||
}
|
||||
|
||||
public function testForeignServiceIsNotFound(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithBranch();
|
||||
|
||||
@@ -51,7 +51,9 @@ class BookingLocationsScanTest extends ApiTestCase
|
||||
for ($i = 0; $i < $clinicCount; $i++) {
|
||||
$clinic = new Clinic($this->createUser(['ROLE_USER', 'ROLE_CLINIC']));
|
||||
$clinic->setName("کلینیک $i");
|
||||
$clinic->getDoctors()->add($doctor);
|
||||
// پزشک را از همین EM میگیریم: اگر نمونهٔ دیگری باشد، Doctrine او را
|
||||
// «موجودیت تازه» میبیند و flush با خطای cascade میشکند.
|
||||
$clinic->getDoctors()->add($this->em->getRepository(Doctor::class)->find($doctor->getId()));
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
|
||||
@@ -448,6 +448,69 @@ class TreatmentCourseTest extends ApiTestCase
|
||||
self::assertCount(8, $course->getSessions()->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ `book-all` همه یا هیچ است.
|
||||
*
|
||||
* تقویم فقط یک روزِ هفته باز است و فاصلهٔ پروتکل ۱ تا ۲ روز؛ پس جلسهٔ اول وقت پیدا
|
||||
* میکند و جلسهٔ دوم نه. اگر تراکنش کار نکند، بیمار با یک نوبتِ تنها از یک دورهٔ
|
||||
* هشتجلسهای میماند و هیچکس نمیفهمد کجا قطع شد.
|
||||
*/
|
||||
public function testAFailedBookAllLeavesEverySessionPlanned(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$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));
|
||||
|
||||
$resource = $this->authJson('POST', '/api/v1/resource', $user, [
|
||||
'address_uuid' => $address->getUuid(),
|
||||
'type_uuid' => $type['data']['uuid'],
|
||||
'name' => 'اتاق ۱',
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
// فقط شنبهها باز است.
|
||||
$this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/calendar", $user, [
|
||||
'days' => [6 => [['start_minute' => 540, 'end_minute' => 1020]]],
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/segments", $user, [
|
||||
'segments' => [
|
||||
['sequence' => 1, 'name' => 'جلسه', 'duration_minutes' => 30, 'requirements' => [['type_uuid' => $type['data']['uuid']]]],
|
||||
],
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
// بازهٔ ۱ تا ۲ روز: جلسهٔ دوم حتماً بیرون تنها روزِ باز میافتد.
|
||||
$protocol = $this->protocol($user, $service, ['min_days' => 1, 'ideal_days' => 1, 'max_days' => 2]);
|
||||
$started = $this->startCourse($user, $patient, $protocol['uuid']);
|
||||
|
||||
$body = $this->authJson('POST', "/api/v1/treatment-course/{$started['uuid']}/book-all", $user, [
|
||||
'branch_uuid' => $address->getUuid(),
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$this->em->clear();
|
||||
$course = $this->courseEntity($started['uuid']);
|
||||
|
||||
foreach ($course->getSessions() as $session) {
|
||||
self::assertSame(
|
||||
CourseSession::STATUS_PLANNED,
|
||||
$session->getStatus(),
|
||||
sprintf('جلسهٔ %d نباید رزرو مانده باشد', $session->getSessionNumber()),
|
||||
);
|
||||
self::assertNull($session->getAppointment());
|
||||
}
|
||||
}
|
||||
|
||||
public function testAnotherClinicCannotSeeTheCourse(): void
|
||||
{
|
||||
[$owner, $section, , , $patient] = $this->clinicWithPatient();
|
||||
|
||||
@@ -216,6 +216,44 @@ class PackageLedgerTest extends ApiTestCase
|
||||
self::assertSame([6, 5, 6], array_column($ledger['data']['rows'], 'running_balance'));
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ `credit_refundable: false` اعتبار برگشته را پس میگیرد — **بدون** حذف ردیف.
|
||||
*
|
||||
* دفتر append-only است، پس «پس گرفتن» یک ردیف `adjustment` منفی است نه پاک کردن
|
||||
* `refund`. تاریخچه باید نشان بدهد اعتبار برگشت و بعد طبق سیاست پس گرفته شد.
|
||||
*/
|
||||
public function testAPolicyThatDoesNotRefundCreditTakesItBackWithAnAdjustment(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section, 'لیزر');
|
||||
|
||||
// نوبت حدود یک روز دیگر است؛ پنجرهٔ ۹۶ ساعته یعنی این لغو **بیرون** بازهٔ رایگان
|
||||
// نیست بلکه درونِ محدودهٔ جریمه میافتد — تنها حالتی که سیاست اعتبار اثر دارد.
|
||||
$saved = $this->authJson('PUT', '/api/v1/cancellation-policy', $user, [
|
||||
'free_window_hours' => 96,
|
||||
'penalty_mode' => 'none',
|
||||
'credit_refundable' => false,
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode(), json_encode($saved, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
|
||||
$appointment = $this->reload($this->appointment($doctor, $patient, $service, (int) $address->getClinicId()));
|
||||
|
||||
self::assertTrue($this->consumption()->consumeFor($appointment));
|
||||
|
||||
$body = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user, ['by' => 'user']);
|
||||
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
self::assertFalse($body['data']['credit_refundable']);
|
||||
|
||||
$ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user);
|
||||
$kinds = array_column($ledger['data']['rows'], 'kind');
|
||||
|
||||
self::assertSame(['purchase', 'consume', 'refund', 'adjustment'], $kinds, 'هیچ ردیفی حذف نمیشود');
|
||||
|
||||
$entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);
|
||||
self::assertSame(5, $this->ledgerService()->balance($entity), 'جلسه پس گرفته شد');
|
||||
}
|
||||
|
||||
/** `confirm` idempotent است؛ اجرای دومش نباید جلسهٔ دوم بخورد. */
|
||||
public function testConsumingTwiceForTheSameAppointmentTakesOnlyOneSession(): void
|
||||
{
|
||||
|
||||
@@ -342,6 +342,63 @@ class ReportTest extends ApiTestCase
|
||||
$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
|
||||
|
||||
Reference in New Issue
Block a user