# نوبت‌دهی بر پایهٔ منبع: رابطهٔ منبع↔سرویس، گزینهٔ سرویس، و حل مدت/قیمت

## پروژه

`clinicpro` (backend + پنل ادمین).
**cross-repo:** قرارداد `POST /api/v1/appointment` و `GET /api/v1/appointment-booking-locations/{doctorUuid}` را سایت عمومی مصرف می‌کند
(`nobat724_front/services/response.js` → `getBookingLocations`, `getServiceSlots`, `postAppointment`).
تغییر قرارداد در build سایت خطا نمی‌دهد؛ باید دستی دنبال شود.

## قواعد غیرقابل‌مذاکره

این سه بند شرط پذیرش‌اند، نه توصیه. کاری که این‌ها را نقض کند «تمام‌شده» نیست حتی اگر تست‌هایش سبز باشد.

**۱. هر UI جدید داخل تم فعلی، در حد یک متخصص UI/UX.** هیچ تم، پالت، فونت یا کتابخانهٔ CSS تازه‌ای
ساخته نمی‌شود. مشخصاً:

- رنگ‌ها فقط از توکن‌های `assets/admin/styles.css` (`var(--primary)`، `var(--surface)`، `var(--border)`،
  `var(--text-2)`، …). هیچ hex خامی در کامپوننت جدید.
- کامپوننت از `assets/admin/components/ui/` استفاده شود، نه نسخهٔ دست‌ساز: `DataTable`، `Modal`،
  `ConfirmDialog`، `PageHeader` (با `backTo`)، `SearchableSelect`، `StatusBadge`، `Pagination`،
  `PersianDateInput`. **`<select>` خام ممنوع** — همیشه `SearchableSelect`.
- سه حالت نمایش باید سالم باشند: دارک‌مود (`[data-theme="dark"]`)، حالت فشرده
  (`[data-density="compact"]`) و موبایل ۳۹۰px بدون اسکرول افقی.
- دو تلهٔ شناخته‌شدهٔ همین CSS: `.card` **padding ندارد** (برای فاصله `card-pad` اضافه کن) و
  `.field` خودش جعبهٔ ورودی است (برچسبِ بالای فیلد با `.field-block` می‌آید، نه داخل `.field`).
- RTL و متن فارسی؛ تاریخ‌ها شمسی با `formatDate()`.
- صفحهٔ زیرمجموعه بدون دکمهٔ بازگشت پذیرفته نیست: `PageHeader backTo=…` یا `<BackButton fallback=…/>`.
- وضعیت لیست‌ها (جستجو، فیلتر، صفحه) در query string با `hooks/useUrlState.ts`، نه در `useState`.

**۲. هر چیزِ اضافه حذف می‌شود.** فقط منبع، سرویس، گزینهٔ سرویس و دسته‌بندی می‌ماند. وظیفهٔ ۸ فهرست
حذف را دارد؛ ولی قاعده کلی‌تر است: در همین کار هم صفحه، فیلد، endpoint یا گزینه‌ای که سند
نخواسته اضافه نکن.

**۳. ساختار ساده.** بدون abstraction برای آینده:

- جدول جدید فقط وقتی هیچ جدول موجودی — حتی با یک ستون تازه — کافی نباشد؛ دلیلش نوشته شود.
  (به همین دلیل «گزینهٔ سرویس» جدول جدید نمی‌گیرد — پایین‌تر.)
- Controller نازک، منطق در Service، کوئری در Repository، وابستگی با constructor injection.
- interface و کلاس پایه فقط وقتی **الان** بیش از یک پیاده‌سازی دارد.
- نام‌گذاری و سبک کد دقیقاً مثل فایل‌های همسایه.

## زمینه

تسک‌های `docs/new_feture/taskes/` لایهٔ منبع را ساخته‌اند — `ResourceType`، `ClinicResource`،
تقویم منبع، استثنا، استخر، مهارت، موتور دسترس‌پذیری و اشغال واقعی. ولی **واحد رزرو هنوز پزشک است**:
نوبت به `Doctor` گره خورده، و «کدام منبع این سرویس را می‌دهد» و «مدت/قیمت این سرویس برای این منبع»
هیچ‌جا داده نیست. سند مالک محصول می‌گوید مدل باید بر پایهٔ منبع باشد، و هر چیزی خارج از
منبع/سرویس/گزینه از محصول حذف شود.

