# معماری — تسک ۰۵

## ساختار فایل

```
src/Appointment/Plan/
├── Entity/
│   ├── SegmentTemplate.php
│   └── SegmentRequirement.php
├── Dto/
│   ├── AppointmentPlan.php        # نتیجهٔ نهایی — immutable
│   ├── PlannedSegment.php
│   └── PlannedRequirement.php
├── Service/
│   ├── AppointmentPlanBuilder.php # ارکستراتور
│   ├── SegmentAssembler.php       # جمع‌آوری + ادغام بخش‌ها
│   ├── SegmentDurationResolver.php# مدت هر بخش
│   └── RequirementResolver.php    # نیازمندی → منابع کاندید
├── Controller/
│   ├── SegmentTemplateController.php
│   └── AppointmentPlanController.php
└── Exception/NoEligibleResourceException.php
```

پنج کلاس سرویس به‌جای یک کلاس بزرگ، چون هر کدام یک دلیل تغییر دارد: ادغام بخش‌ها،
محاسبهٔ مدت، و پیدا کردن منبع کاندید سه مسئلهٔ مستقل‌اند و تسک ۰۹ فقط به دوتای اول
قلاب می‌زند.

## `SegmentTemplate`

```php
class SegmentTemplate
{
    use TenantOwnedTrait;

    public const OWNER_SERVICE = 'service';   // بخش پایهٔ سرویس
    public const OWNER_OPTION  = 'option';    // بخش اضافهٔ یک آیتم

    private string  $ownerType;
    private ?ServiceItem   $serviceItem = null;
    private ?ServiceOption $option      = null;

    private string  $name;                 // «انتظار اثر بی‌حسی»
    private string  $segmentType;          // کلید ادغام: prep | wait | treatment | aftercare | custom:*
    private int     $sequence;             // ترتیب اجرا
    private ?int    $fixedMinutes = null;  // مدت ثابت؛ null یعنی مدت پویا
    private ?int    $durationShare = null; // درصد از مدت محاسبه‌شدهٔ سرویس، وقتی fixedMinutes نیست
    private bool    $patientPresent = true;
    private bool    $mergeable = false;    // با بخش‌های هم‌نوع ادغام می‌شود
    private bool    $active = true;
}
```

### مدت ثابت یا سهمی

دو حالت، دقیقاً یکی از آن‌ها:

- `fixedMinutes = 30` — انتظار اثر کرم همیشه ۳۰ دقیقه است، چه یک ناحیه چه پنج ناحیه
- `durationShare = 100` — «خود لیزر» همهٔ مدتِ محاسبه‌شده از `DurationCalculator` (تسک ۰۴)
  را می‌گیرد

جمع `durationShare` همهٔ بخش‌های یک سرویس باید دقیقاً ۱۰۰ باشد (اگر هیچ بخش سهمی نباشد،
شرط بی‌اثر است). اعتبارسنجی هنگام ذخیرهٔ الگو، نه هنگام ساخت برنامه.

## `SegmentRequirement`

```php
class SegmentRequirement
{
    public const OCCUPANCY_EXCLUSIVE = 'exclusive';  // منبع کامل اشغال
    public const OCCUPANCY_SHARED    = 'shared';     // یک واحد از ظرفیت
    public const OCCUPANCY_PASSIVE   = 'passive';    // رزرو ولی بدون کار فعال

    private SegmentTemplate $segment;
    private ResourceType $role;             // نقش: اپراتور، دستگاه، اتاق
    private int  $count = 1;
    private ?ResourcePool $pool = null;     // «هر عضو این استخر»
    private ?ClinicResource $specific = null; // منبع مشخص (کم‌کاربرد ولی لازم)
    private array $requiredSkills = [];     // skill_id[] — همه لازم‌اند، نه یکی
    private array $constraints = [];        // {same_gender_as_patient: true, attributes: {...}}
    private string $occupancy = self::OCCUPANCY_EXCLUSIVE;
}
```

`pool` و `specific` هر دو تهی‌پذیرند؛ اگر هیچ‌کدام نباشد یعنی «هر منبعِ آن نقش در آن شعبه
که شرط‌ها را دارد».

### `constraints` — فهرست بسته

مثل `DiscountRule`، شرط‌ها از یک فهرست بسته می‌آیند، نه کد دلخواه:

| کلید | مقدار | معنی |
|---|---|---|
| `same_gender_as_patient` | bool | منبع باید `attributes.gender` برابر جنسیت بیمار داشته باشد |
| `attributes` | object اسکالر | تطبیق دقیق روی `clinic_resources.attributes` |
| `min_skill_level` | 1..5 | حداقل سطح مهارت |

هر کلید ناشناخته → `422` هنگام ذخیره. این محدودیت عمدی است (مستند بند ۸): تسک ۰۶ باید
همهٔ این‌ها را به یک کوئری تبدیل کند.

