Files
clinicpro/docs/architecture/tenancy.md
T
hamedandClaude Opus 5 04d3222559 feat(resource): admin UI for resources, types, skills and pools, plus real API docs
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>
2026-07-30 17:51:38 +03:30

25 KiB
Raw Blame History

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

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


هویت محیط

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

ستون مقدار
entity_type doctor یا clinicVARCHAR(10) در همهٔ جدول‌های tenant-دار (طولِ یکسان، وگرنه JOIN به collation mismatch می‌خورد)
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 تور ایمنی ندارند

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

قاعدهٔ مرزبندی (فاز ۸): فرزندی که uuidش از خودِ درخواست می‌آید، حق ندارد فرزند بماند.

$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

جفت در سازنده از ریشه مشتق می‌شود، نه از ورودی:

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 نقطهٔ واحد این بررسی است:

$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-کیف‌پول:

// 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

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