## مشکل / هدف

سه شکاف واقعی نسبت به سند:

1. **رابطهٔ منبع↔سرویس وجود ندارد.** انتخاب منبع فقط با `type + skill` انجام می‌شود، پس
   «دستگاه لیزر ۱ این سرویس را می‌دهد ولی دستگاه ۲ نه» قابل بیان نیست.
2. **مدت و قیمت بُعد منبع ندارند.** override موجود per **شعبه** است؛ «دکتر احمدی ۳۰ دقیقه /
   دکتر رضایی ۴۵ دقیقه برای همان گزینه» نمایش‌دادنی نیست.
3. **نوبت منبع را نگه نمی‌دارد.** `Appointment.doctor` غیرتهی است و رزرو بدون پزشک ممکن نیست،
   پس نوبتِ «دستگاه لیزر ۲» در مدل جا ندارد.

هدف: منبع واحد رزرو شود، مدت و قیمت با زنجیرهٔ **منبع+گزینه → منبع+سرویس → پیش‌فرض** حل شود،
و نوبت منبع و snapshot را نگه دارد.

> **گزینهٔ سرویس در این کدبیس جدول جدید نمی‌خواهد.** «گزینه» همان `ServiceItem` عضو یک
> `ItemGroup` است (`select_min`/`select_max` از قبل هست) و «سرویس» همان آیتم والد. ساختن
> جدول سوم `service_options` یعنی دو منبع حقیقت برای یک چیز، و همهٔ مسیرهای امروز
> (`appointment-booking-services`، `appointment-service-slots`، `PriceListItem`، `Tariff`)
> باید دوباره نوشته شوند. دلیل انتخاب در «نکات مهم» ثبت شده است.

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

- ✅ **موفق:** برای سرویس «لیزر» و گزینهٔ «پا» دو ردیف منبع ثبت شود (دستگاه ۱: ۲۰ دقیقه/۸ میلیون ریال،
  دستگاه ۲: ۱۵ دقیقه/۹٫۵ میلیون ریال). `GET /api/v1/resource/{uuid}/services` هر دو را برگرداند و
  `POST /api/v1/appointment` با `resource_uuid` دستگاه ۲ → `201` با
  `service_total_minutes: 15` و `price_snapshot.final_rials: 9500000`.
- ❌ **خطا:** رزرو با `resource_uuid` منبعی که آن سرویس را ندارد → `422` با
  `ERR_VALIDATION_001` و پیام «این منبع این سرویس را ارائه نمی‌دهد»؛ بدون توکن → `401` با envelope خطا.
- ✅ **دسته‌بندی:** دستهٔ «تمام بدن» شامل «دست» و «پا» تعریف شود؛ انتخاب هم‌زمان «لیزر تمام بدن» و
  «لیزر دست» → `422` با پیام فارسی، و یک دستگاه بتواند هم‌زمان به چند دسته وصل باشد.
- ⚠️ **مرزی:** منبعی که برای گزینه مقدار ندارد ولی برای سرویس دارد → مقدار سطح سرویس استفاده شود؛
  منبعی که هیچ‌کدام را ندارد → پیش‌فرض خودِ آیتم؛ و نوبت‌های **قبلاً ثبت‌شده** (بدون `resource_id`)
  باید همچنان در لیست‌ها و پنل بدون خطا نمایش داده شوند.

## فایل‌های مرتبط

