Four pages on the existing design system: a resources list whose branch/type/skill/
status filters live in the URL and go straight to the server, and three supporting
pages for types, skills and pools. Filtering client-side over a list the server had
already filtered would have been a second source of truth, so the page does neither.
The pool members dialog only offers resources from the pool's own branch and type —
the same rule the server enforces with 422, applied early so the user never reaches
the error. Skill assignment and pool membership are both full replacements, and both
say so in the dialog, because a partial-looking save that silently drops rows is
worse than an explicit one.
Wiring that was missing: deactivating a staff member through
PATCH /api/v1/staff/{uuid}/toggle now closes their resource too. Without it an
inactive operator would still have shown up in availability search. It is an explicit
call rather than a Doctrine lifecycle callback, since callbacks do not fire for
getArrayResult() — which is how every admin list is built — and that asymmetry is
its own bug. The reverse does not hold: closing a resource does not deactivate the
person, who may be purely administrative.
docs/api/resource.md documents all sixteen endpoints with responses captured from
real curl runs against ddev, including the 422 bodies for person-capacity and
non-scalar attributes. staff.md gains a "relationship to resources" section stating
that job_title is not a skill. tenancy.md contrasts these aggregate children —
whose roots do carry a tenant pair — with the branch_working_hours case from task 01,
where the root was global and the classification was wrong.
Also fixed a pre-existing flaky test: NumericFieldNormalizerTest guarded its random
mobile against collision on the never-reset db_test but not its random national code,
so a full-suite run could fail with 422 and close the EntityManager, taking an
unrelated test down with it. Both are now guarded, and the assertion prints the
server's response instead of a bare "422 is not 201".
Verified: phpunit 1119 tests / 3113 assertions green; slot-mode frozen contract green;
phpstan 14 errors before and after, none in touched files; tsc clean; vitest 88 files
/ 617 tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
308 lines
25 KiB
Markdown
308 lines
25 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 کن |
|
||
|
||
**فیلتر جایگزین 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` | مالک چند کلینیک به هرکدام میتواند سوییچ کند |
|