## `AppointmentPlanBuilder` — جریان

```php
public function build(PlanRequest $request): AppointmentPlan
{
    // ۱. اعتبارسنجی انتخاب (تسک ۰۴) — اگر نامعتبر بود همین‌جا تمام
    $selection = $this->selectionValidator->validate(...);

    // ۲. جمع‌آوری بخش‌ها: پایهٔ سرویس + بخش‌های اضافهٔ هر آیتم انتخابی
    $raw = $this->assembler->collect($service, $selection->options);

    // ۳. ادغام هم‌نوع‌ها (mergeable=true و segmentType یکسان → یکی)
    $merged = $this->assembler->merge($raw);

    // ۴. مدت هر بخش
    $timed = $this->durationResolver->resolve($merged, $selection->totalMinutes);

    // ۵. چیدمان: offset تجمعی بر اساس sequence
    $sequenced = $this->assembler->layout($timed);

    // ۶. نیازمندی‌ها → منابع کاندید (اینجا کوئری می‌خورد)
    $withResources = $this->requirementResolver->resolve($sequenced, $request->branch, $request->patient);

    // ۷. نقطهٔ اتصال تسک ۰۹: قوانین دستهٔ «منبع» و «زمان» اینجا اعمال می‌شوند
    //    فعلاً یک no-op PolicyApplier تزریق شود تا امضا بعداً عوض نشود.
    return $this->policies->applyToPlan($withResources);
}
```

مرحلهٔ ۷ عمداً از روز اول در امضا هست حتی وقتی خالی است — افزودنش بعداً یعنی تغییر
امضای عمومی و همهٔ تست‌ها.

## ادغام بخش‌ها

```
ورودی: بخش‌های سرویس + بخش‌های همهٔ آیتم‌های انتخابی
گروه‌بندی بر اساس segmentType
برای هر گروه:
    اگر همهٔ اعضا mergeable=true  → یک بخش با:
        نام: نام بخشِ سرویس (یا اولین)
        مدت: بیشترین مدت ثابت، یا مجموع سهم‌ها
        نیازمندی‌ها: اتحاد (بدون تکرار؛ count بیشینه برای هر نقش)
    وگرنه → همه جدا می‌مانند، به ترتیب sequence
```

مثال مستند: بیمار پنج ناحیه انتخاب می‌کند، هر ناحیه یک بخش «آماده‌سازی» با
`mergeable=true` دارد → یک آماده‌سازی، نه پنج تا.

## `RequirementResolver`

```php
/** @return ClinicResource[] منابع واجد شرایط برای این نیازمندی */
public function candidates(SegmentRequirement $req, Branch $branch, ?Patient $patient): array
```

از `ClinicResourceRepository::findEligible()` (تسک ۰۲) استفاده می‌کند:
شعبه + نقش + `HAVING COUNT(DISTINCT skill) = n` + فیلتر `attributes` در PHP
(JSON در MariaDB قابل ایندکس‌گذاری مطمئن نیست؛ تعداد منابع یک شعبه کوچک است).

اگر `candidates === []` → `NoEligibleResourceException` با پیام ساخته‌شده از نقش و مهارت‌ها:

```php
throw new NoEligibleResourceException(sprintf(
    'هیچ %s با مهارت %s در شعبهٔ %s موجود نیست',
    $req->getRole()->getName(), implode('، ', $skillNames), $branch->getName()
));
```

پیام انسانی اجباری است — مستند بند ۱۰ صریح می‌گوید خطا باید بگوید چه چیزی کم است.

## سازگاری: سرویس بدون الگو

`SegmentAssembler::collect()` وقتی هیچ `SegmentTemplate` پیدا نکرد، یک بخش مجازی می‌سازد:

```php
new PlannedSegment(
    name: $service->getName(),
    segmentType: 'treatment',
    durationMinutes: $selection->totalMinutes,
    requirements: [ PlannedRequirement::doctorDefault() ],   // منبع type=doctor
);
```

این تضمین می‌کند حالت `booking_mode=service` امروزی، وقتی به موتور جدید مهاجرت کند،
دقیقاً همان رفتار را داشته باشد.

## پنل ادمین

`ServiceSegmentsPage.tsx` (زیرصفحهٔ `ServiceDetailPage`):

- لیست مرتب بخش‌ها با drag ندارد؛ `sequence` عددی
- هر بخش قابل بازشدن: مدت (ثابت/سهمی)، حضور بیمار، ادغام‌پذیر
- زیر هر بخش، نیازمندی‌ها: نقش (`SearchableSelect`)، تعداد، استخر، مهارت‌ها (چیپ)،
  نوع اشغال (رادیو با توضیح فارسی هر گزینه)
- **نوار پیش‌نمایش زمانی**: چهار بخش روی یک خط با عرض متناسب مدت و آیکن منابع هر بخش
  — این تنها راهی است که کاربر غیرفنی می‌فهمد چه ساخته
