Files
clinicpro/tests/Appointment/AppointmentPlanTest.php
T
hamedandClaude Opus 5 b3c331f0cb 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>
2026-08-01 14:22:22 +03:30

497 lines
24 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\Appointment;
use App\Appointment\Plan\Entity\SegmentRequirement;
use App\Appointment\Plan\Entity\SegmentTemplate;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\DoctorAddress;
use App\Resource\Entity\ResourceType;
use App\Tests\ApiTestCase;
/**
* برنامهٔ چندبخشی نوبت — بند ۷ مستند.
*
* مثال مرجع: بی‌حسی ۵ · انتظار ۳۰ (اپراتور آزاد) · لیزر ۲۰ · مراقبت ۵ = ۶۰ دقیقه،
* در حالی که اپراتور فقط ۳۰ دقیقه اشغال است.
*/
class AppointmentPlanTest extends ApiTestCase
{
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress} */
private function clinicWithBranch(): 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);
$this->em->flush();
return [$user, $section, $address];
}
private function service(ServiceSection $section, string $name, ?int $solo = 20, ?int $additional = null): ServiceItem
{
$item = new ServiceItem($section, $name);
$item->setSoloDurationMinutes($solo);
$item->setAdditionalDurationMinutes($additional);
$this->em->persist($item);
$this->em->flush();
return $item;
}
private function resourceType(DoctorAddress $address, string $code, string $name): ResourceType
{
$type = new ResourceType($address->tenantEntityType(), $address->tenantEntityId(), $code, $name);
$this->em->persist($type);
$this->em->flush();
return $type;
}
/** @param array<string, mixed> $extra */
private function resource(User $user, DoctorAddress $address, ResourceType $type, string $name, array $extra = []): array
{
$body = $this->authJson('POST', '/api/v1/resource', $user, $extra + [
'address_uuid' => $address->getUuid(),
'type_uuid' => $type->getUuid(),
'name' => $name,
]);
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
return $body['data'];
}
/** @param list<array<string, mixed>> $segments */
private function setSegments(User $user, ServiceItem $service, array $segments): array
{
$body = $this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/segments", $user, [
'segments' => $segments,
]);
return $body;
}
/** @param array<string, mixed> $extra */
private function preview(User $user, ServiceItem $service, DoctorAddress $address, array $extra = []): array
{
return $this->authJson('POST', '/api/v1/appointment-plan/preview', $user, $extra + [
'service_uuid' => $service->getUuid(),
'branch_uuid' => $address->getUuid(),
]);
}
/** مثال مرجع مستند، دقیقاً با همان آفست‌ها. */
public function testFourSegmentLaserPlan(): void
{
[$user, $section, $address] = $this->clinicWithBranch();
$service = $this->service($section, 'لیزر', 20);
$room = $this->resourceType($address, 'room', 'اتاق');
$operator = $this->resourceType($address, 'operator', 'اپراتور');
$device = $this->resourceType($address, 'device', 'دستگاه');
$this->resource($user, $address, $room, 'اتاق ۱');
$this->resource($user, $address, $operator, 'اپراتور ۱');
$this->resource($user, $address, $device, 'لیزر ۱');
$roomReq = ['type_uuid' => $room->getUuid()];
$operatorReq = ['type_uuid' => $operator->getUuid()];
$deviceReq = ['type_uuid' => $device->getUuid()];
$this->setSegments($user, $service, [
['sequence' => 1, 'name' => 'بی‌حسی موضعی', 'duration_minutes' => 5, 'requirements' => [$roomReq, $operatorReq]],
['sequence' => 2, 'name' => 'انتظار اثر کرم', 'duration_minutes' => 30, 'requirements' => [$roomReq]],
['sequence' => 3, 'name' => 'خود لیزر', 'duration_source' => 'items', 'requirements' => [$roomReq, $operatorReq, $deviceReq]],
['sequence' => 4, 'name' => 'مراقبت بعد', 'duration_minutes' => 5, 'requirements' => [$roomReq, $operatorReq]],
]);
self::assertSame(200, $this->responseCode());
$plan = $this->preview($user, $service, $address);
self::assertSame(200, $this->responseCode(), json_encode($plan, JSON_UNESCAPED_UNICODE));
$data = $plan['data'];
self::assertSame(60, $data['total_minutes']);
self::assertCount(4, $data['segments']);
self::assertSame([0, 5, 35, 55], array_column($data['segments'], 'offset_minutes'));
self::assertSame([5, 30, 20, 5], array_column($data['segments'], 'duration_minutes'));
// نکتهٔ اصلی بند ۷: اپراتور در بخش انتظار **نیست**.
$waitRoles = array_column($data['segments'][1]['requirements'], 'role');
self::assertSame(['room'], $waitRoles, 'اپراتور در انتظار آزاد است');
$laserRoles = array_column($data['segments'][2]['requirements'], 'role');
sort($laserRoles);
self::assertSame(['device', 'operator', 'room'], $laserRoles);
}
/** بخشی که مدتش از آیتم‌ها می‌آید با دو ناحیه طولانی‌تر می‌شود. */
public function testItemDrivenSegmentUsesTheDurationCalculator(): void
{
[$user, $section, $address] = $this->clinicWithBranch();
$service = $this->service($section, 'لیزر', 0);
$face = $this->service($section, 'صورت', 15, 8);
$bikini = $this->service($section, 'بیکینی', 12, 8);
$room = $this->resourceType($address, 'room', 'اتاق');
$this->resource($user, $address, $room, 'اتاق ۱');
$this->setSegments($user, $service, [
['sequence' => 1, 'name' => 'آماده‌سازی', 'duration_minutes' => 5, 'mergeable' => true, 'requirements' => [['type_uuid' => $room->getUuid()]]],
['sequence' => 2, 'name' => 'خود لیزر', 'duration_source' => 'items', 'requirements' => [['type_uuid' => $room->getUuid()]]],
]);
$plan = $this->preview($user, $service, $address, [
'item_uuids' => [$face->getUuid(), $bikini->getUuid()],
]);
self::assertSame(200, $this->responseCode());
// ۵ آماده‌سازی + ۲۳ لیزر (۱۵ + ۸، نه ۲۷) = ۲۸
self::assertSame(28, $plan['data']['total_minutes']);
self::assertSame(23, $plan['data']['segments'][1]['duration_minutes']);
}
/** سرویس بدون الگوی بخش، همان رفتار امروز را می‌گیرد: یک بخش پیوسته با پزشک. */
public function testServiceWithoutTemplatesFallsBackToASingleSegment(): void
{
[$user, $section, $address] = $this->clinicWithBranch();
$service = $this->service($section, 'ویزیت', 20);
$doctorType = $this->resourceType($address, ResourceType::CODE_DOCTOR, 'پزشک');
$this->resource($user, $address, $doctorType, 'دکتر یک');
$plan = $this->preview($user, $service, $address);
self::assertSame(200, $this->responseCode(), json_encode($plan, JSON_UNESCAPED_UNICODE));
self::assertSame(20, $plan['data']['total_minutes']);
self::assertCount(1, $plan['data']['segments']);
self::assertSame('doctor', $plan['data']['segments'][0]['requirements'][0]['role']);
}
/** بخش بدون هیچ نیازمندی معتبر است: زمان می‌گیرد، منبعی نمی‌گیرد. */
public function testSegmentWithoutRequirementsIsValid(): void
{
[$user, $section, $address] = $this->clinicWithBranch();
$service = $this->service($section, 'مراقبت خانگی', 10);
$this->setSegments($user, $service, [
['sequence' => 1, 'name' => 'انتظار در خانه', 'duration_minutes' => 45, 'patient_present' => false, 'requirements' => []],
]);
self::assertSame(200, $this->responseCode());
$plan = $this->preview($user, $service, $address);
self::assertSame(45, $plan['data']['total_minutes']);
self::assertSame([], $plan['data']['segments'][0]['requirements']);
self::assertFalse($plan['data']['segments'][0]['patient_present']);
}
/** مهارتی که هیچ منبعی ندارد → خطای انسانی با نام نقش و مهارت (بند ۱۰). */
public function testMissingEligibleResourceGivesAHumanError(): void
{
[$user, $section, $address] = $this->clinicWithBranch();
$service = $this->service($section, 'لیزر', 20);
$operator = $this->resourceType($address, 'operator', 'اپراتور');
$skill = $this->authJson('POST', '/api/v1/skills', $user, ['name' => 'لیزر آلکساندرایت']);
self::assertSame(201, $this->responseCode());
// اپراتور هست ولی مهارت را ندارد.
$this->resource($user, $address, $operator, 'اپراتور بی‌مهارت');
$this->setSegments($user, $service, [
['sequence' => 1, 'name' => 'لیزر', 'duration_minutes' => 20, 'requirements' => [
['type_uuid' => $operator->getUuid(), 'skill_uuid' => $skill['data']['uuid']],
]],
]);
self::assertSame(200, $this->responseCode());
$body = $this->preview($user, $service, $address);
self::assertSame(422, $this->responseCode());
self::assertSame('ERR_NO_ELIGIBLE_RESOURCE', $body['errors'][0]['code']);
self::assertStringContainsString('اپراتور', $body['errors'][0]['message']);
self::assertStringContainsString('لیزر آلکساندرایت', $body['errors'][0]['message']);
self::assertStringContainsString('شعبهٔ مرکزی', $body['errors'][0]['message']);
}
/**
* قید جنسیت وقتی جنسیت بیمار نامشخص است نادیده گرفته **نمی‌شود** — رد کردن بی‌صدا
* یعنی بیمار به منبعی می‌رسد که قرار نبود.
*/
public function testSameGenderConstraintRequiresAKnownPatientGender(): void
{
[$user, $section, $address] = $this->clinicWithBranch();
$service = $this->service($section, 'لیزر', 20);
$operator = $this->resourceType($address, 'operator', 'اپراتور');
$this->resource($user, $address, $operator, 'اپراتور خانم', ['attributes' => ['gender' => 'female']]);
$this->setSegments($user, $service, [
['sequence' => 1, 'name' => 'لیزر', 'duration_minutes' => 20, 'requirements' => [
['type_uuid' => $operator->getUuid(), 'constraints' => [SegmentRequirement::CONSTRAINT_SAME_GENDER]],
]],
]);
self::assertSame(200, $this->responseCode());
$unknown = $this->preview($user, $service, $address);
self::assertSame(422, $this->responseCode());
self::assertStringContainsString('جنسیت بیمار', $unknown['errors'][0]['message']);
$female = $this->preview($user, $service, $address, ['patient_gender' => 'female']);
self::assertSame(200, $this->responseCode());
self::assertSame(1, $female['data']['segments'][0]['requirements'][0]['candidates']);
$male = $this->preview($user, $service, $address, ['patient_gender' => 'male']);
self::assertSame(422, $this->responseCode(), 'هیچ اپراتور آقایی نیست');
self::assertStringContainsString('آقایی', $male['errors'][0]['message']);
}
/** `setup/cleanup` در نمای کاربر نمی‌آید ولی برای تسک بعدی در پاسخ هست. */
public function testOccupancyOffsetCarriesResourceSetupAndCleanup(): void
{
[$user, $section, $address] = $this->clinicWithBranch();
$service = $this->service($section, 'لیزر', 20);
$device = $this->resourceType($address, 'device', 'دستگاه');
$this->resource($user, $address, $device, 'لیزر ۱', ['setup_minutes' => 5, 'cleanup_minutes' => 10]);
$this->setSegments($user, $service, [
['sequence' => 1, 'name' => 'لیزر', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $device->getUuid()]]],
]);
$plan = $this->preview($user, $service, $address);
$offset = $plan['data']['segments'][0]['requirements'][0]['occupancy_offset'];
self::assertSame(5, $offset['setup_minutes']);
self::assertSame(10, $offset['cleanup_minutes']);
}
public function testTotalBeyondTheDailyCapIsRejected(): void
{
[$user, $section] = $this->clinicWithBranch();
$service = $this->service($section, 'ماراتن', 20);
$body = $this->setSegments($user, $service, [
['sequence' => 1, 'name' => 'بخش بلند', 'duration_minutes' => 400],
['sequence' => 2, 'name' => 'بخش بلند دوم', 'duration_minutes' => 200],
]);
self::assertSame(422, $this->responseCode());
self::assertStringContainsString('سقف', $body['errors'][0]['message']);
}
public function testFixedSegmentNeedsAPositiveDuration(): void
{
[$user, $section] = $this->clinicWithBranch();
$service = $this->service($section, 'لیزر', 20);
$body = $this->setSegments($user, $service, [
['sequence' => 1, 'name' => 'بخش بی‌مدت', 'duration_minutes' => 0],
]);
self::assertSame(422, $this->responseCode());
self::assertSame('duration_minutes', $body['errors'][0]['field']);
}
/** بخش‌های `mergeable` هم‌نام یک بار می‌آیند. */
public function testMergeableSegmentsAppearOnce(): void
{
[$user, $section, $address] = $this->clinicWithBranch();
$service = $this->service($section, 'لیزر', 20);
$room = $this->resourceType($address, 'room', 'اتاق');
$this->resource($user, $address, $room, 'اتاق ۱');
$this->setSegments($user, $service, [
['sequence' => 1, 'name' => 'آماده‌سازی', 'duration_minutes' => 5, 'mergeable' => true, 'requirements' => [['type_uuid' => $room->getUuid()]]],
['sequence' => 2, 'name' => 'آماده‌سازی', 'duration_minutes' => 5, 'mergeable' => true, 'requirements' => [['type_uuid' => $room->getUuid()]]],
['sequence' => 3, 'name' => 'کار اصلی', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $room->getUuid()]]],
]);
$plan = $this->preview($user, $service, $address);
self::assertCount(2, $plan['data']['segments'], 'آماده‌سازی یک بار');
self::assertSame(25, $plan['data']['total_minutes']);
}
/**
* ⭐ ادغام واقعی: دو ناحیه که هرکدام «آماده‌سازی» خودشان را دارند.
*
* پیش از این، الگوهای آیتم‌های انتخاب‌شده اصلاً خوانده نمی‌شدند و پرچم `mergeable`
* هیچ کاری نمی‌کرد — در یک سرویس، دو بخشِ هم‌نام معنا ندارد.
*/
public function testSegmentsOfSelectedItemsAreMergedByName(): void
{
[$user, $section, $address] = $this->clinicWithBranch();
$face = $this->service($section, 'لیزر صورت', 20);
$bikini = $this->service($section, 'لیزر بیکینی', 30);
$room = $this->resourceType($address, 'room', 'اتاق');
$this->resource($user, $address, $room, 'اتاق ۱');
// آماده‌سازیِ بیکینی طولانی‌تر است؛ ادغام باید طولانی‌ترین را نگه دارد.
$this->setSegments($user, $face, [
['sequence' => 1, 'name' => 'آماده‌سازی', 'duration_minutes' => 5, 'mergeable' => true, 'requirements' => [['type_uuid' => $room->getUuid()]]],
['sequence' => 2, 'name' => 'کار اصلی', 'duration_minutes' => 0, 'duration_source' => 'items', 'requirements' => [['type_uuid' => $room->getUuid()]]],
]);
$this->setSegments($user, $bikini, [
['sequence' => 1, 'name' => 'آماده‌سازی', 'duration_minutes' => 12, 'mergeable' => true, 'requirements' => [['type_uuid' => $room->getUuid()]]],
]);
$plan = $this->preview($user, $face, $address, [
'item_uuids' => [$face->getUuid(), $bikini->getUuid()],
]);
$segments = $plan['data']['segments'];
$names = array_column($segments, 'name');
self::assertSame(['آماده‌سازی', 'کار اصلی'], $names, 'آماده‌سازی یک بار می‌آید');
self::assertSame(12, $segments[0]['duration_minutes'], 'طولانی‌ترین آماده‌سازی می‌ماند');
// کار اصلی از `DurationCalculator` می‌آید: ۲۰ + ۳۰.
self::assertSame(50, $segments[1]['duration_minutes']);
self::assertSame(62, $plan['data']['total_minutes']);
}
/**
* ⭐ تعداد منبع پس از ادغام **بیشینه** است، نه جمع و نه اولی.
*
* دو ناحیه با هم دو اتاق نمی‌خواهند؛ ولی اگر یکی دو نفر لازم داشت، ادغام نباید آن
* را به یک تنزل بدهد — نوبتی که نیروی کافی ندارد بدتر از نوبتِ نگرفته است.
*/
public function testMergingTakesTheLargestRequiredCount(): void
{
[$user, $section, $address] = $this->clinicWithBranch();
$face = $this->service($section, 'ناحیهٔ یک', 20);
$bikini = $this->service($section, 'ناحیهٔ دو', 20);
$operator = $this->resourceType($address, 'operator', 'اپراتور');
$this->resource($user, $address, $operator, 'اپراتور ۱');
$this->resource($user, $address, $operator, 'اپراتور ۲');
$this->setSegments($user, $face, [
['sequence' => 1, 'name' => 'آماده‌سازی', 'duration_minutes' => 10, 'mergeable' => true, 'requirements' => [['type_uuid' => $operator->getUuid(), 'count' => 1]]],
]);
$this->setSegments($user, $bikini, [
['sequence' => 1, 'name' => 'آماده‌سازی', 'duration_minutes' => 8, 'mergeable' => true, 'requirements' => [['type_uuid' => $operator->getUuid(), 'count' => 2]]],
]);
$plan = $this->preview($user, $face, $address, [
'item_uuids' => [$face->getUuid(), $bikini->getUuid()],
]);
$segments = $plan['data']['segments'];
self::assertCount(1, $segments);
self::assertSame(2, $segments[0]['requirements'][0]['count'], 'بیشینه، نه اولی');
}
/**
* ⭐ قطعیت: دو build با همان ورودی باید **بایت‌به‌بایت** یکی باشند.
*
* ترتیب منابع و بخش‌ها از کوئری می‌آید و کوئریِ بدون `ORDER BY` قطعی نیست. برنامه‌ای
* که بین پیش‌نمایش و رزرو جابه‌جا شود، یعنی کاربر چیزی را تأیید کرده که رزرو نشد.
*/
public function testTwoIdenticalBuildsProduceTheSamePlan(): void
{
[$user, $section, $address] = $this->clinicWithBranch();
$service = $this->service($section, 'لیزر', 20);
$room = $this->resourceType($address, 'room', 'اتاق');
$operator = $this->resourceType($address, 'operator', 'اپراتور');
foreach (['اتاق ۱', 'اتاق ۲', 'اتاق ۳'] as $name) {
$this->resource($user, $address, $room, $name);
}
foreach (['اپراتور ۱', 'اپراتور ۲'] as $name) {
$this->resource($user, $address, $operator, $name);
}
$this->setSegments($user, $service, [
['sequence' => 1, 'name' => 'آماده‌سازی', 'duration_minutes' => 5, 'requirements' => [['type_uuid' => $room->getUuid()], ['type_uuid' => $operator->getUuid()]]],
['sequence' => 2, 'name' => 'کار اصلی', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $room->getUuid()]]],
]);
$first = $this->preview($user, $service, $address)['data'];
$second = $this->preview($user, $service, $address)['data'];
self::assertSame(
json_encode($first, JSON_UNESCAPED_UNICODE),
json_encode($second, JSON_UNESCAPED_UNICODE),
);
}
/**
* ⭐ الگوی نمونه نقطهٔ شروع است، نه پیکربندی نهایی — و **بازنویسی خاموش نمی‌کند**.
*/
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();
[, $otherSection] = $this->clinicWithBranch();
$foreign = $this->service($otherSection, 'سرویس بیگانه', 20);
$this->authJson('POST', '/api/v1/appointment-plan/preview', $user, [
'service_uuid' => $foreign->getUuid(),
'branch_uuid' => $address->getUuid(),
]);
self::assertSame(404, $this->responseCode());
}
}