my/clinic-doctors now reports has_schedule per doctor, and the appointments page builds tabs from it. A doctor with no working days had a tab that could only ever show an empty timeline. The flag is resolved with one query for the whole list rather than one per doctor. Clinic owners now read this authenticated endpoint too instead of the public clinic doctor-list, which is where the flag lives; admin keeps the public list and, with no flag present, hides nobody. Also repairs fallout from making the resource supervisor mandatory: four test classes build resources through their own helpers and were failing with 422. The supervisorFor helper moved to ApiTestCase so all domains share one, rather than copying it per suite. Full backend suite is green again (1306 tests) — the previous commit only ran tests/Resource and missed this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
567 lines
27 KiB
PHP
567 lines
27 KiB
PHP
<?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(),
|
||
'supervisor_doctor_uuid' => $this->supervisorFor($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 testARejectedReplaceLeavesTheExistingSegmentsIntact(): 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' => 10, 'requirements' => [['type_uuid' => $room->getUuid()]]],
|
||
['sequence' => 2, 'name' => 'کار اصلی', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $room->getUuid()]]],
|
||
]);
|
||
self::assertCount(2, $this->preview($user, $service, $address)['data']['segments']);
|
||
|
||
// قیدِ ناشناخته: باید ۴۲۲ بگیرد **و** چیزی را خراب نکند.
|
||
$this->setSegments($user, $service, [
|
||
['sequence' => 1, 'name' => 'بخش تازه', 'duration_minutes' => 15, 'requirements' => [
|
||
['type_uuid' => $room->getUuid(), 'constraints' => ['same_blood_type']],
|
||
]],
|
||
]);
|
||
self::assertSame(422, $this->responseCode());
|
||
|
||
$after = $this->preview($user, $service, $address);
|
||
|
||
self::assertCount(2, $after['data']['segments'], 'بخشهای قبلی باید دستنخورده مانده باشند');
|
||
self::assertSame('آمادهسازی', $after['data']['segments'][0]['name']);
|
||
}
|
||
|
||
public function testTooManySegmentsIsRejected(): void
|
||
{
|
||
[$user, $section, $address] = $this->clinicWithBranch();
|
||
$service = $this->service($section, 'لیزر', 20);
|
||
|
||
$segments = [];
|
||
for ($i = 1; $i <= 21; $i++) {
|
||
$segments[] = ['sequence' => $i, 'name' => sprintf('بخش %d', $i), 'duration_minutes' => 5];
|
||
}
|
||
|
||
$this->setSegments($user, $service, $segments);
|
||
|
||
self::assertSame(422, $this->responseCode());
|
||
}
|
||
|
||
/**
|
||
* ⭐ «نود دقیقه وقت بگذارید» برای نوبتی که چهل دقیقهاش انتظار است، حرفِ درستی نیست.
|
||
* محاسبه یکجا در بکاند است تا هر کلاینت خودش جمع نزند.
|
||
*/
|
||
public function testThePlanReportsHowLongThePatientIsActuallyPresent(): 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' => 20, 'patient_present' => true, 'requirements' => [['type_uuid' => $room->getUuid()]]],
|
||
['sequence' => 2, 'name' => 'تمیزکاری', 'duration_minutes' => 15, 'patient_present' => false, 'requirements' => [['type_uuid' => $room->getUuid()]]],
|
||
]);
|
||
|
||
$plan = $this->preview($user, $service, $address)['data'];
|
||
|
||
self::assertSame(35, $plan['total_minutes']);
|
||
self::assertSame(20, $plan['patient_facing_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());
|
||
}
|
||
}
|