Files

342 lines
27 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# جداسازی محیط (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` متفاوت است و جفت محیط واقعی می‌گیرد: «شش جلسه لیزر کلینیک الف»
در کلینیک ب هیچ معنایی ندارد و قابل مصرف نیست. همین تفاوت باعث می‌شود پکیج‌های یک بیمار
در دو کلینیک کاملاً از هم جدا بمانند.