# جداسازی محیط (tenancy) هر دادهٔ عملیاتی در ClinicPro به یک **محیط** تعلق دارد: یا مطب شخصی یک پزشک، یا یک کلینیک. این سند می‌گوید آن محیط چطور تعیین می‌شود، کجا اجبار می‌شود، و — مهم‌تر — **کجا اجبار نمی‌شود**. --- ## هویت محیط جفت `(entity_type, entity_id)` روی خودِ جدول: | ستون | مقدار | |---|---| | `entity_type` | `doctor` یا `clinic` — `VARCHAR(10)` در هر ۲۰ جدول tenant-دار | | `entity_id` | شناسهٔ همان پزشک یا کلینیک | موجودیت‌ها این جفت را از trait مشترک می‌گیرند: ```php use App\Shared\Tenant\TenantOwnedTrait; $appointment->assignTenant(EntityContext::forBooking($doctor, $clinic)); // یا وقتی جفت از قبل به‌صورت اسکالر حل شده: $rule->assignTenantPair('clinic', $clinicId); ``` ستون‌ها `NOT NULL` بدون مقدار پیش‌فرض‌اند. اگر سازنده‌ای `assignTenant()` را فراموش کند، `flush` می‌شکند — عمدی است: ردیفِ بی‌محیط بی‌صدا از دید همه پنهان می‌شود. --- ## تعیین محیط جاری `App\Shared\Context\EntityContextResolver` تنها نقطهٔ تصمیم است. اولویت: ``` clinic_uuid صریحِ درخواست > UserActiveContext ذخیره‌شده > fallback نقش ``` دو مورد اول **انتخابِ کاربر**اند و `EntityContext::$chosen` را `true` می‌کنند. سومی حدس است. ### ماتریس نقش‌ها | نقش | محیط مؤثر | |---|---| | پزشک مستقل | همیشه `('doctor', id)` | | پزشک عضو یک یا چند کلینیک | طبق محیط فعال؛ بیرون از آن، مطب شخصی | | پزشکِ مالک کلینیک | طبق محیط فعال؛ در محیط کلینیک بدون محدودیت `ClinicDoctorPermission` | | مدیر/مالک کلینیک | همیشه `('clinic', id)` | | منشی | فقط از محیط فعال؛ بدون آن `unknown` | `user_active_context` هم `db_uuid` دارد هم `db_type`، پس حل محیط یک lookup است نه دو تا. --- ## اجبار: `TenantFilter` یک `SQLFilter` که به هر کوئری DQL روی موجودیت‌های tenant-دار شرط `(entity_type, entity_id)` اضافه می‌کند. **پیش‌فرض خاموش است.** `TenantFilterSubscriber` آن را در `kernel.request` روشن می‌کند و فقط وقتی که: - کاربر احراز شده باشد، **و** - `ROLE_ADMIN` نداشته باشد (پنل ادمین ذاتاً cross-tenant است)، **و** - محیطش **انتخاب‌شده** باشد (`$context->chosen`) > **چرا فقط محیط انتخاب‌شده؟** کاربری که هنوز محیطی برنگزیده در هیچ محیطی «نیست». قفل‌کردنش روی حدسِ نقش، دادهٔ کلینیکی‌ای را که قانوناً حقش است پنهان می‌کند — مثلاً پزشکِ عضوی که هرگز محیط عوض نکرده، نوبت‌های کلینیکش را از دست می‌داد. برای این کاربران، دسترسی را همان checkerهای دامنه تعیین می‌کنند (رفتار پیش از فاز ۴). ### چه تضمین می‌دهد و چه نمی‌دهد | مسیر | فیلتر اعمال می‌شود؟ | |---|---| | DQL و QueryBuilder | ✅ | | `findBy` / `findOneBy` | ✅ | | `EntityManager::find()` با کلید اصلی | ✅ — در Doctrine ORM 3 (تثبیت‌شده در `TenantFilterLeakTest`) | | بارگذاری تنبل کالکشن‌ها | ✅ | | entity که از قبل در identity map است | ❌ دوباره کوئری نمی‌شود | | `getReference()` | ❌ | | SQL خام DBAL | ❌ | | فرزندان aggregate | ❌ — همیشه از ریشه JOIN کن | **فیلتر جایگزین authorization نیست.** `AppointmentAccessChecker`، `ClinicDoctorAccessChecker`، `SecretaryAccessChecker` و `PatientRecordScopeResolver` سر جایشان می‌مانند: آن‌ها «چه کاری مجاز است» را جواب می‌دهند، فیلتر فقط «کدام ردیف‌ها». ### تغییر رفتار: ۴۰۴ به‌جای ۴۰۳ با فیلتر روشن، رکوردی که مال محیط دیگری است **وجود ندارد**، نه اینکه «ممنوع» باشد: - کاربر با محیط انتخاب‌شدهٔ A که رکورد محیط B را باز کند → `404` - مقداردهیِ proxy به موجودیتی که فیلتر کنارش گذاشته → `EntityNotFoundException` که `ExceptionSubscriber` به `404` با `ERR_NOT_FOUND_001` نگاشت می‌کند (و در سطح `info` لاگ می‌شود تا FK واقعاً شکسته هم دیده شود) --- ## طبقه‌بندی جدول‌ها — `App\Shared\Tenant\GlobalTables` هر entity دقیقاً در یکی از چهار وضعیت است، و `TenantSchemaCoverageTest` این را اجبار می‌کند: | وضعیت | یعنی | نمونه | |---|---|---| | جفت tenant دارد | فیلتر پوششش می‌دهد | `appointments`، `patient_records`، `service_sections` | | `ENTITIES` | عمداً سراسری | `cities`، `specialties`، `users`، `blogs` | | `AGGREGATE_CHILDREN` | محیط را از ریشه به ارث می‌برد | `patient_notes` → `patient_records` | | `DEFERRED` | بدهی ثبت‌شده، هنوز طبقه‌بندی نشده | جدول‌های مالی | ### ⚠️ فرزندان aggregate تور ایمنی ندارند فیلتر روی آن‌ها اعمال نمی‌شود. کوئری مستقیم روی `patient_attachments` بدون JOIN به `patient_records`، cross-tenant است. تست فقط تضمین می‌کند زنجیرهٔ اعلام‌شده به ریشه‌ای با جفت tenant می‌رسد — نه اینکه کوئری‌ها واقعاً از ریشه شروع می‌شوند. ### بدهی باقی‌مانده جدول‌های مالی (`payments`، `settlements`، `financial_breakdowns`، `wallet_transactions`، `secretary_earnings`، `bank_accounts`، `pos_devices`) در `DEFERRED` ثبت شده‌اند. مالکیتشان دوگانه است — پرداخت‌کننده در برابر دریافت‌کننده — و تصمیم درباره‌شان تحلیل جدا می‌خواهد. `testDeferredDebtDoesNotGrow` جلوی رشد بی‌صدای این فهرست را می‌گیرد. --- ## entity جدید می‌سازی؟ ۱. اگر به یک محیط تعلق دارد → `use TenantOwnedTrait;` و در نقطهٔ ساخت `assignTenant()` را صدا بزن ۲. اگر ندارد → با **دلیل** در `GlobalTables::ENTITIES` ثبتش کن ۳. اگر فرزند یک aggregate است → در `GlobalTables::AGGREGATE_CHILDREN` با ریشهٔ صریح ۴. تست را اجرا کن: `ddev exec php bin/phpunit tests/Shared/TenantSchemaCoverageTest.php` ایندکس‌ها: `entity_type, entity_id` باید **ستون‌های اول** هر ایندکس ترکیبیِ لیست باشند، وگرنه MariaDB برای شرط فیلتر از آن استفاده نمی‌کند. --- ## تست‌ها | فایل | چه چیزی را تضمین می‌کند | |---|---| | `tests/Shared/EntityContextResolverTest.php` | ماتریس ۵ نقش | | `tests/Shared/TenantIsolationMatrixTest.php` | هر نقش فقط دادهٔ محیط خودش را از API می‌گیرد | | `tests/Shared/TenantFilterLeakTest.php` | فیلتر **به‌تنهایی** cross-tenant را می‌بندد؛ مسیر عمومی و ادمین باز می‌مانند | | `tests/Shared/TenantSchemaCoverageTest.php` | هیچ entity طبقه‌بندی‌نشده نمی‌ماند | | `tests/Appointment/BookingTenantTest.php` | نوبت در محیط درست ثبت می‌شود | | `tests/Secretary/SecretaryMultiClinicScopeTest.php` | یک منشی، یک پزشک، چند کلینیک |