Files
clinicpro/tests/Appointment/AppointmentPlanTest.php
T
hamedandClaude Opus 5 22c89fbae4 feat(plan): multi-segment appointments with per-segment resource requirements
Section 7 of the design document, and the reason the whole resource layer exists.
A laser session is not one block: numbing cream (5 min, room + operator), waiting for
it to work (30 min, room only), the laser itself (20 min, room + operator + device),
aftercare (5 min, room + operator). Under the single-interval model the operator is
locked for all 60 minutes while actually working 30 — half the capacity thrown away.

AppointmentPlanBuilder turns (service, selected items, branch, patient) into a plan:
segments with offsets, durations and resource requirements. It deliberately assigns
no absolute time and no specific resource — that is the next task. This only produces
the *shape* of the appointment.

Segment duration comes from one of two sources. A fixed segment carries its own
number; an item-driven one gets its duration from task 04's DurationCalculator, so
"the laser itself" grows with two treated areas while "waiting for the cream" does
not. One number could not have expressed that.

Three contracts worth stating:

- A service with no segment templates falls back to a single continuous segment
  requiring the doctor resource — exactly today's behaviour. Without it every
  existing service would have become unplannable overnight.
- A segment with no requirements is valid: "waiting at home" consumes time but
  occupies nothing.
- same_gender_as_patient with an unknown patient gender is a 422, not a silently
  dropped requirement. Dropping it quietly would route the patient to a resource the
  clinic said must not serve them.

When no resource qualifies, the error names the role, the skill and the branch —
"no female operator with the skill «Alexandrite laser» is available at «Central»" —
rather than an empty result the caller has to interpret (section 10).

occupancy_offset carries each requirement's setup/cleanup minutes for the availability
engine. It is taken as the maximum across candidates, because the builder does not yet
know which resource will be picked and under-reserving means the next appointment
lands on top of the cleanup.

11 tests covering the document's reference example (offsets 0/5/35/55, total 60),
item-driven scaling, the no-template fallback, all three gender-constraint outcomes,
merging and both caps. 1186 tests overall. phpstan back at its 14-error baseline;
slot-mode frozen contract green.

The admin segments page is not built; the checklist records it with a target. The
backend and preview endpoint are complete and consumable without it.

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

341 lines
16 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']);
}
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());
}
}