| فایل | نقش |
|---|---|
| `src/Resource/Entity/ClinicResource.php` | منبع؛ امروز به `Doctor`/`ClinicStaff`/`Room` لینک می‌شود |
| `src/Resource/Repository/ClinicResourceRepository.php` | `findEligible()` — انتخاب کاندید با type+skill |
| `src/ClinicService/Entity/ServiceItem.php` | سرویس و گزینه (هر دو item) با `solo/additional` و قیمت |
| `src/ClinicService/Entity/ItemGroup.php` · `ItemGroupMember.php` | گروه گزینه‌ها با بازهٔ انتخاب |
| `src/ClinicService/Entity/CatalogCategory.php` | دستهٔ سراسری محیط؛ `parent` تک‌والدی و `MAX_DEPTH = 4` |
| `src/ClinicService/Service/ServiceSelectionValidator.php` | اعتبارسنجی ترکیب انتخاب‌ها |
| `src/ClinicService/Entity/ServiceBranchOverride.php` | override per شعبه (باید در زنجیره بماند) |
| `src/ClinicService/Service/DurationCalculator.php` | جمع solo/additional |
| `src/Pricing/Service/PricingEngine.php` | `quote()` — بدون بُعد منبع |
| `src/Appointment/Entity/Appointment.php` | `private Doctor $doctor` غیرتهی |
| `src/Appointment/Controller/AppointmentController.php` | `book()` — `doctor_uuid` الزامی |
| `src/Appointment/Plan/Service/AppointmentPlanBuilder.php` | ساخت برنامه و نیازمندی‌ها |
| `src/Appointment/Availability/Service/AvailabilityEngine.php` | جستجوی وقت آزاد روی منابع |
| `assets/admin/pages/ResourcesPage.tsx` | صفحهٔ منابع پنل |
| `docs/api/appointment.md` · `docs/api/clinic.md` | سند endpointها |

## وضعیت فعلی

انتخاب کاندید هیچ ربطی به سرویس ندارد — `src/Resource/Repository/ClinicResourceRepository.php:108`:

```php
public function findEligible(DoctorAddress $address, ResourceType $type, array $skillIds = []): array
{
    $qb = $this->createQueryBuilder('r')
        ->where('r.address = :address')
        ->andWhere('r.type = :type')
        ->andWhere('r.active = true')
        // … فیلتر مهارت
```

override فقط per شعبه است — `src/ClinicService/Entity/ServiceBranchOverride.php:41`:

```php
#[ORM\Column(name: 'price_rials', type: 'bigint', nullable: true)]
private ?int $priceRials = null;

#[ORM\Column(name: 'solo_duration_minutes', type: 'smallint', nullable: true)]
private ?int $soloDurationMinutes = null;
```

نوبت بدون پزشک ساخته نمی‌شود — `src/Appointment/Entity/Appointment.php:101` و `:242`:

```php
private Doctor $doctor;

public function __construct(Doctor $doctor, User $user, int $slotStart, int $slotEnd)
```

قیمت‌گذاری بُعد منبع ندارد — `src/Pricing/Service/PricingEngine.php:59`:

```php
public function quote(
    ServiceItem $service,
    array $items,
    DoctorAddress $address,
    int $at,
    array $policy = [],
    ?PatientRecord $patient = null,
): PriceQuote {
```

## وظایف

### ۱. جدول رابطهٔ منبع↔سرویس

`src/Resource/Entity/ResourceServiceOffering.php` — رابطهٔ چند‌به‌چند با تنظیمات اختصاصی.
همان الگوی `ResourceSkill`/`ResourcePoolMember`: entity رابطه‌ای با جفت یکتا.

```php
#[ORM\Entity(repositoryClass: ResourceServiceOfferingRepository::class)]
#[ORM\Table(name: 'resource_service_offerings')]
#[ORM\UniqueConstraint(name: 'uniq_resource_service', columns: ['resource_id', 'service_item_id'])]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'service_item_id'], name: 'idx_offering_tenant_service')]
class ResourceServiceOffering
{
    use TenantOwnedTrait;   // جفت از خود منبع مشتق می‌شود، نه از بدنهٔ درخواست

    public function __construct(ClinicResource $resource, ServiceItem $serviceItem) { … }

    private ?int $durationMinutes = null;   // null = ارث از سطح بالاتر
    private ?int $priceRials      = null;   // null = ارث از سطح بالاتر
    private bool $active          = true;
}
```

نکته: چون «گزینه» هم `ServiceItem` است، همین یک جدول هر دو سطحِ سند را پوشش می‌دهد —
ردیف با آیتمِ والد = «منبع + سرویس»، ردیف با آیتمِ عضو گروه = «منبع + گزینه».

