Files
clinicpro/tests/Appointment/AppointmentPlanTest.php
T
hamedandClaude Opus 5 47a40e2021 feat(plan): build the plan from the selected items too, so mergeable finally means something
The mergeable flag was stored, returned by the API and rendered in the editor
while changing nothing. The reason was upstream: the builder only ever read the
primary service's templates, and within one service two segments with the same
name do not occur — so the dedupe it already had could never fire.

Templates now come from the primary service plus every selected item, and
same-named mergeable segments collapse to one. Rules, with their reasons:

- the longest of the same-named segments survives — prepping two areas is not
  shorter than prepping the longer one alone
- a duration_source: "items" segment also appears once even when it is not
  marked mergeable, because DurationCalculator has already summed every item
  and repeating the segment counts that time twice
- the merged requirement count is the maximum, not the sum and not the first
  one seen: two areas do not need two rooms, but if one of them needed two
  operators, merging must not quietly demote that to one

Also pins that the plan is deterministic: two previews of the same input are
compared byte for byte. A plan that shifts between preview and booking means
the user confirmed something that was not what got booked.

Unrelated but found by running the suite on a Saturday: testPastStartsAreExcluded
searched "last week's Saturday", which is today when today is Saturday, so this
afternoon's slots were legitimately not in the past. It now searches two weeks
back, which is unambiguous on every weekday.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:40:05 +03:30

452 lines
21 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 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());
}
}