# جداسازی محیط (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 تور ایمنی ندارند

فیلتر روی آن‌ها اعمال نمی‌شود. کوئری مستقیم روی `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` این را **بدون هیچ گارد دستی** می‌سنجد: فقط خودِ فیلتر. با برداشتن ستون، همان نشتی مالی فاز ۷ برمی‌گردد و تست قرمز می‌شود.

### 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 نکند، **استثنا می‌دهد** — سکوت اینجا گاردِ همیشه-بسته می‌سازد که خودش باگ است.

`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` | مالک چند کلینیک به هرکدام می‌تواند سوییچ کند |
