# نوبت‌دهی بر پایهٔ منبع: رابطهٔ منبع↔سرویس، گزینهٔ سرویس، و حل مدت/قیمت ## پروژه `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`. **`` خام؛ فرم با 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` را دوبار می‌نویسی: یک‌بار با قلاب سیاست، یک‌بار بدون آن.