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

## واژگان — مهم‌ترین نکتهٔ این تسک

مستند و کد فعلی دو واژهٔ متفاوت برای چیزهای متفاوت دارند و قاطی کردنشان کل تسک را خراب می‌کند:

| مستند | معادل در این کدبیس | یعنی |
|---|---|---|
| دسته‌بندی (`service_category`) | **`ServiceCategory` جدید، درختی** | «زیبایی › لیزر» — فقط برای مرتب کردن |
| سرویس (`service`) | **`ServiceItem` موجود** | چیزی که بیمار رزرو می‌کند: «لیزر کندلا» |
| گروه آیتم (`item_group`) | **`ItemGroup` جدید** | «نواحی موردنظر»، «سطح انرژی» |
| آیتم (`service_item`) | **`ServiceOption` جدید** | «صورت»، «بیکینی»، «دندان ۵» |
| — | `ServiceSection` موجود | **بخش کلینیک** (رادیولوژی، تزریقات) — سازمانی، نه کاتالوگی |

⚠️ نام `ServiceItem` در کد فعلی معادل «سرویس» مستند است، نه «آیتم». پس آیتم‌های مستند
کلاس جدید `ServiceOption` می‌گیرند. تغییر نام `ServiceItem` **ممنوع** است — در
`appointment_service_items`, `service_item_staff`, `session_services`, `tariffs` و سه ریپوی
کلاینت استفاده می‌شود.

این جدول را عیناً در `docs/api/clinic-services.md` بنویس.

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

```
src/ClinicService/
├── Entity/
│   ├── ServiceCategory.php        # جدید — درختی
│   ├── ItemGroup.php              # جدید
│   ├── ServiceOption.php          # جدید — «آیتم» مستند
│   ├── ServiceOptionRelation.php  # جدید — ناسازگاری/پیش‌نیاز
│   ├── ServiceBranchOverride.php  # جدید
│   ├── ServiceItem.php            # موجود — ستون‌های تازه
│   └── ServiceSection.php         # موجود — دست‌نخورده
├── Service/
│   ├── ServiceSelectionValidator.php   # اعتبارسنجی انتخاب
│   ├── DurationCalculator.php          # محاسبهٔ مدت با دو نوع زمان
│   ├── ServicePriceResolver.php        # قیمت با override شعبه
│   └── ItemGroupService.php
└── Controller/
    ├── ServiceCategoryController.php
    ├── ItemGroupController.php
    └── ServiceSelectionController.php
```

## `DurationCalculator` — قلب تسک

```php
final class DurationCalculator
{
    /**
     * مدت کل یک انتخاب. قاعدهٔ مستند بند ۷:
     * «اولین آیتم هر گروه: زمان تنها — بقیه: زمان اضافه»
     *
     * @param ServiceOption[] $options انتخاب‌های کاربر
     */
    public function totalMinutes(ServiceItem $service, array $options, ?Branch $branch = null): int
    {
        $base = $this->baseDuration($service, $branch);   // مدت پایهٔ سرویس (ممکن است ۰ باشد)

        $byGroup = [];
        foreach ($options as $option) {
            $byGroup[$option->getGroup()->getId()][] = $option;
        }

        $total = $base;
        foreach ($byGroup as $groupOptions) {
            // ترتیب پایدار: بلندترین «زمان تنها» اول، تا انتخاب کاربر روی نتیجه اثر نگذارد
            usort($groupOptions, fn($a, $b) => $b->getSoloMinutes() <=> $a->getSoloMinutes());
            $total += $groupOptions[0]->getSoloMinutes();
            foreach (array_slice($groupOptions, 1) as $rest) {
                $total += $rest->getAdditionalMinutes() ?? $rest->getSoloMinutes();
            }
        }

        return $total;
    }
}
```

**چرا مرتب‌سازی نزولی؟** بدون آن، «صورت بعد بیکینی» و «بیکینی بعد صورت» دو مدت متفاوت
می‌دهند و همان انتخاب در دو نشست دو قیمت/دو ظرفیت می‌گیرد. مستند این را نگفته ولی
لازمهٔ قطعی بودن است. تصمیم: بیشترین زمان تنها، «آیتم اصلی» است.

**چرا per گروه، نه per کل انتخاب؟** آماده‌سازی per نوع کار است. «سطح انرژی» و «ناحیه»
دو کار متفاوت‌اند و هر کدام آماده‌سازی خودش را دارد.

