# تسک ۰۴ — کاتالوگ خدمات نسخهٔ ۲: گروه آیتم، دو نوع زمان، ناسازگاری

**فاز:** ۱ (هسته) · **وابستگی:** ۰۱ · **زمان:** ۱۴-۱۶ ساعت

---

## هدف

مستند بند ۵ می‌گوید انتخاب آیتم خودش قانون دارد و این قوانین **نباید** به موتور قوانین
سپرده شوند: «حتماً یک سطح انرژی، فقط یکی»، «بین ۱ تا ۸ دندان»، «بیکینی با فول‌بادی
جمع نمی‌شود». و مهم‌تر: هر آیتم دو زمان دارد — «زمان تنها» و «زمان اضافه».

## وضعیت فعلی

```php
// src/ClinicService/Entity/ServiceItem.php
private ?int $durationMinutes = null;   // یک عدد، تخت
private int  $priceRials = 0;
private bool $bookable = false;
```

و در `AppointmentController::serviceSlots()`:

```php
$totalMinutes += $duration;   // ← جمع ساده؛ همان فرمولی که مستند ردش می‌کند
```

نتیجه: بیمار که «صورت + بیکینی» می‌خواهد، ۱۵+۱۵=۳۰ دقیقه ظرفیت می‌گیرد در حالی که
واقعیت ۱۵+۸=۲۳ دقیقه است. هفت دقیقه ضرب در روزی ۲۰ نوبت = یک ساعت ظرفیت هدررفته در روز.

همچنین `ServiceSection` تک‌سطحی است و دسته‌بندی درختی مستند را ندارد.

## دامنه

**هست:**
- `ServiceCategory` درختی (جدا از `ServiceSection` موجود که «بخش کلینیک» است)
- `ItemGroup` با `min_select` / `max_select`
- روی `ServiceItem`: `solo_duration_minutes` و `additional_duration_minutes`
- `ServiceItemRelation` برای `incompatible_with` و `requires`
- `ServiceBranchOverride` برای قیمت و مدت اختصاصی شعبه
- `session_count` روی سرویس (تک‌جلسه یا دوره‌ای — پروتکل کاملش تسک ۱۲)
- `ServiceSelectionValidator` — اعتبارسنجی انتخاب کاربر پیش از هر محاسبه
- `DurationCalculator` — محاسبهٔ درست مدت با دو نوع زمان

**نیست:** بخش‌های نوبت و نیازمندی منبع (تسک ۰۵)، اعمال روی جستجوی وقت (تسک ۰۶).

## Endpoint ها

| متد | مسیر | توضیح |
|---|---|---|
| GET | `/api/v1/service-categories/tree` | درخت دسته‌بندی |
| POST/PATCH/DELETE | `/api/v1/service-category[/{uuid}]` | |
| GET/POST | `/api/v1/service-item/{uuid}/groups` | گروه‌های آیتم یک سرویس |
| PATCH/DELETE | `/api/v1/item-group/{uuid}` | |
| PUT | `/api/v1/item-group/{uuid}/items` | جایگزینی کامل آیتم‌های گروه |
| PUT | `/api/v1/service-item/{uuid}/relations` | ناسازگاری و پیش‌نیاز |
| PUT | `/api/v1/service-item/{uuid}/branch-overrides` | قیمت/مدت per شعبه |
| POST | `/api/v1/service-selection/validate` | اعتبارسنجی انتخاب + مدت و قیمت محاسبه‌شده |

`POST /service-selection/validate` مهم‌ترین endpoint این تسک است: سایت عمومی و پنل هر دو
پیش از رفتن به مرحلهٔ انتخاب زمان، آن را صدا می‌زنند.

## معیار پذیرش

- ✅ موفق: سرویس «لیزر» با گروه «نواحی» (`min=1, max=8`) و آیتم‌های صورت (تنها ۱۵، اضافه ۸)
  و بیکینی (تنها ۱۲، اضافه ۸). انتخاب هر دو →
  `POST /service-selection/validate` برمی‌گرداند `total_duration_minutes = 23`
  (اولین آیتم زمان تنها، بقیه زمان اضافه) و `valid = true`.
- ✅ موفق: انتخاب فقط بیکینی → `total_duration_minutes = 12`.
- ✅ موفق: شعبهٔ مرکزی برای همین سرویس `price_rials` بالاتر دارد →
  با `branch_uuid` مرکزی، قیمت override اعمال می‌شود.
- ❌ خطا: انتخاب صفر آیتم از گروهی با `min_select=1` → `valid=false` با
  `errors[{group_uuid, code: 'min_select', message: 'انتخاب حداقل یک مورد از «نواحی» الزامی است'}]`.
- ❌ خطا: انتخاب ۹ آیتم از گروهی با `max_select=8` → `valid=false` با کد `max_select`.
- ❌ خطا: انتخاب دو آیتم ناسازگار → `valid=false` با کد `incompatible` و نام هر دو آیتم.
- ❌ خطا: انتخاب آیتمی که پیش‌نیازش انتخاب نشده → `valid=false` با کد `missing_prerequisite`.
- ❌ خطا: uuid آیتم از محیط دیگر → `404` (نه ۴۲۲ — نباید وجودش لو برود).
- ⚠️ مرزی: `max_select = null` یعنی نامحدود.
- ⚠️ مرزی: گروه با `min_select = 0` یعنی اختیاری.
- ⚠️ مرزی: آیتم بدون `additional_duration_minutes` → از `solo_duration_minutes` استفاده شود
  (سازگاری با داده‌های موجود که فقط یک `duration_minutes` دارند).
- ⚠️ مرزی: حلقهٔ پیش‌نیاز (الف پیش‌نیاز ب، ب پیش‌نیاز الف) → `422` هنگام ثبت رابطه.

## خروجی

- توسعهٔ `src/ClinicService/` (بدون شکستن endpoint های موجود)
- `assets/admin/pages/ServiceDetailPage.tsx` توسعه: تب «گروه‌ها و آیتم‌ها»
- `docs/api/clinic-services.md` به‌روزرسانی
- migration + backfill: `duration_minutes` موجود → `solo_duration_minutes`
