# جداسازی محیط (tenancy) هر دادهٔ عملیاتی در ClinicPro به یک **محیط** تعلق دارد: یا مطب شخصی یک پزشک، یا یک کلینیک. این سند می‌گوید آن محیط چطور تعیین می‌شود، کجا اجبار می‌شود، و — مهم‌تر — **کجا اجبار نمی‌شود**. --- ## هویت محیط جفت `(entity_type, entity_id)` روی خودِ جدول: | ستون | مقدار | |---|---| | `entity_type` | `doctor` یا `clinic` — `VARCHAR(10)` در همهٔ جدول‌های tenant-دار (طولِ یکسان، وگرنه JOIN به collation mismatch می‌خورد) | | `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 کن | ### استثنای مجاز: دادهٔ «مالِ خودِ کاربر» چند رکورد در محیطِ یک مطب ثبت می‌شوند ولی مالکشان بیمار است، نه آن مطب: پرداختِ نوبت و خودِ نوبتِ بیمار. مجوزشان با `user_id` بررسی می‌شود، نه با محیط. اینجا فیلتر چیزی به امنیت اضافه نمی‌کند و فقط ضرر می‌زند: کاربری که خودش صاحب محیط دیگری است — پزشکی که از مطب دیگری نوبت می‌گیرد، منشی‌ای که جایی بیمار است — رکورد خودش را نمی‌دید و صفحهٔ نتیجهٔ پرداخت ۴۰۴ می‌شد. تنها راه مجاز دورزدن، `App\Shared\Tenant\TenantFilterScope::withoutFilter()` است. عمداً یک کلاس جداست تا جاهای دورزدن قابل شمردن بمانند، و فیلتر را در `finally` برمی‌گرداند تا بقیهٔ همان درخواست دوباره محدود شود. مصرف‌کنندگان فعلی — هر سه کاربرمحور، نه محیط‌محور: | اندپوینت | چرا | |---|---| | `GET /api/v1/payment/{uuid}` | پرداخت‌کننده باید پرداخت خودش را ببیند | | `GET /api/v1/my/payments` | همان، به شکل فهرست | | `GET /api/v1/appointments/user` | نوبت‌های خودِ فرد به‌عنوان بیمار | `PayerSeesOwnPaymentTest` هر سه را می‌بندد و هم‌زمان تأیید می‌کند که پرداختِ کاربر دیگر همچنان ۴۰۳ می‌گیرد. **فیلتر جایگزین 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 تور ایمنی ندارند فیلتر روی آن‌ها اعمال نمی‌شود. کوئری مستقیم روی `service_item_consumables` بدون JOIN به ریشه، cross-tenant است. `TenantSchemaCoverageTest` فقط تضمین می‌کند زنجیرهٔ اعلام‌شده به ریشه‌ای با جفت tenant می‌رسد — نه اینکه کوئری‌ها واقعاً از ریشه شروع می‌شوند. **قاعدهٔ مرزبندی (فاز ۸):** فرزندی که uuidش از خودِ درخواست می‌آید، حق ندارد فرزند بماند. ```php $item = $this->serviceItemRepo->findByUuid($data['service_item_uuid']); ``` این **خودش** یک کوئری بی‌لنگر است. فاز ۷ نتیجه گرفته بود فرزندان ستون لازم ندارند چون «هر کوئری repository به ریشه لنگر می‌زند» — که دربارهٔ DQLهای repository درست بود و همین حالت را ندید. هر سه نشتی فاز ۷ دقیقاً همین شکل بودند. پس هشت فرزندِ قابل‌دسترس با uuid جفت محیط **خودشان** را گرفتند و دیگر در `AGGREGATE_CHILDREN` نیستند: ``` service_items · patient_sessions · session_payments · patient_notes patient_calls · patient_messages · patient_attachments · patient_medical_records ``` جفت در **سازنده** از ریشه مشتق می‌شود، نه از ورودی: ```php public function __construct(ServiceSection $section, ...) { $this->section = $section; $this->assignTenantPair($section->getEntityType(), $section->getEntityId()); } ``` دو خاصیت مهم دارد: هیچ نقطهٔ ساختی نمی‌تواند فراموشش کند، و چون محیطِ یک ریشه هرگز عوض نمی‌شود، این کپی write-once است و واگرا نمی‌شود. ۱۷ فرزند باقی‌مانده هیچ‌کدام با uuid از درخواست لود نمی‌شوند (اندازه‌گیری‌شده، نه فرض) و فقط از ریشه پیمایش می‌شوند. `RequestReachableChildTenantTest` این را **بدون هیچ گارد دستی** می‌سنجد: فقط خودِ فیلتر. با برداشتن ستون، همان نشتی مالی فاز ۷ برمی‌گردد و تست قرمز می‌شود. ### ⚠️ ریشهٔ سراسری، فرزندِ محیط‌دار — پروندهٔ `doctor_addresses` `doctor_addresses` عمداً در `ENTITIES` سراسری است («آدرس‌های پزشک؛ در همهٔ محیط‌های او یکسان است»)، پس **فیلتر رویش اعمال نمی‌شود** و `findOneBy(['uuid' => …])` آدرس کلینیک دیگر را هم برمی‌گرداند. دو جدولِ تسک شعبه روی همین ریشه نشستند و درس دادند: | جدول | طبقه‌بندی | چرا | |---|---|---| | `branch_working_hours` | جفت محیط **خودش** | اول به‌عنوان فرزند aggregate با ریشهٔ `DoctorAddress` ثبت شد و `TenantSchemaCoverageTest` ردش کرد: ریشه‌ای که خودش سراسری است، هیچ محیطی برای ارث دادن ندارد | | `rooms` | جفت محیط خودش | uuidش از درخواست می‌آید — همان قاعدهٔ فاز ۸ | جفت از **`type` آدرس** مشتق می‌شود، که نگاشتی کامل است: `personal ⇒ (doctor, doctor_id)` و `clinic ⇒ (clinic, clinic_id)`. چون آدرس هم فقط در محیط خودش فهرست می‌شود، هیچ ردیفی بی‌دلیل پنهان نمی‌شود. خودِ آدرس محافظ دستی دارد: `App\Branch\Service\BranchResolver` تک‌نقطهٔ تبدیل «uuid شعبه در request» به آدرسِ محیط جاری است و در غیر این صورت **۴۰۴** می‌دهد — همان رفتار فیلتر، نه ۴۰۳. **درسِ عملیاتی:** آنجا که `AGGREGATE_CHILDREN` بی‌فایده است، فقط طبقه‌بندی عوض نکن؛ جفت واقعی بده. و برای ریشهٔ سراسری یک resolver واحد بساز، نه بررسی تکراری در هر کنترلر. قرینهٔ درست همین ماجرا در تسک ۰۲ دیده می‌شود: `resource_skills` و `resource_pool_members` هم فرزند aggregate اند، ولی ریشه‌هاشان (`clinic_resources` و `resource_pools`) خودشان جفت محیط دارند و uuidشان از request نمی‌آید — پس آنجا `AGGREGATE_CHILDREN` طبقه‌بندی درستی است. تفاوت در ریشه است، نه در فرزند. ### uuid از درخواست — خطرناک‌ترین الگو سه نشتی واقعی در آدیت این نقطه پیدا شد و **هیچ‌کدام در repository نبودند**؛ همه در کنترلر و سرویس بودند، جایی که یک uuid از بدنه یا کوئری می‌آید و کسی محیطش را نمی‌سنجد: | مسیر | چه بود | |---|---| | `GET /api/v1/appointment-service-slots` | با uuid سرویسِ محیط دیگر، وجود/فعال‌بودن/مدتش لو می‌رفت و اسلات‌ها با آن محاسبه می‌شد | | `POST /api/v1/my/appointment` | بخش/سرویس/پرسنلِ محیط دیگر به نوبت **چسبانده و ذخیره** می‌شد | | `POST/PATCH` مراجعه | قیمتِ سرویسِ محیط دیگر وارد **فاکتور** می‌شد و `SessionService` با آن ذخیره می‌ماند | `App\Shared\Tenant\TenantOwnershipChecker` نقطهٔ واحد این بررسی است: ```php $this->tenantOwnership->belongsTo($context, $entity); // با EntityContext $this->tenantOwnership->belongsToPair($type, $id, $entity); // وقتی جفت اسکالر است $this->tenantOwnership->allBelongTo($context, $entities); // یک بیگانه = رد کل فهرست ``` موجودیتی که جفتش را expose نکند، **استثنا می‌دهد** — سکوت اینجا گاردِ همیشه-بسته می‌سازد که خودش باگ است. **فیلتر جایگزین این بررسی نیست، حتی روی جدولِ جفت‌دار.** جداسازی سختِ `TenantFilter` فقط روی محیطِ **انتخاب‌شده** اعمال می‌شود ({@see `EntityContext::$chosen`}). پزشکی که هنوز محیطی برنگزیده در هیچ محیطی «نیست»، پس فیلتر برایش خاموش است و `PATCH /api/v1/room/{uuid}` می‌توانست اتاق کلینیک دیگری را ویرایش کند — با `RoomCrudTest::testForeignRoomIsNotFound` گرفته شد که قبل از اصلاح ۲۰۰ می‌داد. هر کنترلری که uuid را از request می‌گیرد باید `belongsToPair()` را خودش صدا بزند. `TenantLookupInventoryTest` تعداد این جست‌وجوها را per-file نگه می‌دارد. افزودن یک `findByUuid` تازه روی موجودیت محیط‌دار تست را قرمز می‌کند تا کسی ثابت کند محیطش بررسی می‌شود و بعد عدد را به‌روز کند. ### جدول‌های مالی بدهی صفر است. `testThereIsNoUnclassifiedDebtLeft` خالی‌ماندنش را اجبار می‌کند. استدلالِ اولیهٔ «مالکیتشان دوگانه است» درست نبود: پرداخت به محیطِ **گیرنده** تعلق دارد و پرداخت‌کننده مانعی نیست، چون بیمار محیطی انتخاب نکرده و فیلتر برایش خاموش است. | جدول | تصمیم | چرا | |---|---|---| | `payments` | جفت محیط | نوبت → محیط نوبت · اشتراک → محیطی که خریدار صاحبش است · شارژ پیامک → محیط همان کیف پول | | `payment_logs` · `financial_breakdowns` | فرزند `Payment` | با FK به پرداخت لنگر می‌خورند | | `secretary_earnings` | فرزند `FinancialBreakdown` | زنجیره تا `payments` می‌رسد | | `wallet_transactions` | `ENTITIES` + انتسابِ per-ردیف | کیف پولِ **شخص** است و موجودی سراسری می‌ماند؛ ولی هر ردیف محیطِ ثبتش را در `recorded_entity_*` نگه می‌دارد تا دفتری که کلینیک می‌بیند به همان محیط محدود شود (پایین) | | `settlements` | `ENTITIES` | برداشت از همان کیف پولِ شخصی (`SettlementController` موجودی را با `getWalletBalance(user)` می‌سنجد) | | `bank_accounts` · `pos_devices` | جفت محیط، **تهی‌پذیر** | از کاربر به محیط منتقل شدند؛ موارد مبهم تهی ماندند (پایین) | نتیجهٔ عملی برای زنجیره: تضمین فقط تا جایی است که کوئری به `payments` لنگر بزند. `SecretaryEarningRepository::reportFor` این کار را با `join('b.payment','p')` می‌کند و فیلتر روی همان می‌نشیند؛ `FinancialChainTenantTest` همین را می‌سنجد. ### کیف پول: موجودی سراسری، دفتر per-محیط کیف پول ستون tenant ندارد و نباید داشته باشد: پول مالِ شخص است و اگر فیلتر روی موجودی می‌نشست، بیمار در هر محیط بخشی از پول خودش را می‌دید. ولی دفترِ تراکنش را کلینیک هم می‌بیند (`GET /api/v1/patient/{uuid}/wallet/transactions` و `recent_transactions`)، و گاردِ `ownsRecord` فقط **پرونده** را می‌سنجد نه سطرها. پس بدون تفکیک، کلینیک A می‌خواند بیمار در کلینیک B چه پرداخت کرده و چه کسی ثبتش کرده. راه‌حل، انتساب per-ردیف است نه مالکیت per-کیف‌پول: ```php // WalletTransaction — نامِ ستون‌ها عمداً entity_type/entity_id نیست #[ORM\Column(name: 'recorded_entity_type', ...)] private ?string $recordedEntityType = null; #[ORM\Column(name: 'recorded_entity_id', ...)] private ?int $recordedEntityId = null; ``` ⚠️ **نام‌ها باید همین بمانند.** `TenantFilter` روی `entityType/entityId` کلید می‌زند؛ اگر این دو همان نام را می‌گرفتند، فیلتر خودکار روی محاسبهٔ موجودی هم می‌نشست و پول بیمار را نصف نشان می‌داد. اینجا انتساب است، نه مالکیت. نتیجه: `balance_rials` سراسری، سطرهای دفتر per-محیط — پس **جمع سطرها با موجودی برابر نیست** و UI باید بگوید. ردیف‌های بی‌انتساب (پیش از این تفکیک، یا بیرون از هر محیط مثل سهم نماینده) در همه‌جا دیده می‌شوند تا تاریخچهٔ موجود ناپدید نشود. `PatientWalletTenantTest` هر سه را می‌سنجد: تفکیک سطرها، سراسری‌ماندن موجودی، و دیده‌شدن ردیف بی‌انتساب. ### ⚠️ نقطهٔ ضعف: کارتِ بی‌محیط در هیچ محیطی دیده نمی‌شود `bank_accounts` و `pos_devices` تنها جدول‌هایی‌اند که جفت محیطشان **تهی‌پذیر** است ({@see `NullableTenantOwnedTrait`}). دلیل: تا فاز ۶ روی `User` ثبت می‌شدند و برای کاربری که چند محیط دارد هیچ ستونی نمی‌گفت کدام کارت مال کدام محیط است. تصمیم گرفته شد **حدس زده نشود**؛ ردیف مبهم تهی می‌ماند تا مالک خودش تعیین کند. هزینه‌اش این است: فیلتر شرط تساوی می‌گذارد و `NULL` با هیچ مقداری برابر نیست، پس چنین ردیفی از هر کوئری DQL غایب است — حتی برای کسی که خودش ثبتش کرده. راه خروج، تنها استثنای این دامنه است: `findUnassignedByUser()` و `assignEntity()` عمداً با DBAL خام اجرا می‌شوند (فیلتر رویشان اعمال نمی‌شود) و به‌جای فیلتر، محدودیت `user_id` را در خودِ کوئری دارند. انتساب با `PATCH /api/v1/my/payment-methods/{bank-accounts|pos}/{uuid}/environment` انجام می‌شود و شرط‌های مالکیت و بی‌محیط بودن داخل خودِ `UPDATE`اند تا دو درخواست هم‌زمان یک کارت را به دو محیط نچسبانند. پنل ادمین این ردیف‌ها را با نشانهٔ «محیط تعیین‌نشده» و دکمهٔ انتساب نشان می‌دهد، وگرنه کاربر چندمحیطی فکر می‌کند کارتش گم شده. --- ## SQL خام — آدیت‌شده فیلتر روی `Connection::executeQuery/executeStatement` اعمال نمی‌شود. هر نقطه‌ای که SQL خام می‌زند بررسی و طبقه‌بندی شده: | فایل | دسته | چرا امن است | |---|---|---| | `Billing/Repository/ClaimRepository` | **tenant-دار** | هر سه کوئری `WHERE c.entity_type = :type AND c.entity_id = :id` دارند؛ `ClaimsByPatientTest::testAnotherTenantsClaimsNeverAppearInTheDashboard` تثبیتش می‌کند | | `Admin/Controller/AdminApiController` | ادمین | `#[IsGranted('ROLE_ADMIN')]` سطح کلاس؛ عمداً cross-tenant | | `Representation/Controller/RepresentationActionController` | نماینده | فقط `doctors`، اسکوپ `representation_id` | | `Category/Service/CategoryImporter` | سراسری | فقط جدول‌های مرجع؛ نام جدول از ثابت `TABLES` می‌آید (نه ورودی کاربر) و `isValidBundle()` + `ROLE_ADMIN` گیتش می‌کنند | | `Doctor/Command/Purge*Command` · `Shared/Command/SeedDemoDataCommand` | کنسول | dry-run پیش‌فرض، `--force` لازم، prod از سطح kernel مسدود | | `Shared/Controller/HealthController` | سراسری | `SELECT 1` | | `Shared/Logging/DbLogger` | سراسری | `app_log` در `GlobalTables` | | `PaymentMethod/Repository/{BankAccount,Pos}Repository` | **عمداً بیرون فیلتر** | تنها راه رسیدن به ردیفِ بی‌محیط؛ هر دو کوئری `WHERE user_id = ?` دارند و `assignEntity` شرط `entity_type IS NULL` را هم داخل `UPDATE` نگه می‌دارد. `PaymentMethodTenantTest` تلاش برای تصاحب کارت شخص دیگر را می‌سنجد | `getReference()` در کل `src/` یک مورد است و روی `User` (سراسری) — بدون اثر tenant. --- ## بکاپ per-tenant ```bash php bin/console app:tenant:dump --tenant=clinic:12 --output=/tmp/clinic12.sql ``` جدول‌ها از metadata خوانده می‌شوند (همان معیار `TenantFilter`)، پس جدولی که فردا جفت tenant بگیرد خودکار وارد خروجی می‌شود. ⚠️ فرزندان aggregate ستون محیط ندارند و در خروجی **نمی‌آیند**. برای بکاپ کامل یک محیط، آن‌ها باید از ریشه دنبال شوند. ورودی `--tenant` با regex بسته اعتبارسنجی می‌شود چون مستقیم داخل `--where` و خط فرمان می‌رود؛ `TenantDumpCommandTest` هفت ورودی بدشکل (تزریق SQL و شل، نوع ناشناخته، id صفر/منفی) را می‌سنجد. --- ## entity جدید می‌سازی؟ ۱. اگر به یک محیط تعلق دارد → `use TenantOwnedTrait;` و در نقطهٔ ساخت `assignTenant()` را صدا بزن ۲. اگر ندارد → با **دلیل** در `GlobalTables::ENTITIES` ثبتش کن ۳. اگر فرزند یک aggregate است → در `GlobalTables::AGGREGATE_CHILDREN` با ریشهٔ صریح ۴. تست را اجرا کن: `ddev exec php bin/phpunit tests/Shared/TenantSchemaCoverageTest.php` `NullableTenantOwnedTrait` برای entity **جدید** نیست. فقط برای جدولی است که از قبل وجود داشته و مالکِ بعضی ردیف‌هایش از داده قابل تشخیص نیست؛ entity جدید از روز اول محیط دارد، پس ستون تهی‌پذیر فقط تور ایمنی را سوراخ می‌کند. `DEFERRED` هم راه فرار نیست: خالی است و باید خالی بماند. ایندکس‌ها: `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` | یک منشی، یک پزشک، چند کلینیک | | `tests/Shared/TenantOwnershipCheckerTest.php` | خودِ checker: null، محیط حل‌نشده، نوعِ متفاوت با شناسهٔ یکسان | | `tests/Shared/TenantLookupInventoryTest.php` | جست‌وجوی uuid تازه‌ای بدون بازبینی اضافه نشده | | `tests/Appointment/ServiceModeSectionDurationTest.php` | سرویسِ محیط دیگر نه اسلات می‌دهد نه به نوبت می‌چسبد | | `tests/Patient/SessionServiceTenantTest.php` | سرویس/پرسنلِ محیط دیگر نه قیمت می‌خورد نه ذخیره می‌شود | | `tests/Payment/PaymentTenantTest.php` | پرداخت به محیط گیرنده می‌نشیند؛ بیمار پرداخت خودش را می‌بیند، محیط دیگر نمی‌بیند | | `tests/Settlement/FinancialChainTenantTest.php` | زنجیرهٔ مالی از راه لنگر به `payments` جدا می‌شود؛ کیف پول عمداً سراسری می‌ماند | | `tests/PaymentMethod/PaymentMethodTenantTest.php` | کارت‌ها per-محیط‌اند؛ ردیف بی‌محیط دیده می‌شود ولی تا انتساب قابل ویرایش نیست | | `tests/Patient/PatientWalletTenantTest.php` | دفتر کیف پول per-محیط است ولی موجودی سراسری می‌ماند | | `tests/Shared/RequestReachableChildTenantTest.php` | فرزندانِ قابل‌دسترس با uuid را **خودِ فیلتر** می‌بندد، بدون گارد دستی | | `tests/Auth/MultiClinicOwnerContextTest.php` | مالک چند کلینیک به هرکدام می‌تواند سوییچ کند | ## اعتبار جلسه: چرا برخلاف کیف پول جفت محیط می‌گیرد `wallet_transactions` عمداً در `ENTITIES` است: پول مالِ **شخص** است و در هر محیطی همان پول است؛ هر ردیف فقط `recorded_entity_*` دارد تا معلوم باشد کجا ثبت شده. `session_credit_ledger` متفاوت است و جفت محیط واقعی می‌گیرد: «شش جلسه لیزر کلینیک الف» در کلینیک ب هیچ معنایی ندارد و قابل مصرف نیست. همین تفاوت باعث می‌شود پکیج‌های یک بیمار در دو کلینیک کاملاً از هم جدا بمانند.