## `ServiceSelectionValidator`

```php
/** @return SelectionResult{valid: bool, errors: SelectionError[], total_minutes: int, total_price_rials: int} */
public function validate(EntityContext $ctx, ServiceItem $service, array $optionUuids, ?Branch $branch): SelectionResult
```

ترتیب بررسی — عمداً همین ترتیب:

```
۱. مالکیت محیط همهٔ uuid ها      → یک بیگانه = 404، نه پیام دقیق‌تر
۲. آیتم‌ها واقعاً به این سرویس تعلق دارند → 422
۳. قید min/max هر گروه
۴. ناسازگاری‌ها
۵. پیش‌نیازها
۶. محاسبهٔ مدت و قیمت (فقط اگر ۱..۵ سبز باشند)
```

مرحلهٔ ۱ اول است چون پیام‌های مراحل بعد وجود و نام آیتم را لو می‌دهند — دقیقاً همان
نشتی‌ای که در `GET /api/v1/appointment-service-slots` پیدا و رفع شد
(`docs/architecture/tenancy.md`، جدول «uuid از درخواست»).

خطاها **همه با هم** برگردانده می‌شوند، نه اولی. فرم انتخاب باید همهٔ ایرادها را یک‌جا
نشان دهد.

## `ServiceOption`

```php
class ServiceOption
{
    use TenantOwnedTrait;                       // uuid از request می‌آید
    private ItemGroup $group;
    private string $name;
    private int  $soloMinutes;                  // «زمان تنها»
    private ?int $additionalMinutes = null;     // «زمان اضافه»؛ null → soloMinutes
    private int  $priceRials = 0;
    private int  $sortOrder = 0;
    private bool $active = true;
}
```

`additionalMinutes` تهی‌پذیر عمدی است: مقدار null یعنی «تعریف نشده، محافظه‌کارانه رفتار کن»
و همان `soloMinutes` را می‌گیرد. این باعث می‌شود مهاجرت داده‌های موجود بدون تغییر رفتار
انجام شود؛ کلینیک بعداً عدد واقعی را وارد می‌کند و ظرفیتش آزاد می‌شود.

## `ServiceOptionRelation`

```php
private ServiceOption $source;
private ServiceOption $target;
private string $type;      // TYPE_INCOMPATIBLE | TYPE_REQUIRES
```

- `incompatible` **متقارن** است: ثبت (الف، ب) خودکار (ب، الف) را هم معنا می‌دهد.
  در repository با `WHERE (source IN :sel AND target IN :sel)` هر دو جهت پوشش داده می‌شود؛
  ردیف دوم ذخیره نمی‌شود.
- `requires` **جهت‌دار** است و باید بدون حلقه بماند. تشخیص حلقه با DFS هنگام ثبت
  (`ServiceOptionRelationService::assertNoCycle()`).

## قیمت با override شعبه

```php
final class ServicePriceResolver
{
    /** ترتیب: override شعبه ← تعرفهٔ سال جاری ← قیمت پایهٔ سرویس */
    public function basePrice(ServiceItem $service, ?Branch $branch, int $at): int;
}
```

`Tariff` موجود (سالانه) دست‌نخورده می‌ماند و در این زنجیره قرار می‌گیرد. لیست قیمت
بازه‌دار کامل کار تسک ۰۸ است؛ اینجا فقط لایهٔ شعبه اضافه می‌شود.

## پنل ادمین

`ServiceDetailPage.tsx` موجود یک تب می‌گیرد: «گروه‌ها و آیتم‌ها».

- لیست گروه‌ها با `min/max` قابل ویرایش inline
- زیر هر گروه، جدول آیتم‌ها با ستون‌های: نام، زمان تنها، زمان اضافه، قیمت، فعال
- ناسازگاری/پیش‌نیاز با `SearchableSelect` چندانتخابی روی آیتم‌های همان سرویس
- پیش‌نمایش زنده: «انتخاب صورت + بیکینی → ۲۳ دقیقه» با صدا زدن
  `POST /service-selection/validate` (debounce ۴۰۰ms)

پیش‌نمایش زنده اختیاری نیست — بدون آن، کلینیک تفاوت «زمان تنها» و «زمان اضافه» را
نمی‌فهمد و هر دو را یک عدد می‌گذارد، که یعنی کل این تسک بی‌اثر می‌شود.
