PUT /service-item/{uuid}/segments deletes and rewrites. deleteForService issues
a DQL DELETE that runs immediately, and three validations — duration, occupancy
and constraints — only ran afterwards, while building the new rows. A rejected
request therefore deleted the service's segments and saved nothing, and the
service silently fell back to "one continuous block": different duration,
different resources, on every future appointment, with a 422 as the only clue.
Validation now happens before the delete, and the delete plus rewrite are one
transaction. A test pins it: an unknown constraint is refused and the previous
two segments are still there afterwards.
While in there, the caps the task asked for and never got: 20 segments and 10
requirements per segment. The availability engine evaluates resource
combinations per segment per requirement, so the numbers protect the search
rather than the table. They are generous — no real service reaches them, but a
bad payload does.
The plan response now carries patient_facing_minutes. "Set aside 90 minutes"
is wrong for an appointment where 40 of them are waiting for anaesthetic to
take effect, and computing it once in the backend stops each client summing it
differently.
A condition on a fact the request never supplies still evaluates to false —
that part was right — but it now logs a warning naming the policy and listing
the facts that were available. A rule that hits that line every time is
effectively switched off, and nothing said so.
A new policy version can no longer start in the past: yesterday's appointments
were priced under the previous text, and their price trace points at the
version. Backdating makes that trace describe a rule that did not exist.
require_resource errors name the policy that demanded the role. Knowing a room
is missing does not tell an operator which of ten active rules to look at.
Six operators now have a test each. An operator that compares wrongly produces
a rule that always matches or never does, and neither raises anything.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
566 lines
27 KiB
PHP
566 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(),
|
||
'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());
|
||
}
|
||
}
|