**نحوه تست:** migration ساخته و اجرا شود؛ سپس
`ddev exec php bin/phpunit tests/Resource/ResourceServiceOfferingTest.php` با سه تست:
ثبت ردیف، جفت تکراری → خطای یکتایی، و اینکه جفت محیط از منبع گرفته می‌شود نه از ورودی.

### ۲. Resolver مدت و قیمت

`src/ClinicService/Service/ResourceServiceResolver.php` — الگوی **Chain of Responsibility**،
چون سند صریحاً ترتیب اولویت تعریف کرده و افزودن سطح بعدی (مثلاً قرارداد بیمه) نباید
`if` تازه در دل موتور بگذارد.

ترتیب (از خاص به عام) — سطح شعبه عمداً در زنجیره می‌ماند چون داده‌اش امروز وجود دارد:

```
۱. منبع + گزینه            → ResourceServiceOffering(resource, optionItem)
۲. منبع + سرویس            → ResourceServiceOffering(resource, parentItem)
۳. شعبه + آیتم             → ServiceBranchOverride(item, address)
۴. پیش‌فرض خودِ آیتم        → ServiceItem::getSoloDurationMinutes() / getPriceRials()
```

```php
public function resolve(ClinicResource $resource, ServiceItem $item, DoctorAddress $address): ResolvedServiceSpec
{
    // اولین سطحی که مقدارِ غیرnull دارد برنده است — مدت و قیمت **جدا** حل می‌شوند:
    // منبعی که فقط مدت را override کرده نباید قیمتش هم از همان سطح بیاید.
}
```

`ResolvedServiceSpec` باید بگوید هر مقدار از کدام سطح آمده (`durationSource`, `priceSource`) —
بدون این، دیباگِ «چرا این عدد؟» در پنل غیرممکن است.

**نحوه تست:** `tests/ClinicService/ResourceServiceResolverTest.php` — چهار تست، هر سطح یکی، به‌علاوهٔ
تستِ مرزی «مدت از سطح ۱ و قیمت از سطح ۳».

### ۳. فیلتر کاندیدها بر اساس سرویس

`ClinicResourceRepository::findEligible()` یک آرگومان اختیاری `?ServiceItem $service` بگیرد و
وقتی داده شد، فقط منابعی برگردد که ردیف فعال در `resource_service_offerings` دارند.

**قاعدهٔ سازگاری عقب‌رو:** اگر برای آن سرویس **هیچ** ردیفی ثبت نشده باشد، فیلتر اعمال نشود
(همان رفتار امروز). وگرنه هر محیطی که هنوز رابطه‌ها را پر نکرده، یک‌شبه بدون وقت آزاد می‌شود.

`AppointmentPlanBuilder::planRequirements()` سرویس را به `eligibleFor()` پاس بدهد.

**نحوه تست:** دو منبع از یک نوع بساز، فقط یکی را به سرویس وصل کن، `POST /api/v1/appointment-availability`
بزن و مطمئن شو `assignment` همیشه همان یک منبع است. سپس ردیف را غیرفعال کن → `slots` خالی با `reason`.

### ۴. منبع و snapshot روی نوبت

- `Appointment::$resource` (`ManyToOne`, **nullable**) + `Appointment::$serviceOptionItem` (nullable).
  nullable بودن اجباری است: ۷۲ نوبت موجود منبع ندارند و migration نباید آن‌ها را بشکند.
- `service_total_minutes` و `PriceSnapshot` از قبل هستند — فقط باید از خروجی resolver پر شوند،
  نه از `ServiceItem` مستقیم.
- `Appointment::toArray()` باید `resource` (`uuid`, `name`, `type`) و `service_option` را برگرداند.

**نحوه تست:** `tests/Appointment/ResourceBookingTest.php` — رزرو با منبع، سپس تغییر قیمت سرویس و
اطمینان از اینکه `price_snapshot` نوبت قبلی تکان نمی‌خورد (همان قاعدهٔ snapshot سند).

### ۵. رزرو با منبع در endpointها

