feat(tenant): enforce environment isolation in the ORM layer

Phase 4 of the tenant-marking series. Until now isolation depended on every
query remembering its own WHERE clause. With 82 entities and 844 tests, that is
not a guarantee — it is a hope. MariaDB has no row-level security, so the
backstop has to live in Doctrine.

TenantFilter appends (entity_type, entity_id) to every DQL query on a
tenant-owning entity. It ships disabled and TenantFilterSubscriber turns it on
per request.

The filter engages only for a **chosen** environment — an explicit clinic_uuid
on the request, or a stored UserActiveContext. EntityContext now records which
of the two produced it. Locking a user to the role fallback instead would hide
data they are entitled to: a clinic-member doctor who never switched context
lost every appointment belonging to that clinic. Five tests caught exactly that
before the gate was added. Admins and unauthenticated marketplace traffic stay
outside the filter by design.

Two findings from running it rather than reasoning about it:

- Dereferencing a lazy proxy whose target the filter excluded raises
  EntityNotFoundException, which surfaced as 500 on four patient endpoints.
  ExceptionSubscriber now maps it to 404: outside your environment means it does
  not exist for you. It is logged at info level so a genuinely broken FK is still
  visible.
- EntityManager::find() by primary key IS filtered in Doctrine ORM 3, contrary
  to the limitation carried over from older versions. The stronger guarantee is
  pinned by a test so a future regression is noticed, and the documented table
  was corrected.

The filter also caught a real leak: a clinic secretary's appointment list
filtered by doctor id alone, so a doctor's personal-practice booking appeared in
the clinic list. The test had been asserting that behaviour.

GlobalTables classifies all 82 entities into four states — carries a tenant,
deliberately global, aggregate child, or recorded debt — and
TenantSchemaCoverageTest fails on anything unclassified. Aggregate children
declare their root explicitly, because several attach through a scalar FK rather
than a Doctrine association and cannot be inferred from metadata; the test walks
each chain to a tenant-owning root. Financial tables stay in DEFERRED with a
ceiling assertion so the list cannot grow quietly.

Deliberately not built: the prePersist assignment listener from the plan. The
tenant columns are NOT NULL without a default, so a missing assignTenant()
already fails loudly at flush — phase 2 surfaced 123 such failures. A listener
would add silent auto-assignment where the current behaviour is an explicit
crash.

EXPLAIN with the filter's conditions still picks idx_appointments_tenant_slot
and uniq_patient_record.

