docs: prompt and checklist for the resource-first booking model

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-01 20:13:55 +03:30
co-authored by Claude Opus 5
parent fc6b865c15
commit 4711ba0af7
2 changed files with 540 additions and 0 deletions
@@ -0,0 +1,385 @@
# نوبت‌دهی بر پایهٔ منبع: رابطهٔ منبع↔سرویس، گزینهٔ سرویس، و حل مدت/قیمت
## پروژه
`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` را دوبار می‌نویسی: یک‌بار با قلاب سیاست، یک‌بار بدون آن.