- `POST /api/v1/appointment`: `resource_uuid` پذیرفته شود. اگر آمد، `doctor_uuid` اختیاری است و
  پزشک از `resource->subject()` استنتاج می‌شود (اگر منبع پزشک باشد). اگر منبع دستگاه باشد و
  نوبت پزشک ندارد، `Appointment.doctor` باید nullable شود — این تغییر schema است و migration جدا می‌خواهد.
- `GET /api/v1/appointment-booking-services/{doctorUuid}` مکمل بگیرد:
  `GET /api/v1/resource/{uuid}/services` → سرویس‌های آن منبع با مدت و قیمتِ **حل‌شده**.
- `POST /api/v1/appointment` بدون هیچ‌کدام → `422`.

**نحوه تست:** با `curl` و توکن بیمار روی دادهٔ `app:seed-scenarios`: یک‌بار با `resource_uuid` دستگاه،
یک‌بار با `doctor_uuid` (مسیر قدیمی باید سالم بماند)، یک‌بار با منبعِ بی‌ارتباط → `422`.

### ۶. پنل ادمین: تب «سرویس‌های این منبع»

در `assets/admin/pages/ResourcesPage.tsx` (یا صفحهٔ جزئیات منبع) جدولی با ستون‌های
سرویس/گزینه · مدت · قیمت · فعال، با ویرایش inline. خالی‌گذاشتن مدت یا قیمت یعنی «ارث از سطح بالاتر» و
باید مقدار مؤثر را به‌صورت placeholder با برچسب منبعش نشان دهد (`از شعبه`، `پیش‌فرض سرویس`).

از `SearchableSelect` استفاده شود، نه `<select>` خام؛ فرم با React Hook Form + Zod؛ داده با TanStack Query.

**صفحهٔ تازه ساخته نشود** — این یک تب روی صفحهٔ منبع موجود است. جدول با `DataTable` و ستون وضعیت با
`StatusBadge`؛ حذف رابطه با `ConfirmDialog`. طبق قاعدهٔ ۱، هیچ رنگ خام و هیچ کامپوننت موازی.

**نحوه تست:** `npx vitest run assets/admin/pages/ResourceServicesTab.test.tsx` — سه تست:
نمایش مقدار مؤثر، ذخیرهٔ override، پاک‌کردن override → بازگشت به ارث.
سپس **بازبینی چشمی** در مرورگر: دارک‌مود، حالت فشرده و موبایل ۳۹۰px — هر سه بدون شکستگی و
بدون اسکرول افقی. بدون این سه اسکرین‌شات، وظیفه تمام‌شده نیست.

### ۷. دسته‌بندی سراسری کلینیک، مشترک بین سرویس و منبع، با «شامل بودن»

سند مالک محصول: دسته‌بندی باید در سطح کلینیک تعریف شود و **هم سرویس‌ها هم منابع** از همان
استفاده کنند؛ و یک دسته می‌تواند شامل دسته‌های دیگر باشد («تمام بدن» شامل دست و پا و …).

**وضعیت امروز:** `CatalogCategory` از قبل سراسریِ محیط است (جفت `(entity_type, entity_id)` +
`parent` + `MAX_DEPTH = 4`) و روی `ServiceItem::$catalogCategory` می‌نشیند. دو چیز کم است:

الف) `ClinicResource` هیچ فیلد دسته‌ای ندارد، پس «این دستگاه برای دست و پا است» گفتنی نیست.

ب) **`parent` برای «شامل بودن» کافی نیست.** درخت تک‌والدی است: «دست» نمی‌تواند هم‌زمان زیر
«تمام بدن» و زیر «اندام فوقانی» باشد، در حالی که در لیزر مجموعه‌ها روی هم می‌افتند. پس
containment یک **گراف جهت‌دار بدون دور** است، جدا از سلسله‌مراتب نمایشی.

سه تغییر:

```php
// ۱) عضویت چندگانهٔ منبع در دسته‌ها — m2m، چون یک دستگاه چند ناحیه را پوشش می‌دهد
#[ORM\Table(name: 'resource_catalog_categories')]
#[ORM\UniqueConstraint(name: 'uniq_resource_category', columns: ['resource_id', 'category_id'])]

// ۲) یال «شامل بودن» بین دسته‌ها — DAG، نه درخت
#[ORM\Table(name: 'catalog_category_includes')]
#[ORM\UniqueConstraint(name: 'uniq_category_include', columns: ['parent_category_id', 'child_category_id'])]
class CatalogCategoryInclude
{
    // «تمام بدن» → «دست» ، «تمام بدن» → «پا» …
    // parent === child ممنوع، و بستارِ گذرا نباید به خودش برگردد.
}
```

