342 lines
27 KiB
Markdown
342 lines
27 KiB
Markdown
# جداسازی محیط (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` متفاوت است و جفت محیط واقعی میگیرد: «شش جلسه لیزر کلینیک الف»
|
||
در کلینیک ب هیچ معنایی ندارد و قابل مصرف نیست. همین تفاوت باعث میشود پکیجهای یک بیمار
|
||
در دو کلینیک کاملاً از هم جدا بمانند.
|