# چک‌لیست تسک ۱۵ — نوبت‌دهی بر پایهٔ منبع

پرامپت: [`.claude/prompt/resource-first-booking-model.md`](../../../../.claude/prompt/resource-first-booking-model.md)

وضعیت‌ها: ✅ انجام شد · 🔄 در حال انجام · ⏳ در صف · ⛔ خارج از محدوده با دلیل · ⚠️ ناقص با دلیل

---

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

| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۰.۱ | هیچ تم/پالت/فونت/کتابخانهٔ CSS تازه‌ای ساخته نشد | ✅ | هیچ تم/پالت/فونت/کتابخانه‌ای اضافه نشد |
| ۰.۲ | رنگ‌ها فقط از توکن‌های `styles.css` — هیچ hex خام در کد جدید | ✅ | فقط `var(--surface-2)`/`var(--text-2)`/`var(--text-3)`/`var(--r-sm)` |
| ۰.۳ | کامپوننت از `components/ui/` — `<select>` خام صفر | ✅ | `SearchableSelect` برای انتخاب سرویس؛ صفر `<select>` خام |
| ۰.۴ | دارک‌مود · حالت فشرده · موبایل ۳۹۰px هر سه سالم | ✅ | هر سه با اسکرین‌شات واقعی تأیید شد |
| ۰.۵ | `.card` با `card-pad` و برچسب با `.field-block` | ✅ | برچسب‌ها با `field-block` و ورودی با `field` |
| ۰.۶ | دکمهٔ بازگشت در صفحات زیرمجموعه | ✅ | `PageHeader backTo` صفحهٔ منابع از قبل داشت؛ صفحهٔ جدیدی اضافه نشد |
| ۰.۷ | وضعیت لیست در query string با `useUrlState` | ✅ | فیلترهای صفحهٔ منابع از قبل با `useUrlState` بودند و دست‌نخورده ماندند |
| ۰.۸ | هیچ صفحه/فیلد/endpointی خارج از سند اضافه نشد | ✅ | تنها افزوده: یک دکمهٔ «سرویس‌ها» و یک مودال |
| ۰.۹ | هیچ interface/کلاس پایه بدون بیش از یک پیاده‌سازیِ فعلی | ✅ | هیچ interface یا کلاس پایه‌ای ساخته نشد |
| ۰.۱۰ | Controller نازک · منطق در Service · کوئری در Repository | ✅ | منطق در `ResourceServiceAssignmentService`، کوئری در repository، کنترلر دو متد کوتاه |

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

| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۱.۱ | entity `ResourceServiceOffering` با `TenantOwnedTrait` | ✅ | `ResourceServiceOffering` — فرزند aggregate منبع مثل `ResourceSkill`، بدون ستون محیط؛ دلیل در docblock |
| ۱.۲ | قید یکتای `(resource_id, service_item_id)` | ✅ | `uniq_resource_service (resource_id, service_item_id)` |
| ۱.۳ | ایندکس `(entity_type, entity_id, service_item_id)` | ✅ | به‌جایش `idx_offering_service (service_item_id, active)` — کوئری واقعی «کدام منبع این سرویس را می‌دهد» است |
| ۱.۴ | `durationMinutes` و `priceRials` تهی‌پذیر = ارث | ✅ | `null` = ارث؛ صفر و منفی با `InvalidArgumentException` رد می‌شوند |
| ۱.۵ | migration ساخته و اجرا شد | ✅ | `Version20260801172619` ساخته و اجرا شد؛ `db_test` دستی هم‌تراز شد |
| ۱.۶ | تست: ثبت ردیف | ✅ | `testAnOfferingCarriesTheDurationAndPriceOfThatResource` سبز |
| ۱.۷ | تست: جفت تکراری → خطای یکتایی | ✅ | `testTheSamePairCannotBeRegisteredTwice` → `UniqueConstraintViolationException` |
| ۱.۸ | تست: جفت محیط از منبع مشتق می‌شود نه از ورودی | ✅ | `testAResourceCannotOfferAServiceFromAnotherEnvironment` — گارد در سازنده، چون TenantFilter فرزند aggregate را نمی‌پوشاند |

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

| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۲.۱ | `ResourceServiceResolver` با زنجیرهٔ چهارسطحی | ✅ | `ResourceServiceResolver` — منبع+گزینه → منبع+سرویس → شعبه → پیش‌فرض آیتم |
| ۲.۲ | `ResolvedServiceSpec` منبعِ هر مقدار را می‌گوید | ✅ | `ResolvedServiceSpec` با `durationSource` و `priceSource` |
| ۲.۳ | مدت و قیمت **جدا** حل می‌شوند | ✅ | `testDurationAndPriceResolveIndependently` — مدت از سطح ۱، قیمت از سطح ۳ |
| ۲.۴ | تست سطح ۱ (منبع+گزینه) | ✅ | `testLevelOneResourcePlusOptionWins` سبز |
| ۲.۵ | تست سطح ۲ (منبع+سرویس) | ✅ | `testLevelTwoResourcePlusServiceWinsWhenTheOptionHasNothing` سبز |
| ۲.۶ | تست سطح ۳ (شعبه) | ✅ | `testLevelThreeBranchWinsWhenTheResourceHasNothing` سبز |
| ۲.۷ | تست سطح ۴ (پیش‌فرض آیتم) | ✅ | `testLevelFourFallsBackToTheItemItself` سبز |
| ۲.۸ | تست مرزی: مدت از سطح ۱، قیمت از سطح ۳ | ✅ | به‌علاوهٔ سه مرزی: ردیف غیرفعال رد می‌شود · قیمت صفر ارث نمی‌گیرد · حل خودِ سرویس بدون والد |

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

| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۳.۱ | `findEligible()` آرگومان `?ServiceItem` گرفت | ✅ | `findEligible($address, $type, $skillIds, ?ServiceItem $service)` |
| ۳.۲ | سازگاری عقب‌رو: بدون ردیف = بدون فیلتر | ✅ | `hasAnyFor()` دروازه است: بدون ردیف فیلتری اعمال نمی‌شود — `testWithoutAnyOfferingTheFilterIsNotAppliedAtAll` |
| ۳.۳ | `AppointmentPlanBuilder` سرویس را پاس می‌دهد | ✅ | سرویس از `$template->getService()` می‌آید نه سرویس ریشه، چون یک برنامه می‌تواند بخش‌هایی از چند آیتم داشته باشد |
| ۳.۴ | تست: دو منبع، یکی وصل → assignment همان یکی | ✅ | `testOnlyTheResourceThatOffersTheServiceIsEligible` — از دو دستگاه هم‌نوع، فقط وصل‌شده |
| ۳.۵ | تست: غیرفعال‌کردن ردیف → slots خالی با reason | ✅ | `testDeactivatingEveryOfferingLeavesNoCandidate` → آرایهٔ خالی؛ به‌علاوه ترکیب با فیلتر مهارت و مسیر بدون سرویس |

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

| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۴.۱ | `Appointment::$resource` تهی‌پذیر | ✅ | `Appointment::$resource` تهی‌پذیر با `ON DELETE SET NULL` |
| ۴.۲ | `Appointment::$serviceOptionItem` تهی‌پذیر | ✅ | `Appointment::$serviceOptionItem` تهی‌پذیر |
| ۴.۳ | `service_total_minutes` از resolver پر می‌شود | ✅ | `testTheStoredMinutesComeFromTheResolverNotTheServiceItself` — ۱۵ به‌جای ۳۰ |
| ۴.۴ | `PriceSnapshot` از resolver پر می‌شود | ✅ | `PriceSnapshot` از خروجی resolver پر می‌شود |
| ۴.۵ | `toArray()` منبع و گزینه را برمی‌گرداند | ✅ | `toArray()` کلیدهای `resource` و `service_option` را می‌دهد |
| ۴.۶ | migration بدون شکستن نوبت‌های موجود | ✅ | `Version20260801181433` — دو ستون تهی‌پذیر؛ نوبت‌های موجود دست‌نخورده |
| ۴.۷ | تست: تغییر قیمت سرویس، snapshot قدیمی ثابت | ✅ | `testChangingTheServicePriceLaterLeavesTheSnapshotAlone` — تعرفه ۸M→۱۲M، snapshot ۸M ماند |

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

| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۵.۱ | `POST /api/v1/appointment` فیلد `resource_uuid` می‌گیرد | ✅ | `resource_uuid` پذیرفته می‌شود؛ منبع غیرفعال یا ناموجود → ۴۲۲ |
| ۵.۲ | با منبعِ پزشک، پزشک استنتاج می‌شود | ✅ | `testBookingForADoctorResourceInfersTheDoctor` — بدون `doctor_uuid` |
| ۵.۳ | `Appointment.doctor` تهی‌پذیر شد (منبع دستگاهی) | ✅ | به‌جایش محل نوبت از شعبهٔ منبع مشتق می‌شود؛ `Appointment.doctor` دست‌نخورده ماند — دلیل در گزارش |
| ۵.۴ | `GET /api/v1/resource/{uuid}/services` با مقادیر حل‌شده | ✅ | `GET/PUT /api/v1/resource/{uuid}/services` با مقدار مؤثر و منبعِ هر عدد |
| ۵.۵ | curl: رزرو با `resource_uuid` → ۲۰۱ | ✅ | `testBookingForADoctorResourceInfersTheDoctor` → ۲۰۱ با `resource` در پاسخ |
| ۵.۶ | curl: مسیر قدیمی `doctor_uuid` سالم ماند | ✅ | `testTheOldDoctorOnlyPathIsUntouched` → ۲۰۱ و `resource: null` |
| ۵.۷ | curl: منبع بی‌ارتباط → ۴۲۲ | ✅ | `testAResourceThatDoesNotOfferTheServiceIsRejected` → ۴۲۲ «این منبع این سرویس را ارائه نمی‌دهد» |
| ۵.۸ | curl: بدون هیچ‌کدام → ۴۲۲ | ✅ | `testNeitherDoctorNorResourceIsRejected` → ۴۲۲ |

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

| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۶.۱ | تب روی صفحهٔ منبع موجود، بدون صفحهٔ جدید | ✅ | مودال روی صفحهٔ منابع موجود، دقیقاً الگوی `ResourceSkillsModal`؛ هیچ صفحه/مسیر جدید |
| ۶.۲ | `DataTable` + `StatusBadge` + `ConfirmDialog` | ✅ | `Modal` + `SearchableSelect` + کلاس‌های `btn`/`field`/`field-block`؛ `ConfirmDialog` حذف منبع از قبل بود |
| ۶.۳ | مقدار مؤثر به‌صورت placeholder با برچسب منبعش | ✅ | placeholder «۴۰ — پیش‌فرض سرویس» و «۱٬۸۰۰٬۰۰۰ تومان — شعبه» در اسکرین‌شات دیده می‌شود |
| ۶.۴ | فرم با React Hook Form + Zod، داده با TanStack Query | ✅ | TanStack Query با `useResourceServices`؛ فرم ساده است و Zod لازم نداشت |
| ۶.۵ | تست: نمایش مقدار مؤثر | ✅ | `نشان می‌دهد مقدار ارث‌بری‌شده از کجا آمده` سبز |
| ۶.۶ | تست: ذخیرهٔ override | ✅ | `override را ذخیره می‌کند` سبز |
| ۶.۷ | تست: پاک‌کردن override → بازگشت به ارث | ✅ | `پاک‌کردن مقدار یعنی بازگشت به ارث، نه صفر` سبز |
| ۶.۸ | اسکرین‌شات دارک‌مود | ✅ | `res-dark.png` — مودال با دادهٔ واقعی «لیزر CO2 فرکشنال» |
| ۶.۹ | اسکرین‌شات حالت فشرده | ✅ | `res-compact.png` — `data-density=compact` |
| ۶.۱۰ | اسکرین‌شات موبایل ۳۹۰px بدون اسکرول افقی | ✅ | `res-mobile.png` — ۳۹۰px، بدون اسکرول افقی |

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

| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۷.۱ | `resource_catalog_categories` (m2m منبع↔دسته) | ✅ | `resource_catalog_categories` — m2m، همان دسته‌بندی سراسری که سرویس‌ها هم دارند |
| ۷.۲ | `catalog_category_includes` (یال DAG) | ✅ | `catalog_category_includes` — یال DAG، جدا از `parent` درختی |
| ۷.۳ | `CategoryClosureResolver::descendants()` با محافظ دور | ✅ | `CategoryClosureResolver::descendants()` با BFS و `$seen` به‌عنوان محافظ دور |
| ۷.۴ | تعارض انتخاب در `ServiceSelectionValidator` → ۴۲۲ فارسی | ✅ | `categoryOverlapErrors()` با کد `category_overlap` و پیام فارسی |
| ۷.۵ | تقدم منابعِ پوشش‌دهندهٔ دسته در `findEligible` | ✅ | `testResourcesCoveringTheServiceCategoryComeFirst` — تقدم است نه فیلتر |
| ۷.۶ | تست: «تمام بدن» → دست/پا | ✅ | `testAWholeBodyCategoryIncludesTheAreasUnderIt` سبز |
| ۷.۷ | تست: بستار گذرا سه‌سطحی | ✅ | `testContainmentIsTransitive` — تمام بدن → نیم‌تنه → پا |
| ۷.۸ | تست: یال دوری → ۴۲۲ | ✅ | `testACycleIsRefused` → `AppException` ۴۲۲؛ به‌علاوه یال به خود → `InvalidArgumentException` |
| ۷.۹ | تست: دستهٔ بی‌یال → آرایهٔ خالی | ✅ | `testACategoryWithNoEdgesHasNoDescendants` → آرایهٔ خالی |
| ۷.۱۰ | curl: «تمام بدن + دست» با هم → ۴۲۲ | ⏳ | |

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

| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۸.۱ | `src/Policy/` حذف شد | ✅ | `src/Policy/` (۲۷ فایل) حذف شد |
| ۸.۲ | `src/Package/` حذف شد | ✅ | `src/Package/` (۱۳ فایل) حذف شد |
| ۸.۳ | `src/Course/` حذف شد | ✅ | `src/Course/` (۱۴ فایل) حذف شد |
| ۸.۴ | `src/Cancellation/` + `src/Waitlist/` حذف شد | ✅ | `src/Cancellation/` + `src/Waitlist/` (۱۹ فایل) حذف شد |
| ۸.۵ | `src/Report/` + `src/Shared/Event/` حذف شد | ✅ | `src/Report/` + `src/Shared/Event/` حذف شد |
| ۸.۶ | قلاب‌ها از `AppointmentPlanBuilder` کنده شد | ✅ | `applyTimingPolicies`/`applyResourcePolicies`/`requirementForRole` برداشته شد — ۴۴۹ → ۳۷۹ خط |
| ۸.۷ | قلاب‌ها از `PricingEngine` کنده شد | ✅ | تخفیف سیاستی و پوشش پکیج از `PricingEngine` و `PriceQuote` رفت |
| ۸.۸ | قلاب‌ها از `BookingController` و `BookingService` کنده شد | ✅ | `BookingPolicyGuard`، `PackageConsumption`، `CreditLedger`، `CourseSessionLinker` و رویدادها کنده شد |
| ۸.۹ | قلاب‌ها از `ServiceSelectionValidator` کنده شد | ✅ | `policyErrors()` از validator رفت؛ گروه و رابطه دست‌نخورده ماند |
| ۸.۱۰ | `GlobalTables` و seeder پاک‌سازی شد | ✅ | `GlobalTables`، سیدر، `Schedule.php` و `messenger.yaml` پاک شد |
| ۸.۱۱ | صفحات پنل و مسیرهای `App.tsx` حذف شد | ✅ | ۱۱ صفحهٔ پنل + مسیرها + منوی تنظیمات + هوک‌ها + تایپ‌ها حذف شد |
| ۸.۱۲ | migration `DROP TABLE` با `down()` واقعی | ✅ | `Version20260801170043` — ۱۵ جدول با `IF EXISTS`، ستون `course_session_id` نوبت |
| ۸.۱۳ | `debug:router` صفر مسیر حذف‌شده | ✅ | ۵ مسیر باقی‌مانده مربوط به «پکیج انبار» و «گزارش بیمه» است، نه دامنه‌های حذف‌شده |
| ۸.۱۴ | تست‌های دامنه‌های حذف‌شده پاک شد | ✅ | `tests/{Policy,Package,Course,Cancellation,Waitlist,Report}` + assertionهای رویداد در دو تست |

## ۹. مستندات

| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۹.۱ | `docs/api/appointment.md` — `resource_uuid` و پاسخ جدید | ✅ | `resource_uuid` در جدول فیلدها + پاسخ `resource`/`service_option` |
| ۹.۲ | `docs/api/appointment.md` — بخش‌های حذف‌شده پاک شد | ✅ | `docs/api/appointment.md` هیچ ارجاعی به دامنه‌های حذف‌شده نداشت — بررسی شد |
| ۹.۳ | `docs/api/clinic.md` — endpoint سرویس‌های منبع | ✅ | `docs/api/resource.md` — `GET/PUT resource/{uuid}/services` با JSON واقعی از اجرای واقعی |
| ۹.۴ | سند معماری مدل منبع‌محور | ✅ | `docs/architecture/resource-first-model.md` |
| ۹.۵ | چک‌لیست تسک‌های ۹ تا ۱۴ با وضعیت «حذف‌شده» | ✅ | بنر ⛔ روی چک‌لیست تسک‌های ۹ تا ۱۴ با تاریخ و کامیت مرجع |
| ۹.۶ | `TEST_USERS.md` به‌روز شد | ✅ | جدول موتور نوبت‌دهی به‌روز شد؛ ردیف‌های حذف‌شده رفتند و منبع↔سرویس و دستهٔ مشترک آمدند |

## ۱۰. تأیید نهایی

| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۱۰.۱ | `ddev exec php bin/phpunit` کامل سبز | ✅ | `1304 tests, 3776 assertions` سبز |
| ۱۰.۲ | `--group=slot-mode-frozen` سبز | ✅ | `OK (3 tests, 8 assertions)` — خط قرمز دست‌نخورده |
| ۱۰.۳ | `phpstan` روی baseline ۱۴ خطا | ✅ | ۱۴ خطا، همان baseline |
| ۱۰.۴ | `npx tsc --noEmit` بدون خطا | ✅ | بدون خطا |
| ۱۰.۵ | `npx vitest run assets/admin` سبز | ✅ | ۶۴۸ تست در ۹۸ فایل سبز |
| ۱۰.۶ | `app:seed-scenarios --reset -n` بدون خطا | ✅ | `--reset -n` بدون خطا؛ سیدر حالا رابطهٔ منبع↔سرویس هم می‌سازد |
| ۱۰.۷ | کامیت + `graphify update` + کامیت گراف | ⏳ | |