```php
// ۳) بستار گذرا: «تمام بدن» شامل «نیم‌تنهٔ پایین» و آن شامل «پا» ⇒ تمام بدن شامل پا
final class CategoryClosureResolver
{
    /** @return int[] شناسهٔ همهٔ دسته‌های زیرمجموعه، با پیمایش عمقی و محافظ دور */
    public function descendants(CatalogCategory $category): array { … }

    public function overlaps(CatalogCategory $a, CatalogCategory $b): bool { … }
}
```

مصرفش در دو نقطه:

- **تعارض انتخاب:** `ServiceSelectionValidator` وقتی دو آیتم انتخاب‌شده دسته‌هایی دارند که یکی
  دیگری را شامل می‌شود → `422` با پیام «تمام بدن شامل دست است؛ هر دو با هم انتخاب نمی‌شوند».
  این جای رابطهٔ دستیِ `incompatible_with` را برای این حالت می‌گیرد — یک بار در دسته تعریف
  می‌شود، نه به‌ازای هر جفت آیتم.
- **فیلتر منبع:** در `findEligible()` (وظیفهٔ ۳) اگر سرویس دسته دارد، منابعی که آن دسته یا یکی از
  اجدادش را پوشش می‌دهند مقدم‌اند.

**حلقه ممنوع:** پیش از ذخیرهٔ یال، `descendants($child)` بررسی شود و اگر `$parent` در آن بود
`422` برگردد. بدون این، `descendants()` تا سرریز استک می‌رود.

**نحوه تست:** `tests/ClinicService/CategoryClosureTest.php` —
✅ «تمام بدن» → دست/پا ثبت شود و `descendants` هر دو را بدهد ·
✅ زنجیرهٔ سه‌سطحی بستار گذرا را درست بدهد ·
❌ یال دوری («دست شامل تمام بدن») → `422` ·
⚠️ دسته‌ای که هیچ یالی ندارد → آرایهٔ خالی، نه خطا.
سپس با `curl`: انتخاب هم‌زمان «لیزر تمام بدن» و «لیزر دست» در `POST /api/v1/appointment` → `422`.

### ۸. حذف زیرسیستم‌های خارج از این مدل

**تصمیم مالک محصول (۱۴۰۵/۰۵/۱۰):** هر چیزی خارج از منبع/سرویس/گزینه از محصول حذف شود.
ریسکش گفته شد (جریمهٔ لغو و پکیج معمولاً نیاز واقعی کلینیک‌اند) و مالک محصول تصمیم را تکرار کرد.

حذف کامل این پنج زیرسیستم — کد، جدول، endpoint، تست، صفحهٔ پنل، سند:

| دامنه | مسیر | حجم |
|---|---|---|
| موتور سیاست | `src/Policy/` | ۲۷ فایل · ۶ فایل تست |
| پکیج و دفتر اعتبار | `src/Package/` | ۱۳ فایل · ۲ تست |
| دورهٔ درمان | `src/Course/` | ۱۴ فایل · ۲ تست |
| لغو/جریمه/لیست انتظار | `src/Cancellation/` + `src/Waitlist/` | ۱۹ فایل · ۲ تست |
| رویداد و گزارش | `src/Report/` + `src/Shared/Event/` | ۳ فایل · ۳ تست |

صفحات پنل: `PolicyFormPage`, `PolicySimulationPage`, `PackagesPage`, `PatientPackageLedgerPage`,
`CourseProtocolsPage`, `CancellationPolicyPage`, `ResourceUtilizationPage` (+ تست‌هایشان) و مسیرهایشان در `App.tsx`.

**قلاب‌هایی که باید از کد باقی‌مانده کنده شوند** (اینجا کامپایل می‌شکند، پس ترتیب مهم است):

