Files
clinicpro/docs/architecture/tenancy.md
T
hamedandClaude Opus 5 c9d4348c46 feat(tenant): mark the financial tables with their owning environment
Phase 6 of the tenant series. GlobalTables::DEFERRED is now empty and the
coverage test asserts it stays that way.

payments carries the (entity_type, entity_id) pair and belongs to the
receiving side, never the payer: an appointment payment takes the
appointment's environment, a subscription takes the environment its buyer
owns, and an SMS wallet top-up takes the wallet's. The patient never chose
an environment, so TenantFilter stays off for them and they still see their
own payment.

Three corrections to the analysis the phase was planned on, each backed by
the code or the data rather than the plan:

- A third payment type exists. Payment::TYPE_SMS_WALLET is created in
  SmsWalletController and already carries its environment in the metadata;
  without assigning it the write would fail at flush.
- clinic_subscriptions has no user_id, and its trial rows carry no payment,
  so it cannot drive the subscription backfill. The environment is derived
  the way handleSubscriptionActivation derives it — and that method now
  reads the pair off the payment instead of re-deriving it, so a payment and
  the subscription it buys can no longer land on different environments.
- WalletTransaction is not a child of Payment. payment_id is nullable and
  none of the four creation sites set it; the wallet is a person's, with a
  running balance per user. It and Settlement, which withdraws from that same
  wallet, are global with a recorded reason instead.

bank_accounts and pos_devices move from the registering user to the
environment. Their pair is deliberately nullable: nothing in the existing
data says which of a multi-environment owner's cards belongs where, and
guessing would point real money at the wrong account. Ambiguous rows stay
unassigned and the migration reports how many. The cost is that such a row
is invisible in every environment, so the owner reaches it through a
user-scoped lookup that runs outside the filter, and assigns it with
PATCH .../{uuid}/environment. The admin panel marks those rows and offers
the assignment.

Tests: 896 backend (+11), 570 frontend (+4). PHPStan unchanged at its 17
pre-existing errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 15:06:28 +03:30

17 KiB
Raw Blame History

جداسازی محیط (tenancy)

هر دادهٔ عملیاتی در ClinicPro به یک محیط تعلق دارد: یا مطب شخصی یک پزشک، یا یک کلینیک. این سند می‌گوید آن محیط چطور تعیین می‌شود، کجا اجبار می‌شود، و — مهم‌تر — کجا اجبار نمی‌شود.


هویت محیط

جفت (entity_type, entity_id) روی خودِ جدول:

ستون مقدار
entity_type doctor یا clinicVARCHAR(10) در هر ۲۰ جدول tenant-دار
entity_id شناسهٔ همان پزشک یا کلینیک

موجودیت‌ها این جفت را از trait مشترک می‌گیرند:

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_notespatient_records
DEFERRED بدهی ثبت‌شده، هنوز طبقه‌بندی نشده خالی — فاز ۶ آخرین موردش را تعیین تکلیف کرد

⚠️ فرزندان aggregate تور ایمنی ندارند

فیلتر روی آن‌ها اعمال نمی‌شود. کوئری مستقیم روی patient_attachments بدون JOIN به patient_records، cross-tenant است. TenantSchemaCoverageTest فقط تضمین می‌کند زنجیرهٔ اعلام‌شده به ریشه‌ای با جفت tenant می‌رسد — نه اینکه کوئری‌ها واقعاً از ریشه شروع می‌شوند.

فرزندی که لازم است مالکیتش سنجیده شود، جفت ارثی‌اش را expose می‌کند؛ ServiceItem این کار را با delegate به ServiceSection انجام می‌دهد.

uuid از درخواست — خطرناک‌ترین الگو

سه نشتی واقعی در آدیت این نقطه پیدا شد و هیچ‌کدام در repository نبودند؛ همه در کنترلر و سرویس بودند، جایی که یک uuid از بدنه یا کوئری می‌آید و کسی محیطش را نمی‌سنجد:

مسیر چه بود
GET /api/v1/appointment-service-slots با uuid سرویسِ محیط دیگر، وجود/فعال‌بودن/مدتش لو می‌رفت و اسلات‌ها با آن محاسبه می‌شد
POST /api/v1/my/appointment بخش/سرویس/پرسنلِ محیط دیگر به نوبت چسبانده و ذخیره می‌شد
POST/PATCH مراجعه قیمتِ سرویسِ محیط دیگر وارد فاکتور می‌شد و SessionService با آن ذخیره می‌ماند

App\Shared\Tenant\TenantOwnershipChecker نقطهٔ واحد این بررسی است:

$this->tenantOwnership->belongsTo($context, $entity);              // با EntityContext
$this->tenantOwnership->belongsToPair($type, $id, $entity);        // وقتی جفت اسکالر است
$this->tenantOwnership->allBelongTo($context, $entities);          // یک بیگانه = رد کل فهرست

موجودیتی که جفتش را expose نکند، استثنا می‌دهد — سکوت اینجا گاردِ همیشه-بسته می‌سازد که خودش باگ است.

TenantLookupInventoryTest تعداد این جست‌وجوها را per-file نگه می‌دارد. افزودن یک findByUuid تازه روی موجودیت محیط‌دار تست را قرمز می‌کند تا کسی ثابت کند محیطش بررسی می‌شود و بعد عدد را به‌روز کند.

جدول‌های مالی

بدهی صفر است. testThereIsNoUnclassifiedDebtLeft خالی‌ماندنش را اجبار می‌کند.

استدلالِ اولیهٔ «مالکیتشان دوگانه است» درست نبود: پرداخت به محیطِ گیرنده تعلق دارد و پرداخت‌کننده مانعی نیست، چون بیمار محیطی انتخاب نکرده و فیلتر برایش خاموش است.

جدول تصمیم چرا
payments جفت محیط نوبت → محیط نوبت · اشتراک → محیطی که خریدار صاحبش است · شارژ پیامک → محیط همان کیف پول
payment_logs · financial_breakdowns فرزند Payment با FK به پرداخت لنگر می‌خورند
secretary_earnings فرزند FinancialBreakdown زنجیره تا payments می‌رسد
wallet_transactions ENTITIES کیف پولِ شخص است: موجودی از مجموع credit−debitِ همان کاربر مشتق می‌شود و payment_id تهی‌پذیر است — تفکیک به محیط، خودِ موجودی را بی‌معنا می‌کند
settlements ENTITIES برداشت از همان کیف پولِ شخصی (SettlementController موجودی را با getWalletBalance(user) می‌سنجد)
bank_accounts · pos_devices جفت محیط، تهی‌پذیر از کاربر به محیط منتقل شدند؛ موارد مبهم تهی ماندند (پایین)

نتیجهٔ عملی برای زنجیره: تضمین فقط تا جایی است که کوئری به payments لنگر بزند. SecretaryEarningRepository::reportFor این کار را با join('b.payment','p') می‌کند و فیلتر روی همان می‌نشیند؛ FinancialChainTenantTest همین را می‌سنجد.

⚠️ نقطهٔ ضعف: کارتِ بی‌محیط در هیچ محیطی دیده نمی‌شود

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

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-محیط‌اند؛ ردیف بی‌محیط دیده می‌شود ولی تا انتساب قابل ویرایش نیست