Tests: 844 passing. PHPStan unchanged at its 17 pre-existing errors, none in
files touched here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-28 12:27:27 +03:30
co-authored by Claude Opus 5
parent 2e0888e0ef
commit 75d5052f72
14 changed files with 1522 additions and 7 deletions
+11
View File
@@ -227,6 +227,17 @@ Categories are polymorphic via a `bundle` string: `state`, `city`, `specially_do
---
## جداسازی محیط (tenant)
هر دادهٔ عملیاتی به یک محیط تعلق دارد: مطب شخصی یک پزشک، یا یک کلینیک — جفت `(entity_type, entity_id)`.
- **entity جدید** یا `App\Shared\Tenant\TenantOwnedTrait` می‌گیرد، یا با دلیل در `App\Shared\Tenant\GlobalTables` ثبت می‌شود. `TenantSchemaCoverageTest` هر دو حالت را اجبار می‌کند و entity طبقه‌بندی‌نشده را قرمز می‌کند.
- **محیط جاری** همیشه از `EntityContextResolver` گرفته می‌شود، نه از نقش کاربر.
- `TenantFilter` تور ایمنی است، نه جایگزین authorization — روی SQL خام، `getReference()` و فرزندان aggregate اعمال نمی‌شود.
- ایندکس‌های لیست باید `entity_type, entity_id` را **ستون اول** داشته باشند.
جزئیات و جدول کاملِ «چه تضمین می‌دهد و چه نمی‌دهد»: [docs/architecture/tenancy.md](docs/architecture/tenancy.md)
## قواعد غیرقابل‌مذاکره
1. SOLID در هر کد جدید. کامپوننت/کلاس چندمسئولیتی ننویس.
2. API جدید فقط وقتی هیچ اندپوینت موجودی — حتی با توسعه — کافی نباشد.
+7
View File
@@ -22,6 +22,13 @@ doctrine:
string_functions:
JSON_CONTAINS: App\Shared\Doctrine\JsonContains
JSON_EXTRACT: App\Shared\Doctrine\JsonExtract
filters:
# پیش‌فرض خاموش: TenantFilterSubscriber فقط وقتی روشنش می‌کند که محیط
# کاربر پنل حل شده باشد. روشن‌بودن سراسری، مسیرهای عمومی مارکت‌پلیس و
# کامندهای کنسول را — که کاربر ندارند — می‌شکند.
tenant:
class: App\Shared\Tenant\TenantFilter
enabled: false
when@test:
doctrine:
+131
View File
@@ -0,0 +1,131 @@
# جداسازی محیط (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 تور ایمنی ندارند
فیلتر روی آن‌ها اعمال نمی‌شود. کوئری مستقیم روی `patient_attachments` بدون JOIN به `patient_records`، cross-tenant است. تست فقط تضمین می‌کند زنجیرهٔ اعلام‌شده به ریشه‌ای با جفت tenant می‌رسد — نه اینکه کوئری‌ها واقعاً از ریشه شروع می‌شوند.
### بدهی باقی‌مانده
جدول‌های مالی (`payments`، `settlements`، `financial_breakdowns`، `wallet_transactions`، `secretary_earnings`، `bank_accounts`، `pos_devices`) در `DEFERRED` ثبت شده‌اند. مالکیتشان دوگانه است — پرداخت‌کننده در برابر دریافت‌کننده — و تصمیم درباره‌شان تحلیل جدا می‌خواهد. `testDeferredDebtDoesNotGrow` جلوی رشد بی‌صدای این فهرست را می‌گیرد.
---
## entity جدید می‌سازی؟
۱. اگر به یک محیط تعلق دارد → `use TenantOwnedTrait;` و در نقطهٔ ساخت `assignTenant()` را صدا بزن
۲. اگر ندارد → با **دلیل** در `GlobalTables::ENTITIES` ثبتش کن
۳. اگر فرزند یک aggregate است → در `GlobalTables::AGGREGATE_CHILDREN` با ریشهٔ صریح
۴. تست را اجرا کن: `ddev exec php bin/phpunit tests/Shared/TenantSchemaCoverageTest.php`
ایندکس‌ها: `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` | یک منشی، یک پزشک، چند کلینیک |
+726
View File
@@ -0,0 +1,726 @@
# مستند طراحی موتور نوبت‌دهی Clinic Pro
**نسخه ۲ — نگارش ساده**
---
## این مستند برای چیست؟
می‌خواهیم یک سیستم نوبت‌دهی بسازیم که برای همه نوع کلینیک کار کند. کلینیک زیبایی، دندانپزشکی، فیزیوتراپی، مرکز تصویربرداری و هر مرکز خدماتی دیگر.
نکته اصلی این است: برای هر کلینیک جدید نباید کد جدید بنویسیم. باید بتوانیم فقط تنظیمات را عوض کنیم و همان سیستم کار کند.
این مستند می‌گوید این سیستم چطور باید ساخته شود.
---
## ۱. چه چیزهایی در این مستند هست و چه چیزهایی نیست
**هست:**
تعریف خدمات، محاسبه زمان هر نوبت، مدیریت منابع کلینیک، قوانین اختصاصی هر کلینیک، پیدا کردن وقت آزاد، ثبت نوبت، محاسبه قیمت، مدیریت دوره‌های درمان.
**نیست:**
پرونده پزشکی بیمار، حسابداری، انبار، پیامک تبلیغاتی، گزارش‌های مدیریتی.
این‌ها سیستم‌های جداگانه‌ای هستند. سیستم نوبت‌دهی فقط به آن‌ها خبر می‌دهد که چه اتفاقی افتاده و کاری به کارشان ندارد.
---
## ۲. پنج قانون طلایی
هر تصمیمی که در این مستند گرفته شده، از این پنج قانون می‌آید:
**یک) تقویم مال منبع است، نه مال پزشک.**
خیلی از سیستم‌ها فقط برای پزشک تقویم می‌سازند. این اشتباه است. دستگاه لیزر هم تقویم دارد. اتاق هم تقویم دارد. تخت هم. پزشک فقط یکی از این‌هاست.
**دو) یک نوبت، یک تکه زمان پیوسته نیست.**
یک نوبت لیزر از چند بخش تشکیل شده. در هر بخش، منابع متفاوتی درگیرند. این را مفصل توضیح می‌دهیم چون مهم‌ترین نکته این مستند است.
**سه) اطلاعات را با قانون قاطی نکنیم.**
«فلان اپراتور می‌تواند با فلان دستگاه کار کند» یک اطلاعات است. باید در جدول ذخیره شود.
«اگر بیمار VIP بود ده درصد تخفیف بده» یک قانون است.
اگر این دو را قاطی کنیم، سیستم پر از قانون‌های الکی می‌شود و کند می‌شود.
**چهار) قانون‌ها باید ساده و از قبل مشخص باشند.**
نباید اجازه بدهیم کاربر هر کد دلخواهی به عنوان قانون بنویسد. چرا؟ چون وقتی می‌خواهیم وقت‌های آزاد یک ماه آینده را نشان بدهیم، سیستم باید هزاران حالت را بررسی کند. اگر هر قانون یک برنامه دلخواه باشد، این کار چند ثانیه طول می‌کشد و کاربر منتظر می‌ماند.
**پنج) هر چیزی که ثبت شد، باید همان‌طور بماند.**
اگر فردا قیمت لیزر را عوض کردیم، نوبتی که دیروز ثبت شده نباید تغییر کند. اگر قانونی را عوض کردیم، نوبت‌های قبلی نباید خراب شوند.
---
## ۳. سیستم از چه لایه‌هایی تشکیل شده
سیستم مثل یک خط تولید کار می‌کند. هر لایه کار خودش را می‌کند و نتیجه را به لایه بعدی می‌دهد:
```
۱. کاتالوگ → چه خدماتی داریم؟
۲. انتخاب → بیمار چه چیزی خواسته؟
۳. برنامه‌ریزی → این کار چقدر طول می‌کشد و چه منابعی می‌خواهد؟
۴. قوانین → قوانین این کلینیک چه تغییری در برنامه می‌دهند؟
۵. زمان‌یابی → چه ساعت‌هایی آزاد است؟
۶. ثبت نوبت → رزرو کن و مطمئن شو کس دیگری همان وقت را نگرفته
۷. قیمت‌گذاری → چقدر باید پرداخت شود؟
```
---
## ۴. کلینیک، شعبه، اتاق
سیستم قرار است به شکل سرویس اشتراکی (SaaS) فروخته شود. یعنی چند کلینیک مختلف روی یک سیستم کار می‌کنند و هیچ‌کدام نباید اطلاعات دیگری را ببیند.
سه سطح داریم:
| سطح | یعنی چه |
|---|---|
| کلینیک (Tenant) | مشتری ما — کسی که اشتراک خریده |
| شعبه (Branch) | یک ساختمان واقعی با منابع و ساعت کاری خودش |
| اتاق (Room) | فضای داخل شعبه |
**نکته مهم:** شناسه کلینیک باید از همان روز اول در تمام جدول‌ها باشد. این چیزی نیست که بعداً اضافه شود. اگر اول سیستم را تک‌کلینیکی بسازیم، بعداً باید همه چیز را از نو بنویسیم.
خدمات و قیمت‌ها را می‌شود در سطح کلینیک تعریف کرد و در شعبه‌ها تغییر داد. مثلاً لیزر در شعبه مرکزی گران‌تر باشد.
منابع همیشه مال شعبه هستند، چون فیزیکی‌اند.
---
## ۵. تعریف خدمات
### دسته‌بندی
فقط برای مرتب کردن و نمایش است. مثلاً:
```
زیبایی
لیزر
تزریق
دندانپزشکی
ترمیم
جراحی
```
هیچ منطق نوبت‌دهی به دسته‌بندی وابسته نیست. فقط قوانین می‌توانند به آن اشاره کنند، مثلاً «همه خدمات دسته جراحی به جراح نیاز دارند».
### سرویس
هر چیزی که بیمار می‌تواند رزرو کند. مثلاً «لیزر کندلا» یا «ترمیم دندان».
هر سرویس این‌ها را دارد:
- عنوان و دسته‌بندی
- تک‌جلسه است یا دوره‌ای؟ (لیزر معمولاً هشت جلسه است)
- الگوی بخش‌های نوبت (بخش ۷ توضیح می‌دهد)
- قیمت پایه
- گروه‌های آیتم قابل انتخاب
- توضیحات آماده‌سازی بیمار
- فعال یا غیرفعال
### گروه آیتم
اینجا یک اشکال مهم در طراحی قبلی بود. قبلاً آیتم‌ها یک لیست ساده بودند. اما در واقعیت، انتخاب آیتم قانون دارد:
- «حتماً یک سطح انرژی انتخاب کن، فقط یکی»
- «بین یک تا هشت دندان انتخاب کن»
- «اگر بیکینی را انتخاب کردی، نمی‌توانی فول‌بادی را هم انتخاب کنی»
این‌ها را نباید به موتور قوانین سپرد. باید در خود تعریف آیتم باشند.
```
گروه: دندان‌های موردنظر
حداقل انتخاب: ۱
حداکثر انتخاب: ۸
گروه: سطح انرژی
حداقل انتخاب: ۱
حداکثر انتخاب: ۱
```
### آیتم
هر آیتم این‌ها را دارد:
| فیلد | یعنی چه |
|---|---|
| عنوان | مثلاً «صورت» یا «دندان شماره ۵» |
| زمان تنها | اگر تنها آیتم انتخابی باشد چقدر طول می‌کشد |
| زمان اضافه | اگر همراه آیتم‌های دیگر باشد چقدر به کل اضافه می‌کند |
| قیمت | |
| منابع اضافه | اگر منبع خاصی می‌خواهد |
| ناسازگار با | آیتم‌هایی که با آن جمع نمی‌شوند |
| پیش‌نیاز | آیتم‌هایی که باید قبلش انتخاب شوند |
**چرا دو تا فیلد زمان؟**
لیزر صورت به تنهایی پانزده دقیقه است. اما این پانزده دقیقه شامل آماده کردن بیمار، تنظیم دستگاه و توضیح دادن است.
حالا اگر بیمار صورت و بیکینی را با هم بخواهد، آماده‌سازی دو بار انجام نمی‌شود. بیکینی فقط هشت دقیقه به کل زمان اضافه می‌کند، نه پانزده دقیقه.
فرمول قدیمی که می‌گفت «زمان پایه + جمع زمان آیتم‌ها» همیشه زمان را بیشتر از واقعیت حساب می‌کند. یعنی ظرفیت کلینیک را الکی پر می‌کند و درآمد از دست می‌رود.
---
## ۶. منابع کلینیک
### منبع چیست
هر چیزی که برای انجام کار لازم است و ممکن است اشغال باشد: پزشک، اپراتور، دستیار، دستگاه، اتاق، تخت، یونیت دندانپزشکی.
نوع منابع را خود کلینیک تعریف می‌کند. ما در کد چیزی را ثابت نمی‌کنیم.
هر منبع این‌ها را دارد:
- متعلق به کدام شعبه
- چه نوعی است
- ظرفیت همزمان چقدر است
- چه مهارت‌هایی دارد
- ویژگی‌های آزاد: جنسیت، مدل دستگاه، طبقه
- تقویم کاری
- زمان آماده‌سازی و تمیزکاری بین دو بیمار
### ظرفیت همزمان
اتاق تزریق با سه تخت را چطور مدل کنیم؟
**غلط:** سه منبع جداگانه بسازیم.
**درست:** یک منبع با ظرفیت سه.
این کار مدل‌سازی را خیلی ساده‌تر می‌کند.
### زمان تمیزکاری
بین دو بیمار، یونیت دندانپزشکی ده دقیقه ضدعفونی می‌خواهد. این ده دقیقه جزو نوبت بیمار نیست، اما یونیت در این مدت اشغال است.
طراحی قبلی این را در نظر نگرفته بود.
### استخر منابع
کلینیک سه دستگاه لیزر مشابه دارد. وقتی بیمار نوبت می‌گیرد، فرقی نمی‌کند کدام دستگاه.
پس باید بتوانیم بگوییم «هر کدام از دستگاه‌های استخر لیزر آلکساندرایت». طراحی قبلی فقط می‌توانست به یک دستگاه مشخص اشاره کند که در عمل جواب نمی‌دهد.
### جدول مهارت‌ها
کدام اپراتور مجاز است با کدام دستگاه کار کند؟ این یک جدول ساده است.
طراحی قبلی این را به موتور قوانین سپرده بود. مشکل: اگر کلینیک پنجاه اپراتور و دویست سرویس داشته باشد، ده هزار قانون لازم می‌شود. غیرقابل مدیریت است.
### نیازمندی منبع
قلب انعطاف سیستم اینجاست. برای هر بخش از کار می‌گوییم چه منابعی لازم است:
```
نیاز اول:
نقش: اپراتور
تعداد: ۱
شرط: باید مهارت لیزر آلکساندرایت داشته باشد
قید: هم‌جنس با بیمار
نوع اشغال: انحصاری
نیاز دوم:
نقش: دستگاه
تعداد: ۱
شرط: هر عضو استخر لیزرهای آلکساندرایت
نوع اشغال: انحصاری
نیاز سوم:
نقش: اتاق
تعداد: ۱
شرط: اتاقی که نوعش لیزر باشد
نوع اشغال: انحصاری
```
**قید جنسیت** در کلینیک‌های زیبایی ایران یک الزام جدی است، نه یک ترجیح. باید در هسته سیستم باشد.
**نوع اشغال** سه حالت دارد:
- انحصاری: منبع کامل اشغال است
- اشتراکی: یک واحد از ظرفیت منبع مصرف می‌شود
- منفعل: منبع رزرو است ولی کسی فعالانه کار نمی‌کند
---
## ۷. مهم‌ترین بخش: نوبت از چند تکه تشکیل شده
### مشکل چیست
طراحی قبلی فکر می‌کرد یک نوبت، یک بازه زمانی است که همه منابعش را در کل آن بازه اشغال می‌کند.
بیایید یک نوبت لیزر واقعی را نگاه کنیم:
| بخش | مدت | اتاق | اپراتور | دستگاه |
|---|---|---|---|---|
| مالیدن کرم بی‌حسی | ۵ دقیقه | اشغال | اشغال | آزاد |
| منتظر ماندن تا کرم اثر کند | ۳۰ دقیقه | اشغال | **آزاد** | آزاد |
| خود لیزر | ۲۰ دقیقه | اشغال | اشغال | اشغال |
| مراقبت بعد از لیزر | ۵ دقیقه | اشغال | اشغال | آزاد |
با طراحی قبلی، اپراتور شصت دقیقه قفل می‌شود. در حالی که واقعاً فقط سی دقیقه کار می‌کند.
یعنی نصف ظرفیت اپراتور الکی هدر می‌رود. برای یک کلینیک زیبایی، این تفاوت بین سود و ضرر است.
### راه‌حل
هر نوبت را به چند بخش تقسیم می‌کنیم. هر بخش مدت خودش، منابع خودش و ترتیب خودش را دارد:
```
بخش: انتظار اثر بی‌حسی
مدت: ۳۰ دقیقه
ترتیب: بعد از بخش قبلی
منابع: فقط اتاق
بیمار حاضر است: بله
قابل ادغام با: بخش‌های انتظار دیگر
```
### چطور برنامه نوبت ساخته می‌شود
```
ورودی: سرویس، آیتم‌های انتخابی، بیمار، شعبه
۱. بخش‌های پایه سرویس را بردار
۲. بخش‌های اضافه هر آیتم انتخابی را اضافه کن
۳. بخش‌های هم‌نوع را ادغام کن
(آماده‌سازی یک بار انجام می‌شود، نه به تعداد آیتم‌ها)
۴. مدت هر بخش را حساب کن
- اولین آیتم هر گروه: زمان تنها
- بقیه: زمان اضافه
۵. بخش‌ها را به ترتیب درست بچین
۶. قوانین مربوط به زمان را اعمال کن
۷. زمان آماده‌سازی و تمیزکاری هر منبع را اضافه کن
خروجی: برنامه نوبت
```
نتیجه چیزی شبیه این می‌شود:
```
کل مدت: ۶۰ دقیقه
بخش ۱ — دقیقه ۰ تا ۵ — اتاق، اپراتور
بخش ۲ — دقیقه ۵ تا ۳۵ — فقط اتاق
بخش ۳ — دقیقه ۳۵ تا ۵۵ — اتاق، اپراتور، دستگاه
بخش ۴ — دقیقه ۵۵ تا ۶۰ — اتاق، اپراتور
```
حالا اپراتور در آن سی دقیقه انتظار، آزاد است و می‌تواند بیمار دیگری را ببیند.
---
## ۸. قوانین اختصاصی
### چرا طراحی قبلی جواب نمی‌داد
طراحی قبلی همه قوانین را در یک کیسه ریخته بود. سه اشکال داشت:
**اول:** قانون‌ها جنس‌های کاملاً متفاوتی دارند و در زمان‌های مختلفی باید اجرا شوند.
**دوم:** معلوم نبود اگر دو قانون با هم تناقض داشتند، کدام برنده است. مثلاً اگر دو تخفیف همزمان اعمال شوند، جمع می‌شوند یا ضرب؟ نتیجه غیرقابل پیش‌بینی می‌شد.
**سوم:** اگر قانون بتواند هر کاری بکند، پیدا کردن وقت آزاد کند می‌شود.
### شش دسته قانون
| دسته | کی اجرا می‌شود | نمونه |
|---|---|---|
| انتخاب | موقع انتخاب آیتم | «اگر سرویس الف را انتخاب کردی، ب هم لازم است» |
| صلاحیت بیمار | قبل از جستجوی وقت | «بیمار زیر ۱۸ سال بدون رضایت والدین نمی‌شود» |
| منبع | موقع ساخت برنامه | «خدمات جراحی به جراح نیاز دارند» |
| زمان | موقع ساخت برنامه | «حداقل مدت درمان پیچیده یک ساعت است» |
| فاصله زمانی | موقع جستجوی وقت | «حداقل هفت روز از جلسه قبلی فاصله باشد» |
| قیمت | بعد از نهایی شدن برنامه | «بیمار VIP ده درصد تخفیف» |
هر دسته موتور خودش را دارد و در نقطه مشخصی اجرا می‌شود.
### شکل یک قانون
```
شناسه: جراحی نیاز به جراح دارد
کلینیک: ۰۰۱
شعبه: همه شعب
دسته: منبع
اولویت: ۱۰۰
از تاریخ: ۱۴۰۵/۰۱/۰۱
نسخه: ۳
شرط:
دسته‌بندی سرویس شامل «جراحی» باشد
نتیجه:
یک منبع با نقش جراح و مهارت جراحی عمومی اضافه کن
```
### قانون‌ها به چه چیزهایی دسترسی دارند
فقط به این فهرست مشخص. هر چیز جدیدی باید صریحاً اضافه شود:
```
سرویس: شناسه، دسته‌بندی، برچسب‌ها
آیتم‌ها: شناسه، گروه، تعداد
بیمار: سن، جنسیت، برچسب‌ها، تاریخ آخرین جلسه، تعداد جلسات انجام‌شده
رزرو: تاریخ و ساعت، روز هفته، شعبه، کانال رزرو
برنامه: کل مدت، کل قیمت
```
عملگرها هم مشخص است: مساوی، نامساوی، بزرگ‌تر، کوچک‌تر، عضو مجموعه، بین دو مقدار، تعداد روز گذشته.
**نوشتن کد دلخواه ممنوع است.** این محدودیت عمدی است. قوانین مربوط به فاصله زمانی باید قابل تبدیل به کوئری دیتابیس باشند، وگرنه جستجوی وقت آزاد کند می‌شود.
### وقتی چند قانون با هم تناقض دارند
ترتیب انتخاب:
۱. قانون با اولویت بالاتر برنده است
۲. اگر اولویت مساوی بود، قانون اختصاصی‌تر برنده است (شعبه بر کلینیک، سرویس بر دسته‌بندی)
۳. اگر باز هم مساوی بود، قانون قدیمی‌تر برنده است
و وقتی چند قانون هم‌نوع همزمان اعمال می‌شوند:
| نوع اثر | قاعده |
|---|---|
| حداقل مدت | بیشترین مقدار برنده است |
| اضافه کردن زمان | همه با هم جمع می‌شوند |
| اضافه کردن منبع | همه اضافه می‌شوند، تکراری‌ها حذف |
| محدود کردن منبع | اشتراک محدودیت‌ها |
| تخفیف درصدی | به ترتیب اولویت پشت سر هم، با یک سقف قابل تنظیم |
| ممنوعیت | یکی کافی است — کل عملیات رد می‌شود |
### نسخه‌بندی
قانون‌ها ویرایش نمی‌شوند. هر تغییر، یک نسخه جدید با تاریخ شروع می‌سازد.
هر نوبت، فهرست قانون‌هایی که رویش اعمال شده را ذخیره می‌کند. پس اگر فردا قانون عوض شد، نوبت دیروز دست نخورده می‌ماند.
### محیط آزمایش
قبل از اینکه یک قانون واقعاً فعال شود، باید بشود آن را روی داده واقعی اجرا کرد و نتیجه را دید، بدون اینکه چیزی ثبت شود.
---
## ۹. تقویم و ساعت‌های آزاد
### چطور حساب می‌شود
ساعت‌های آزاد یک منبع، از کم کردن لایه‌ها به دست می‌آید:
```
ساعت کاری شعبه
منهای ساعت‌های خارج از شیفت منبع
منهای تعطیلات رسمی
منهای مرخصی و غیبت
منهای زمان سرویس دوره‌ای دستگاه
منهای نوبت‌های ثبت‌شده
منهای رزروهای موقت
منهای زمان آماده‌سازی و تمیزکاری
────────────────────────────────
= ساعت‌های واقعاً آزاد
```
### تقویم شمسی
- ذخیره‌سازی همیشه میلادی و به وقت جهانی است
- تبدیل به شمسی فقط در لحظه نمایش انجام می‌شود
- جدول تعطیلات رسمی کشور داریم، ولی هر کلینیک می‌تواند تغییرش بدهد (بعضی کلینیک‌ها پنجشنبه‌ها کار می‌کنند)
- شیفت‌های تکرارشونده با الگوی استاندارد تعریف می‌شوند
---
## ۱۰. پیدا کردن وقت آزاد
### مراحل
```
ورودی: برنامه نوبت، بازه تاریخ، شعبه
۱. برای هر نیازمندی، منابع مناسب را پیدا کن
منابعی که شرط را دارند، قوانین اجازه می‌دهند و قیدها را دارند
اگر برای یک نیازمندی هیچ منبعی پیدا نشد،
خطای واضح بده: «هیچ اپراتور خانمی با مهارت لیزر در این شعبه نیست»
۲. ساعت‌های آزاد هر منبع را در بازه تاریخ حساب کن
۳. نقطه‌های شروع ممکن را بساز (پیش‌فرض: هر ۱۵ دقیقه)
نقطه‌هایی که از همان اول قطعاً جواب نمی‌دهند را حذف کن
۴. برای هر نقطه شروع:
برای هر بخش از برنامه:
ببین در آن بازه، منابع لازم آزاد هستند یا نه
قوانین فاصله زمانی را چک کن
اگر همه چیز درست بود، این یک وقت معتبر است
۵. وقت‌های پیدا شده را مرتب کن
خروجی: لیست وقت‌های آزاد به همراه پیشنهاد اینکه کدام منبع استفاده شود
```
### کدام منبع را انتخاب کنیم
وقتی چند منبع آزاد است، انتخاب مهم است. کلینیک می‌تواند یکی از این‌ها را تنظیم کند:
- **کمترین شکاف:** منبعی که کمترین وقت خالی بلااستفاده ایجاد کند (پیش‌فرض)
- **توزیع متعادل:** کار بین منابع پخش شود
- **حفظ متخصص‌ها:** اپراتور ماهر برای کارهای ساده مصرف نشود
- **همان منبع قبلی:** در دوره‌های درمان، همان اپراتور جلسات قبل
### سرعت
- ایندکس مخصوص بازه زمانی روی جدول رزروها
- ذخیره موقت ساعت‌های آزاد هر روز و هر منبع
- محدود کردن جستجو به نود روز آینده
- **هدف: جستجوی یک ماهه زیر نیم ثانیه پاسخ بدهد**
---
## ۱۱. ثبت نوبت
### سه مرحله
```
جستجو ← رزرو موقت ← ثبت نهایی
```
طراحی قبلی فقط «ثبت نوبت» داشت. مشکل: دو نفر همزمان یک ساعت را انتخاب می‌کنند، هر دو می‌روند پرداخت کنند، و نفر دوم موقع پرداخت با خطا مواجه می‌شود.
### رزرو موقت
- به محض اینکه بیمار ساعتی را انتخاب کرد، آن ساعت برای همه منابعش قفل می‌شود
- قفل ده دقیقه اعتبار دارد (برای پرداخت آنلاین بیشتر)
- تضمین این کار در سطح دیتابیس انجام می‌شود، نه در کد برنامه
این نکته فنی مهم است: اگر جلوگیری از رزرو تکراری را در کد برنامه بگذاریم، همیشه یک شکاف زمانی می‌ماند که دو درخواست همزمان از آن رد می‌شوند. دیتابیس باید این را تضمین کند.
### ثبت نهایی
همه این کارها با هم و یکجا انجام می‌شوند. اگر یکی شکست بخورد، همه لغو می‌شوند:
۱. چک کن رزرو موقت هنوز معتبر است
۲. قوانین صلاحیت و فاصله زمانی را دوباره چک کن
۳. نوبت و بخش‌هایش را ثبت کن
۴. قفل موقت را به رزرو دائمی تبدیل کن
۵. قیمت و قوانین اعمال‌شده را ذخیره کن
۶. به بقیه سیستم خبر بده
### وضعیت‌های یک نوبت
```
پیش‌نویس → رزرو موقت → ثبت‌شده → پذیرش → در حال انجام → تمام‌شده
↓ ↓
لغو عدم حضور
جابجا شده
```
هر تغییر وضعیت ثبت می‌شود تا بعداً بشود فهمید چه اتفاقی افتاده.
### لغو و عدم حضور
هر کلینیک تنظیم می‌کند: تا چند ساعت قبل لغو رایگان است، جریمه چقدر است، بیعانه برمی‌گردد یا نه، بعد از چند بار عدم حضور بیمار پرریسک علامت بخورد.
---
## ۱۲. قیمت‌گذاری
### چرا لایه جداست
قیمت زندگی خودش را دارد: تاریخ اعتبار، مالیات، بیمه، پکیج، بیعانه. طراحی قبلی قیمت را داخل موتور قوانین گذاشته بود که هر دو را خراب می‌کند.
### مراحل محاسبه
```
۱. قیمت پایه سرویس (از لیست قیمت معتبر در تاریخ رزرو)
۲. جمع قیمت آیتم‌های انتخابی
۳. اعمال قوانین قیمت به ترتیب اولویت
۴. کسر از اعتبار پکیج (اگر بیمار پکیج دارد)
۵. محاسبه مالیات و سهم بیمه
۶. تعیین مبلغ بیعانه
۷. ذخیره فاکتور تفکیک‌شده
```
### لیست قیمت
- هر لیست قیمت تاریخ شروع و پایان دارد
- هر شعبه می‌تواند قیمت خودش را داشته باشد
- **تغییر قیمت هرگز نوبت‌های ثبت‌شده را عوض نمی‌کند**
### پکیج
خیلی رایج در کلینیک زیبایی: «پکیج شش جلسه لیزر». بیمار یکجا پول می‌دهد و بعداً جلساتش را رزرو می‌کند.
```
خرید پکیج → اعتبار جلسه ثبت می‌شود
ثبت نوبت → یک واحد اعتبار مصرف می‌شود
لغو نوبت → اعتبار برمی‌گردد (طبق سیاست لغو)
```
**نکته فنی:** اعتبار را به صورت دفتر حساب نگه می‌داریم، نه یک عدد شمارنده. یعنی هر تراکنش ثبت می‌شود و مانده از جمع آن‌ها حساب می‌شود. اگر فقط یک عدد نگه داریم، اولین اشتباه هرگز قابل ردیابی نیست.
---
## ۱۳. دوره درمان
لیزر معمولاً شش تا هشت جلسه است. طراحی قبلی فقط نوبت تکی می‌شناخت، در حالی که این حالت اصلی کسب‌وکار است.
### پروتکل دوره
```
سرویس: لیزر فول‌بادی
تعداد جلسات: ۸
فاصله بین جلسات:
حداقل: ۲۱ روز
ایده‌آل: ۲۸ روز
حداکثر: ۴۵ روز
سطح انرژی هر جلسه: ۱۲، ۱۴، ۱۶، ۱۸، ۲۰، ۲۰، ۲۲، ۲۲
```
### رفتار سیستم
- بیمار می‌تواند کل دوره را یکجا رزرو کند، یا جلسه به جلسه پیش برود
- بعد از هر جلسه، تاریخ جلسه بعدی پیشنهاد داده می‌شود
- اگر بیمار از حداکثر فاصله عبور کرد، هشدار داده می‌شود
- پیشرفت دوره ردیابی می‌شود: جلسه چندم از چند، پارامترهای هر جلسه
- به صورت پیش‌فرض سعی می‌شود همان اپراتور قبلی انتخاب شود
---
## ۱۴. جدول‌های دیتابیس
فرض: PostgreSQL
```
کلینیک و شعبه
tenant, branch, room
خدمات
service_category دسته‌بندی درختی
service سرویس
service_branch_override قیمت و مدت اختصاصی شعبه
item_group گروه آیتم با حداقل و حداکثر انتخاب
service_item آیتم با دو نوع زمان
service_item_relation ناسازگاری و پیش‌نیاز
segment_template الگوی بخش‌های نوبت
segment_requirement نیازمندی منبع هر بخش
منابع
resource_type نوع منبع
resource منبع با ظرفیت و ویژگی‌ها
resource_pool استخر منابع قابل جایگزینی
skill / resource_skill جدول مهارت‌ها
work_calendar تقویم کاری با الگوی تکرار
resource_exception مرخصی و سرویس دوره‌ای
holiday تعطیلات
قوانین
policy قانون با شرط و نتیجه
policy_version_log تاریخچه نسخه‌ها
نوبت‌ها
appointment نوبت اصلی
appointment_item آیتم‌های انتخابی
appointment_segment بخش‌های نوبت
resource_occupancy اشغال منابع ← مهم‌ترین جدول
appointment_audit تاریخچه تغییرات
قیمت
price_list لیست قیمت تاریخ‌دار
price_snapshot فاکتور ثبت‌شده
package پکیج
session_credit_ledger دفتر اعتبار جلسات
payment / deposit پرداخت و بیعانه
دوره درمان
course_protocol پروتکل دوره
treatment_course دوره بیمار
course_session جلسات دوره
```
**نکته کلیدی:** جدول `resource_occupancy` تنها مرجع حقیقت برای اشغال منابع است. هم رزرو موقت و هم نوبت نهایی در همین جدول ثبت می‌شوند و فقط وضعیت‌شان فرق می‌کند.
با این طراحی، جلوگیری از رزرو تکراری به یک محدودیت ساده دیتابیسی تبدیل می‌شود و دیگر لازم نیست در کد نگرانش باشیم.
---
## ۱۵. مسیر کامل یک رزرو
```
۱. بیمار شعبه را انتخاب می‌کند
۲. دسته‌بندی و سرویس را انتخاب می‌کند
۳. آیتم‌ها را انتخاب می‌کند
سیستم قیدهای گروه را چک می‌کند
قوانین انتخاب اجرا می‌شوند
۴. برنامه نوبت ساخته می‌شود
تقسیم به بخش‌ها، ادغام، محاسبه مدت
قوانین منبع و زمان اعمال می‌شوند
۵. صلاحیت بیمار بررسی می‌شود
اگر رد شد، دلیل قابل فهم نشان داده می‌شود
۶. قیمت اولیه محاسبه می‌شود
۷. وقت‌های آزاد پیدا می‌شوند
۸. وقت‌ها به بیمار نشان داده می‌شوند
۹. بیمار وقت را انتخاب می‌کند و رزرو موقت ایجاد می‌شود
۱۰. پرداخت یا بیعانه (اگر لازم باشد)
۱۱. ثبت نهایی همراه با ذخیره قیمت و قوانین
۱۲. اطلاع‌رسانی به بیمار و بقیه سیستم
```
---
## ۱۶. خبرهایی که سیستم منتشر می‌کند
سیستم‌های دیگر (پیامک، حسابداری، گزارش) به این خبرها گوش می‌دهند:
```
رزرو موقت ایجاد شد نوبت ثبت شد
نوبت لغو شد نوبت جابجا شد
بیمار نیامد نوبت تمام شد
منبع مسدود شد منبع آزاد شد
دوره درمان شروع شد یک جلسه دوره تمام شد
```
---
## ۱۷. ریسک‌ها
| ریسک | چه اتفاقی می‌افتد | چه کنیم |
|---|---|---|
| کند شدن جستجوی وقت با زیاد شدن منابع | کاربر منتظر می‌ماند و می‌رود | ساده نگه داشتن قوانین، ذخیره موقت، محدود کردن بازه جستجو، اندازه‌گیری سرعت از فاز اول |
| کاربر غیرفنی نمی‌تواند قانون درست تعریف کند | قانون‌های اشتباه، رفتار عجیب | ساخت قانون با فرم آماده، الگوهای از پیش تعریف‌شده، آزمایش اجباری قبل از فعال شدن |
| کلینیک بخش‌های نوبت را اشتباه تعریف کند | ظرفیت غلط حساب می‌شود | الگوی آماده برای هر نوع کلینیک، گزارش بهره‌وری منابع برای پیدا کردن اشکال |
| رقابت روی ساعت‌های پرتقاضا | رزرو شکست می‌خورد | رزرو موقت کوتاه، پیشنهاد خودکار ساعت جایگزین |
| اضافه کردن چندکلینیکی بعد از راه‌اندازی | بازنویسی کامل دیتابیس | از همان روز اول در تمام جدول‌ها |
---
## ۱۸. ترتیب ساخت
**مرحله اول — هسته**
کلینیک و شعبه، تعریف کامل خدمات، منابع و مهارت‌ها، مدل بخش‌های نوبت، تقویم، جستجوی وقت، رزرو موقت و ثبت، قیمت پایه.
**مرحله دوم — قوانین**
موتور قوانین با شش دسته، فرم ساخت قانون، محیط آزمایش، نسخه‌بندی.
**مرحله سوم — کسب‌وکار**
دوره درمان، پکیج، بیعانه، سیاست لغو، لیست انتظار.
**مرحله چهارم — بهینه‌سازی**
استراتژی انتخاب منبع، پیشنهاد هوشمند وقت، گزارش بهره‌وری، پیش‌بینی عدم حضور.
---
## ۱۹. جمع‌بندی
سیستم از شش قدم تشکیل شده:
```
تعریف خدمات → ساخت برنامه نوبت → اعمال قوانین
→ پیدا کردن وقت → ثبت → قیمت‌گذاری
```
اگر بخواهیم سه چیز را از این مستند به یاد نگه داریم:
**۱. نوبت از چند بخش تشکیل شده، نه یک تکه.**
بدون این، ظرفیت واقعی کلینیک نصف نشان داده می‌شود.
**۲. قوانین باید ساده، دسته‌بندی‌شده و اولویت‌دار باشند.**
بدون این، جستجوی وقت کند می‌شود و رفتار سیستم غیرقابل پیش‌بینی.
**۳. جلوگیری از رزرو تکراری کار دیتابیس است، نه کار کد.**
بدون این، دیر یا زود دو نفر یک ساعت را می‌گیرند.
کلینیک زیبایی فقط یکی از کاربردهای این سیستم است. همین موتور بدون تغییر کد، دندانپزشکی، فیزیوتراپی و تصویربرداری را هم پوشش می‌دهد.
+15
View File
@@ -23,6 +23,15 @@ final class EntityContext
public readonly ?int $id,
public readonly ?Clinic $clinic = null,
public readonly ?Doctor $doctor = null,
/**
* محیط از انتخابِ صریح کاربر آمده (clinic_uuid درخواست یا UserActiveContext)
* و نه از fallbackِ نقش.
*
* جداسازیِ سختِ TenantFilter فقط روی محیط انتخاب‌شده اعمال می‌شود: کاربری
* که هنوز محیطی برنگزیده در هیچ محیطی «نیست»، و قفل‌کردنش روی حدسِ نقش،
* دادهٔ کلینیکی‌اش را که قانوناً حقش است پنهان می‌کند.
*/
public readonly bool $chosen = false,
) {}
public static function forDoctor(?Doctor $doctor): self
@@ -30,6 +39,12 @@ final class EntityContext
return new self(self::TYPE_DOCTOR, $doctor?->getId(), null, $doctor);
}
/** همان محیط، با علامتِ «کاربر خودش انتخابش کرده». */
public function asChosen(): self
{
return new self($this->type, $this->id, $this->clinic, $this->doctor, true);
}
public static function forClinic(Clinic $clinic): self
{
return new self(self::TYPE_CLINIC, $clinic->getId(), $clinic);
+3 -3
View File
@@ -49,7 +49,7 @@ class EntityContextResolver
}
$this->assertCanActInClinic($user, $clinic);
return EntityContext::forClinic($clinic);
return EntityContext::forClinic($clinic)->asChosen();
}
$fromActive = $this->fromActiveContext($user);
@@ -107,14 +107,14 @@ class EntityContextResolver
$clinic = $this->clinicRepo->findByUuid($active->getDbUuid());
return $clinic !== null && $this->canActInClinic($user, $clinic)
? EntityContext::forClinic($clinic)
? EntityContext::forClinic($clinic)->asChosen()
: null;
}
$doctor = $this->doctorRepo->findByUuid($active->getDbUuid());
return $doctor !== null && $this->canActForDoctor($user, $doctor)
? EntityContext::forDoctor($doctor)
? EntityContext::forDoctor($doctor)->asChosen()
: null;
}
@@ -4,6 +4,7 @@ namespace App\Shared\EventSubscriber;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use Doctrine\ORM\EntityNotFoundException;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -67,6 +68,21 @@ class ExceptionSubscriber implements EventSubscriberInterface
return;
}
// با فیلتر محیط روشن، مقداردهیِ یک proxy به موجودیتی که بیرون از محیط جاری
// است این استثنا را می‌دهد — نه ۵۰۰. «بیرون از محیط تو» یعنی «برای تو وجود
// ندارد». همچنان لاگ می‌شود تا FK واقعاً شکسته هم دیده شود.
if ($exception instanceof EntityNotFoundException) {
$this->logger->info('Entity outside the active tenant or missing', [
'message' => $exception->getMessage(),
'path' => $event->getRequest()->getPathInfo(),
]);
$event->setResponse(new JsonResponse(
['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_NOT_FOUND_001', 'message' => 'منبع درخواستی یافت نشد']]],
404
));
return;
}
if ($exception instanceof AccessDeniedHttpException) {
$event->setResponse(new JsonResponse(
['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_FORBIDDEN_001', 'message' => 'دسترسی به این منبع مجاز نیست']]],
+131
View File
@@ -0,0 +1,131 @@
<?php
namespace App\Shared\Tenant;
/**
* طبقه‌بندی هر entity نسبت به جداسازی محیط. هر کلاس باید دقیقاً در یکی از این
* چهار وضعیت باشد، وگرنه TenantSchemaCoverageTest قرمز می‌شود:
*
* ۱. خودش جفت (entity_type, entity_id) دارد → TenantFilter پوششش می‌دهد
* ۲. {@see self::ENTITIES} → عمداً سراسری است
* ۳. {@see self::AGGREGATE_CHILDREN} → محیط را از ریشه به ارث می‌برد
* ۴. {@see self::DEFERRED} → هنوز طبقه‌بندی نشده، بدهی ثبت‌شده
*
* این فهرست تنها راه فرار از پوشش tenant است؛ افزودن به آن باید دلیل داشته باشد.
*/
final class GlobalTables
{
/**
* Entityهایی که به هیچ محیطی تعلق ندارند.
*
* @var array<class-string, string> کلاس => دلیل
*/
public const ENTITIES = [
// دادهٔ مرجع مشترک بین همهٔ محیط‌ها
\App\Location\Entity\Province::class => 'تقسیمات کشوری',
\App\Location\Entity\City::class => 'تقسیمات کشوری',
\App\Specialty\Entity\Specialty::class => 'تاکسونومی سراسری تخصص‌ها',
\App\DoctorService\Entity\DoctorService::class => 'تاکسونومی سراسری خدمات، وابسته به تخصص نه به محیط',
\App\Insurance\Entity\Insurance::class => 'فهرست بیمه‌های کشور',
\App\Insurance\Entity\InsuranceCoverageDefault::class => 'پیش‌فرض پوشش بیمه در سطح کشور؛ هر محیط با TenantInsurance بازنویسی‌اش می‌کند',
\App\Tag\Entity\Tag::class => 'تاکسونومی سراسری برچسب — قرینهٔ per-tenant آن TenantTag است',
\App\Config\Entity\SiteConfig::class => 'تنظیمات کل سامانه',
\App\Config\Entity\TaxRateHistory::class => 'نرخ مالیات کشور',
\App\Subscription\Entity\SubscriptionPlan::class => 'پلن‌های فروش، مشترک بین همهٔ مشتریان',
\App\Subscription\Entity\SubscriptionPeriod::class => 'دوره‌های قیمتی همان پلن‌ها',
\App\Sms\Entity\SmsTemplate::class => 'قالب پیامک سامانه',
\App\Sms\Entity\SmsMessageTemplate::class => 'متن پیامک سامانه',
\App\Sms\Entity\SmsLog::class => 'لاگ ارسال؛ فقط شماره و قالب دارد، مالک ندارد',
\App\Shared\Logging\AppLog::class => 'لاگ سراسری برنامه',
\App\Blog\Entity\Blog::class => 'محتوای عمومی مارکت‌پلیس',
// هویت — یک شخص می‌تواند در چند محیط حضور داشته باشد
\App\Auth\Entity\User::class => 'هویت سراسری؛ رابطهٔ بیمار با محیط از patient_records می‌آید',
\App\UserProfile\Entity\UserProfile::class => 'پروفایل شخص، نه دادهٔ محیط',
\App\Auth\Entity\PreRegistration::class => 'پیش‌ثبت‌نام، هنوز به هیچ محیطی وصل نیست',
\App\Auth\Entity\UserActiveContext::class => 'خودش تعیین‌کنندهٔ محیط است؛ فیلتر کردنش حلقه می‌سازد',
// خودِ محیط‌ها
\App\Doctor\Entity\Doctor::class => 'خودش یک محیط است',
\App\Clinic\Entity\Clinic::class => 'خودش یک محیط است',
// دادهٔ عمومی مارکت‌پلیس دربارهٔ پزشک — بیمار می‌نویسد، نه محیط
\App\Rating\Entity\Comment::class => 'نظر عمومی بیمار روی پروفایل پزشک',
\App\Rating\Entity\Like::class => 'لایک عمومی روی همان نظرها',
\App\Rating\Entity\Rate::class => 'امتیاز عمومی بیمار به پزشک',
\App\Representation\Entity\Representation::class => 'نمایندهٔ فروش؛ بالادستِ محیط‌هاست نه داخل یکی',
// رابطهٔ بین دو محیط — فیلتر کردن با یک طرف، طرف دیگر را کور می‌کند
\App\Clinic\Entity\ClinicDoctorPermission::class => 'مجوز پزشکِ عضو در یک کلینیک؛ هویتش خودِ جفت (کلینیک، پزشک) است',
\App\ClinicInvitation\Entity\ClinicDoctorInvitation::class => 'دعوت کلینیک از پزشک؛ پیش از عضویت هر دو طرف باید ببینندش',
\App\Doctor\Entity\DoctorClaimRequest::class => 'درخواست تصاحب پروفایل پزشک؛ متقاضی هنوز صاحب محیط نیست',
// دادهٔ خودِ پزشک، مستقل از اینکه در کدام کلینیک کار می‌کند
\App\Doctor\Entity\DoctorAddress::class => 'آدرس‌های پزشک؛ در همهٔ محیط‌های او یکسان است',
\App\Insurance\Entity\DoctorInsurance::class => 'بیمه‌های طرف قرارداد خودِ پزشک',
// استثنای مستندشده در فاز ۲
\App\Appointment\Entity\Holiday::class => 'clinic=NULL یعنی «همهٔ محیط‌ها»، نه «مطب شخصی» — جفت tenant این را نمی‌تواند بیان کند',
];
/**
* فرزندان aggregate: ستون tenant ندارند و محیط را از ریشه به ارث می‌برند.
* ریشه صریح اعلام می‌شود چون بعضی‌شان با FK اسکالر وصل‌اند (نه رابطهٔ Doctrine)
* و از metadata قابل استنتاج نیستند.
*
* ⚠️ TenantFilter روی این‌ها اعمال نمی‌شود. کوئری مستقیم روی این جدول‌ها بدون
* JOIN به ریشه، cross-tenant است — همیشه از ریشه شروع کن.
*
* @var array<class-string, class-string> فرزند => ریشه
*/
public const AGGREGATE_CHILDREN = [
\App\Appointment\Entity\AppointmentEvent::class => \App\Appointment\Entity\Appointment::class,
\App\Patient\Entity\PatientAttachment::class => \App\Patient\Entity\PatientRecord::class,
\App\Patient\Entity\PatientCall::class => \App\Patient\Entity\PatientRecord::class,
\App\Patient\Entity\PatientMedicalRecord::class => \App\Patient\Entity\PatientRecord::class,
\App\Patient\Entity\PatientMessage::class => \App\Patient\Entity\PatientRecord::class,
\App\Patient\Entity\PatientNote::class => \App\Patient\Entity\PatientRecord::class,
\App\Patient\Entity\PatientSession::class => \App\Patient\Entity\PatientRecord::class,
\App\Patient\Entity\SessionAuditLog::class => \App\Patient\Entity\PatientSession::class,
\App\Patient\Entity\SessionConsumable::class => \App\Patient\Entity\PatientSession::class,
\App\Patient\Entity\SessionPayment::class => \App\Patient\Entity\PatientSession::class,
\App\Patient\Entity\SessionService::class => \App\Patient\Entity\PatientSession::class,
\App\ClinicService\Entity\ServiceItem::class => \App\ClinicService\Entity\ServiceSection::class,
\App\ClinicService\Entity\ServiceItemAuditLog::class => \App\ClinicService\Entity\ServiceItem::class,
\App\ClinicService\Entity\ServiceItemConsumable::class => \App\ClinicService\Entity\ServiceItem::class,
\App\ClinicService\Entity\Tariff::class => \App\ClinicService\Entity\ServiceItem::class,
\App\Billing\Entity\ClaimItem::class => \App\Billing\Entity\Claim::class,
\App\Billing\Entity\ClaimStatusLog::class => \App\Billing\Entity\Claim::class,
\App\Billing\Entity\InvoiceItem::class => \App\Billing\Entity\Invoice::class,
\App\Inventory\Entity\InventoryPackageItem::class => \App\Inventory\Entity\InventoryPackage::class,
\App\Insurance\Entity\TenantInsuranceCategoryCoverage::class => \App\Insurance\Entity\TenantInsurance::class,
\App\Insurance\Entity\TenantServiceCoverage::class => \App\Insurance\Entity\TenantInsurance::class,
\App\Sms\Entity\SmsWalletTransaction::class => \App\Sms\Entity\SmsWallet::class,
];
/**
* بدهی ثبت‌شده: مالکیتشان دوگانه است (پرداخت‌کننده در برابر دریافت‌کننده) و
* تصمیم درباره‌شان تحلیل جدا می‌خواهد. migration اشتباه روی دادهٔ مالی برگشت‌پذیر
* نیست، پس عمداً در این فاز دست نخوردند.
*
* این فهرست باید کوچک شود، نه بزرگ.
*
* @var array<class-string, string>
*/
public const DEFERRED = [
\App\Payment\Entity\Payment::class => 'پرداخت بین بیمار و محیط؛ هر دو طرف باید ببینندش',
\App\Payment\Entity\PaymentLog::class => 'فرزند Payment؛ با همان تصمیم می‌رود',
\App\Settlement\Entity\Settlement::class => 'تسویهٔ سامانه با صاحب محیط',
\App\Settlement\Entity\FinancialBreakdown::class => 'تفکیک سهم‌ها بین چند طرف یک پرداخت',
\App\Settlement\Entity\WalletTransaction::class => 'کیف پول کاربر، نه محیط',
\App\Secretary\Entity\SecretaryEarning::class => 'سهم منشی از یک پرداخت',
\App\PaymentMethod\Entity\BankAccount::class => 'حساب بانکی روی User ثبت شده، نه روی محیط',
\App\PaymentMethod\Entity\Pos::class => 'دستگاه کارتخوان روی User ثبت شده، نه روی محیط',
];
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Shared\Tenant;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query\Filter\SQLFilter;
/**
* جداسازی خودکار محیط: به هر کوئری DQL روی entityهای tenant-دار شرط
* (entity_type, entity_id) اضافه می‌شود.
*
* پیش‌فرض خاموش است و فقط وقتی روشن می‌شود که محیط کاربر حل شده باشد
* ({@see TenantFilterSubscriber}). مسیرهای عمومی مارکت‌پلیس و ادمین سراسری
* عمداً بیرون از آن می‌مانند.
*
* ⚠️ تور ایمنی است، نه جایگزین authorization:
*
* | مسیر | اعمال می‌شود؟ |
* |----------------------------------------|---------------|
* | DQL و QueryBuilder | ✅ |
* | findBy / findOneBy | ✅ |
* | EntityManager::find() با کلید اصلی | ✅ (در ORM ۳؛ تثبیت‌شده در TenantFilterLeakTest) |
* | بارگذاری تنبل کالکشن‌ها | ✅ |
* | entity که از قبل در identity map است | ❌ — دوباره کوئری نمی‌شود |
* | getReference() | ❌ |
* | SQL خام DBAL | ❌ |
* | فرزندان aggregate (بدون ستون tenant) | ❌ — از ریشه JOIN کن |
*
* مقداردهیِ proxy به موجودیتی که فیلتر کنارش گذاشته، EntityNotFoundException
* می‌دهد؛ ExceptionSubscriber آن را به ۴۰۴ نگاشت می‌کند («بیرون از محیط تو» یعنی
* «برای تو وجود ندارد»).
*
* پس AppointmentAccessChecker، ClinicDoctorAccessChecker، SecretaryAccessChecker
* و PatientRecordScopeResolver سر جایشان می‌مانند: آن‌ها «چه کاری مجاز است» را
* جواب می‌دهند، این فیلتر فقط «کدام ردیف‌ها».
*/
final class TenantFilter extends SQLFilter
{
public const NAME = 'tenant';
public const PARAM_TYPE = 'tenant_entity_type';
public const PARAM_ID = 'tenant_entity_id';
public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias): string
{
if (!$targetEntity->hasField('entityType') || !$targetEntity->hasField('entityId')) {
return '';
}
return sprintf(
'%s.%s = %s AND %s.%s = %s',
$targetTableAlias,
$targetEntity->getColumnName('entityType'),
$this->getParameter(self::PARAM_TYPE),
$targetTableAlias,
$targetEntity->getColumnName('entityId'),
$this->getParameter(self::PARAM_ID),
);
}
}
@@ -0,0 +1,62 @@
<?php
namespace App\Shared\Tenant;
use App\Auth\Entity\User;
use App\Shared\Context\EntityContextResolver;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* فیلتر محیط را در ابتدای هر درخواست روشن می‌کند — و فقط وقتی که واقعاً محیطی
* حل شود.
*
* دو مسیر عمداً بیرون می‌مانند:
* • مسیرهای عمومی مارکت‌پلیس (nobat724_front) کاربر پنل ندارند، پس محیطی حل
* نمی‌شود و جستجوی چند-کلینیکی دست‌نخورده کار می‌کند.
* • ادمین ذاتاً سراسری است؛ فیلتر کردنش پنل مدیریت را کور می‌کند.
*/
final class TenantFilterSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly Security $security,
private readonly EntityContextResolver $contextResolver,
private readonly EntityManagerInterface $em,
) {}
public static function getSubscribedEvents(): array
{
// بعد از فایروال (اولویت ۸) تا توکن در دسترس باشد.
return [KernelEvents::REQUEST => ['onRequest', 5]];
}
public function onRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$user = $this->security->getUser();
if (!$user instanceof User || $user->hasRole('ROLE_ADMIN')) {
return;
}
// فقط محیطِ انتخاب‌شده. کاربری که هنوز محیطی برنگزیده در هیچ محیطی «نیست»؛
// قفل‌کردنش روی حدسِ نقش، دادهٔ کلینیکی‌ای را که قانوناً حقش است پنهان می‌کند
// و دسترسی را همان checkerهای دامنه تعیین می‌کنند.
$context = $this->contextResolver->tryResolve($user);
if ($context === null || !$context->isResolved() || !$context->chosen) {
return;
}
[$type, $id] = $context->toEntityPair();
$this->em->getFilters()
->enable(TenantFilter::NAME)
->setParameter(TenantFilter::PARAM_TYPE, $type, 'string')
->setParameter(TenantFilter::PARAM_ID, $id, 'integer');
}
}
@@ -39,8 +39,10 @@ class SecretaryAppointmentScopeTest extends ApiTestCase
// یک نوبت برای هر پزشک
$patient = $this->createUser(['ROLE_USER']);
$start = time() + 3600;
$this->em->persist($this->newAppointment($doctorA, $patient, $start, $start + 900));
$this->em->persist($this->newAppointment($doctorB, $patient, $start + 1800, $start + 2700));
// نوبت‌ها در همین کلینیک ثبت می‌شوند، نه در مطب شخصی — وگرنه محیطشان با
// محیط فعالِ منشی یکی نیست و اصلاً نباید در این لیست بیایند.
$this->em->persist($this->newAppointment($doctorA, $patient, $start, $start + 900, $clinic));
$this->em->persist($this->newAppointment($doctorB, $patient, $start + 1800, $start + 2700, $clinic));
$this->em->flush();
$body = $this->authJson('GET', '/api/v1/my/appointments', $secretaryUser);
+5 -2
View File
@@ -30,8 +30,11 @@ class NumericFieldNormalizerTest extends ApiTestCase
$this->em->persist($doctor);
$this->em->flush();
$persianMobile = '۰۹' . str_pad((string) random_int(0, 999_999_999), 9, '۰', STR_PAD_LEFT);
$latinMobile = PersianText::digits($persianMobile);
// db_test پاک نمی‌شود؛ شماره باید تازه باشد وگرنه endpoint با «تکراری» رد می‌کند.
do {
$persianMobile = '۰۹' . str_pad((string) random_int(0, 999_999_999), 9, '۰', STR_PAD_LEFT);
$latinMobile = PersianText::digits($persianMobile);
} while ($this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $latinMobile]) !== null);
$this->authJson('POST', '/api/v1/secretary', $owner, [
'doctor_uuid' => $doctor->getUuid(),
+211
View File
@@ -0,0 +1,211 @@
<?php
namespace App\Tests\Shared;
use App\Auth\Entity\User;
use App\Auth\Entity\UserActiveContext;
use App\Clinic\Entity\Clinic;
use App\Clinic\Entity\ClinicDoctorPermission;
use App\Discount\Entity\DiscountRule;
use App\Doctor\Entity\Doctor;
use App\Shared\Context\EntityContext;
use App\Shared\Tenant\TenantFilter;
use App\Tests\ApiTestCase;
use Doctrine\ORM\EntityManagerInterface;
/**
* فیلتر باید **به‌تنهایی** جلوی cross-tenant را بگیرد — یعنی حتی کوئری‌ای که هیچ
* شرط دستی روی محیط ندارد. بقیهٔ تست‌ها رفتار endpointها را می‌سنجند؛ اینجا خودِ
* تور ایمنی سنجیده می‌شود.
*/
class TenantFilterLeakTest extends ApiTestCase
{
private function em(): EntityManagerInterface
{
return static::getContainer()->get(EntityManagerInterface::class);
}
private function makeDoctor(?User $user = null): Doctor
{
$doctor = new Doctor($user ?? $this->createUser(['ROLE_DOCTOR']), 'دکتر نشتی');
$this->em->persist($doctor);
$this->em->flush();
return $doctor;
}
private function makeClinic(?User $owner = null): Clinic
{
$clinic = new Clinic($owner ?? $this->createUser(['ROLE_CLINIC']));
$clinic->setName('کلینیک نشتی');
$this->em->persist($clinic);
$this->em->flush();
return $clinic;
}
private function rule(string $type, int $id, string $name): DiscountRule
{
$rule = new DiscountRule($type, $id, $name, DiscountRule::TYPE_INVOICE_AMOUNT);
$this->em->persist($rule);
$this->em->flush();
return $rule;
}
private function enableFilterFor(string $type, int $id): void
{
$this->em()->getFilters()
->enable(TenantFilter::NAME)
->setParameter(TenantFilter::PARAM_TYPE, $type, 'string')
->setParameter(TenantFilter::PARAM_ID, $id, 'integer');
}
protected function tearDown(): void
{
$filters = $this->em()->getFilters();
if ($filters->isEnabled(TenantFilter::NAME)) {
$filters->disable(TenantFilter::NAME);
}
parent::tearDown();
}
/** ✅ کوئری بدون هیچ WHERE دستی، فقط ردیف‌های محیط جاری را می‌دهد. */
public function testFilterAloneBlocksCrossTenantRowsWithoutAnyManualCondition(): void
{
$mine = $this->makeDoctor();
$other = $this->makeDoctor();
$this->rule('doctor', $mine->getId(), 'قانون من');
$this->rule('doctor', $other->getId(), 'قانون دیگری');
$this->em->clear();
$this->enableFilterFor('doctor', $mine->getId());
$names = array_map(
static fn(DiscountRule $r) => $r->getName(),
$this->em()->createQuery('SELECT r FROM ' . DiscountRule::class . ' r')->getResult(),
);
self::assertContains('قانون من', $names);
self::assertNotContains('قانون دیگری', $names);
}
/** findOneBy هم فیلتر می‌خورد — رکورد محیط دیگر «وجود ندارد». */
public function testFindOneByCannotReachAnotherTenantsRow(): void
{
$mine = $this->makeDoctor();
$other = $this->makeDoctor();
$foreign = $this->rule('doctor', $other->getId(), 'قانون بیگانه');
$this->em->clear();
$this->enableFilterFor('doctor', $mine->getId());
self::assertNull(
$this->em()->getRepository(DiscountRule::class)->findOneBy(['uuid' => $foreign->getUuid()]),
);
}
/**
* find() با کلید اصلی هم فیلتر می‌خورد — در Doctrine ORM 3 برخلاف نسخه‌های قدیمی
* که این مسیر را استثنا می‌کردند. تضمین قوی‌تری است، ولی چون رفتارِ کتابخانه است
* نه قرارداد ما، اینجا تثبیتش می‌کنیم: اگر روزی برگردد، این تست خبر می‌دهد و سند
* tenancy باید به‌روز شود.
*/
public function testFindByPrimaryKeyIsAlsoFiltered(): void
{
$mine = $this->makeDoctor();
$other = $this->makeDoctor();
$foreign = $this->rule('doctor', $other->getId(), 'قانون بیگانه');
$id = $foreign->getId();
$this->em->clear();
$this->enableFilterFor('doctor', $mine->getId());
self::assertNull($this->em()->find(DiscountRule::class, $id));
}
/**
* ⚠️ محدودیت واقعی: entity که از قبل در identity map است دوباره کوئری نمی‌شود،
* پس فیلتر آن را نمی‌بیند. به همین دلیل فیلتر جایگزین authorization نیست.
*/
public function testAnEntityAlreadyInMemoryIsNotHiddenByTheFilter(): void
{
$mine = $this->makeDoctor();
$other = $this->makeDoctor();
$foreign = $this->rule('doctor', $other->getId(), 'قانون بیگانه');
$id = $foreign->getId();
$this->enableFilterFor('doctor', $mine->getId());
self::assertNotNull($this->em()->find(DiscountRule::class, $id), 'از identity map برمی‌گردد، نه از دیتابیس');
}
/** ⚠️ مسیر عمومی مارکت‌پلیس کاربر پنل ندارد؛ فیلتر نباید سایت را خالی کند. */
public function testPublicDoctorSearchStaysCrossTenant(): void
{
$this->makeDoctor();
$this->makeDoctor();
$this->client->request('GET', '/api/v1/doctors?limit=5');
self::assertSame(200, $this->client->getResponse()->getStatusCode());
}
/** ادمین سراسری است و فیلتر نمی‌خورد. */
public function testAdminIsNotFiltered(): void
{
$admin = $this->createUser(['ROLE_ADMIN']);
$doctor = $this->makeDoctor();
$clinic = $this->makeClinic();
$this->rule('doctor', $doctor->getId(), 'قانون پزشک');
$this->rule('clinic', $clinic->getId(), 'قانون کلینیک');
$this->authJson('GET', '/api/v1/admin/discount-rules', $admin);
self::assertNotSame(500, $this->responseCode(), 'ادمین نباید با فیلتر بشکند');
}
/**
* ⚠️ کاربری که هنوز محیطی انتخاب نکرده فیلتر نمی‌خورد — در هیچ محیطی «نیست» و
* دسترسی‌اش را همان checkerهای دامنه تعیین می‌کنند.
*/
public function testUserWithoutAChosenContextIsNotFiltered(): void
{
$doctor = $this->makeDoctor();
$clinic = $this->makeClinic();
$clinic->getDoctors()->add($doctor);
$this->em->persist(new ClinicDoctorPermission($clinic, $doctor));
$this->em->flush();
$resolver = static::getContainer()->get(\App\Shared\Context\EntityContextResolver::class);
self::assertFalse(
$resolver->resolve($doctor->getUser())->chosen,
'محیطِ برآمده از نقش، انتخاب کاربر نیست',
);
}
/** محیطی که کاربر صریحاً انتخاب کرده، «انتخاب‌شده» علامت می‌خورد. */
public function testStoredActiveContextCountsAsChosen(): void
{
$doctor = $this->makeDoctor();
$clinic = $this->makeClinic();
$clinic->getDoctors()->add($doctor);
$this->em->persist(new ClinicDoctorPermission($clinic, $doctor));
$this->em->persist(new UserActiveContext(
$doctor->getUser(),
$clinic->getUuid(),
EntityContext::TYPE_CLINIC,
));
$this->em->flush();
$resolver = static::getContainer()->get(\App\Shared\Context\EntityContextResolver::class);
$context = $resolver->resolve($doctor->getUser());
self::assertTrue($context->chosen);
self::assertSame(['clinic', $clinic->getId()], $context->toEntityPair());
}
}
+140
View File
@@ -0,0 +1,140 @@
<?php
namespace App\Tests\Shared;
use App\Shared\Tenant\GlobalTables;
use App\Tests\ApiTestCase;
use Doctrine\ORM\EntityManagerInterface;
/**
* ضد رگرسیون برای فازهای بعدی: entity جدیدی که فردا اضافه شود و کسی محیطش را
* تعیین نکند، اینجا قرمز می‌شود — نه ماه‌ها بعد با یک نشت داده.
*
* شکست این تست یعنی «این کلاس طبقه‌بندی نشده»، نه «تست خراب است».
*/
class TenantSchemaCoverageTest extends ApiTestCase
{
/** @return class-string[] */
private function allEntityClasses(): array
{
$em = static::getContainer()->get(EntityManagerInterface::class);
return array_map(
static fn($meta) => $meta->getName(),
$em->getMetadataFactory()->getAllMetadata(),
);
}
private function isTenantOwned(string $class): bool
{
$em = static::getContainer()->get(EntityManagerInterface::class);
return $em->getClassMetadata($class)->hasField('entityType');
}
/**
* هر entity دقیقاً یکی از چهار وضعیت را دارد: جفت tenant، سراسری، فرزند
* aggregate، یا بدهی ثبت‌شده.
*/
public function testEveryEntityIsClassified(): void
{
$unclassified = array_values(array_filter(
$this->allEntityClasses(),
fn(string $class) => !$this->isTenantOwned($class)
&& !isset(GlobalTables::ENTITIES[$class])
&& !isset(GlobalTables::AGGREGATE_CHILDREN[$class])
&& !isset(GlobalTables::DEFERRED[$class]),
));
self::assertSame([], $unclassified, sprintf(
"این entityها نه جفت tenant دارند و نه در GlobalTables ثبت شده‌اند:\n%s",
implode("\n", $unclassified),
));
}
/** هیچ کلاسی نباید هم‌زمان در دو دستهٔ GlobalTables باشد. */
public function testClassificationsDoNotOverlap(): void
{
$buckets = [
'ENTITIES' => array_keys(GlobalTables::ENTITIES),
'AGGREGATE_CHILDREN' => array_keys(GlobalTables::AGGREGATE_CHILDREN),
'DEFERRED' => array_keys(GlobalTables::DEFERRED),
];
foreach ($buckets as $name => $classes) {
foreach ($buckets as $otherName => $otherClasses) {
if ($name === $otherName) {
continue;
}
self::assertSame(
[],
array_values(array_intersect($classes, $otherClasses)),
"{$name} و {$otherName} کلاس مشترک دارند",
);
}
}
}
/**
* هر فرزند aggregate باید زنجیره‌ای به یک ریشهٔ tenant-دار داشته باشد. بدون این،
* ثبت‌کردنش در whitelist فقط تست را ساکت می‌کند بی‌آنکه محیطی وجود داشته باشد.
*/
public function testEveryAggregateChildReachesATenantOwnedRoot(): void
{
foreach (array_keys(GlobalTables::AGGREGATE_CHILDREN) as $child) {
$chain = [$child];
$current = $child;
while (isset(GlobalTables::AGGREGATE_CHILDREN[$current])) {
$current = GlobalTables::AGGREGATE_CHILDREN[$current];
self::assertNotContains($current, $chain, 'زنجیرهٔ aggregate حلقه دارد: ' . implode(' → ', $chain));
$chain[] = $current;
}
self::assertTrue(
$this->isTenantOwned($current),
sprintf('ریشهٔ %s باید جفت tenant داشته باشد: %s', $child, implode(' → ', $chain)),
);
}
}
/** فرزندِ خودش نباید جفت tenant داشته باشد — وگرنه دو منبع حقیقت می‌شود. */
public function testAggregateChildrenDoNotCarryTheirOwnTenant(): void
{
foreach (array_keys(GlobalTables::AGGREGATE_CHILDREN) as $child) {
self::assertFalse(
$this->isTenantOwned($child),
"{$child} هم جفت tenant دارد هم فرزند aggregate ثبت شده — یکی را بردار",
);
}
}
/** هر ثبت باید کلاس موجود و دلیل ناتهی داشته باشد. */
public function testEveryClassificationIsRealAndJustified(): void
{
foreach ([GlobalTables::ENTITIES, GlobalTables::DEFERRED] as $bucket) {
foreach ($bucket as $class => $reason) {
self::assertTrue(class_exists($class), "کلاس ثبت‌شده وجود ندارد: {$class}");
self::assertNotSame('', trim($reason), "دلیل {$class} خالی است");
}
}
foreach (GlobalTables::AGGREGATE_CHILDREN as $child => $root) {
self::assertTrue(class_exists($child), "کلاس ثبت‌شده وجود ندارد: {$child}");
self::assertTrue(class_exists($root), "ریشهٔ ثبت‌شده وجود ندارد: {$root}");
}
}
/**
* بدهی باید کوچک شود نه بزرگ. اگر کلاسی به DEFERRED اضافه شد، این عدد هم باید
* عمداً بالا برود — یعنی تصمیم دیده می‌شود، نه اینکه بی‌صدا بگذرد.
*/
public function testDeferredDebtDoesNotGrow(): void
{
self::assertLessThanOrEqual(
8,
count(GlobalTables::DEFERRED),
'جدول‌های مالی طبقه‌بندی‌نشده بیشتر شدند؛ فهرست DEFERRED باید کوچک شود',
);
}
}