```
src/Appointment/Plan/Service/AppointmentPlanBuilder.php   → applyTimingPolicies() و applyResourcePolicies()
src/Pricing/Service/PricingEngine.php                     → PricingPolicyEngine و PackageConsumptionService
src/Appointment/Booking/Controller/BookingController.php  → BookingPolicyGuard
src/ClinicService/Service/ServiceSelectionValidator.php   → SelectionPolicyEngine
src/Appointment/Booking/Service/BookingService.php        → PackageConsumptionService، CreditLedgerService، CourseSessionLinker
src/Shared/Tenant/GlobalTables.php                        → ردیف‌های همین دامنه‌ها
src/Shared/Command/BookingEngineSeeder.php                → متدهای policies/packagesAndCourses/cancellationAndWaitlist
```

migration جدا برای `DROP TABLE` جدول‌های این دامنه‌ها با `down()` واقعی.

**نحوه تست:** بعد از حذف: `ddev exec php bin/phpunit` کامل سبز ·
`ddev exec php vendor/bin/phpstan analyse` روی همان baseline ۱۴ خطا ·
`ddev exec npx tsc --noEmit` بدون خطا · `ddev exec php bin/console debug:router | grep -cE "policy|package|course|waitlist|cancellation"` → `0` ·
`ddev exec php bin/console app:seed-scenarios --reset -n` بدون خطا.

### ۹. مستندات

- `docs/api/appointment.md`: فیلد `resource_uuid` در رزرو + پاسخ `resource`/`service_option`، و حذف
  بخش‌های سیاست/پکیج/دوره/لغو.
- `docs/api/clinic.md`: endpoint جدید `resource/{uuid}/services`.
- `docs/architecture/`: سند مدل منبع‌محور با همان چهار سطح زنجیره.
- `docs/new_feture/taskes/`: چک‌لیست تسک‌های ۹ تا ۱۴ با وضعیت «حذف‌شده به تصمیم مالک محصول» و تاریخ.
- `TEST_USERS.md`: جدول «موتور نوبت‌دهی» باید سطرهای حذف‌شده را از دست بدهد.

## نکات مهم

- **خط قرمز:** منطق نوبت‌دهی اسلاتی نباید تغییر کند. `ddev exec php bin/phpunit --group=slot-mode-frozen`
  باید در هر مرحله سبز بماند.
- **چرا جدول `service_options` جدید نمی‌سازیم:** گزینه از قبل `ServiceItem` است و `ItemGroup`
  قواعد «حداقل یکی، حداکثر سه‌تا» را دارد. جدول سوم یعنی `PriceListItem`، `Tariff`،
  `appointment_service_items`، `SegmentTemplate` و کل مسیر `appointment-service-slots` باید دو نوع
  ورودی بشناسند — دو منبع حقیقت برای یک مفهوم.
- **مدت و قیمت جدا حل می‌شوند.** منبعی که فقط مدت را override کرده نباید قیمتش هم از همان سطح بیاید.
- **جفت محیط (`entity_type`,`entity_id`) از خود منبع مشتق شود**، نه از بدنهٔ درخواست — همان قاعده‌ای که
  `ClinicResource` و `ServiceItem` رعایت می‌کنند. `TenantSchemaCoverageTest` entity طبقه‌بندی‌نشده را قرمز می‌کند.
- **سازگاری داده:** `resource_id` و `service_option_item_id` روی نوبت nullable؛ فیلتر سرویس در
  `findEligible` فقط وقتی رابطه‌ای ثبت شده باشد.
- **cross-repo:** بعد از تغییر قرارداد، مصرف واقعی در `nobat724_front/services/response.js` و
  `nobat724_front/components/appointment/` دستی بررسی شود؛ سایت امروز فقط `doctor_uuid` می‌فرستد و
  با اختیاری‌شدنش نمی‌شکند، ولی برای رزرو دستگاه باید به‌روز شود.
- **ترتیب اجرا:** اول وظیفهٔ ۷ (حذف) یا اول ۱ تا ۶؟ حذف اول انجام شود — وگرنه resolver و
  `PlanBuilder` را دوبار می‌نویسی: یک‌بار با قلاب سیاست، یک‌بار بدون آن.
