# چک‌لیست تسک ۱۵ — نوبت‌دهی بر پایهٔ منبع پرامپت: [`.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/` — `` خام | | ۰.۴ | دارک‌مود · حالت فشرده · موبایل ۳۹۰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` + کامیت گراف | ⏳ | |