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>
497 lines
24 KiB
PHP
497 lines
24 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(),
|
||
'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());
|
||
}
|
||
}
|