# فاز ۷ — بستن نقطهٔ کور فرزندان aggregate > ادامهٔ سری tenant. **پیش‌نیاز: فاز ۴ (فیلتر) کامل.** مستقل از فاز ۶ است و ارزان‌تر — می‌تواند اول اجرا شود. ## زمینه `TenantFilter` فقط روی entityهایی کار می‌کند که خودشان جفت `(entity_type, entity_id)` دارند. حدود ۲۵ فرزند aggregate ستون محیط ندارند و از ریشه به ارث می‌برند: ``` patient_notes · patient_attachments · patient_calls · patient_messages patient_medical_records · patient_sessions · session_* (۴ جدول) service_items · service_item_audit_logs · service_item_consumables · service_tariffs claim_items · claim_status_logs · invoice_items · inventory_package_items appointment_events · sms_wallet_transactions · tenant_service_coverage · … ``` `TenantSchemaCoverageTest::testEveryAggregateChildReachesATenantOwnedRoot` تضمین می‌کند زنجیرهٔ **اعلام‌شده** به ریشه‌ای tenant-دار می‌رسد. اما تضمین نمی‌کند کوئری‌های واقعی از ریشه شروع شوند. سند این را صریح ثبت کرده: > ⚠️ فیلتر روی آن‌ها اعمال نمی‌شود. کوئری مستقیم روی `patient_attachments` بدون JOIN به `patient_records`، cross-tenant است. ## مشکل / هدف **مشکل:** تست پوشش سبز است ولی نقطهٔ کور باز — «اطمینان کاذب» که در تحلیل فاز ۴ هم به آن اشاره شد. یک repository جدید که مستقیم روی جدول فرزند کوئری بزند، بی‌صدا cross-tenant می‌شود و هیچ تستی خبر نمی‌دهد. **هدف:** هر کوئری‌ای که از یک جدول فرزند شروع می‌شود، یا به ریشه JOIN بزند یا صریح به‌عنوان استثنا ثبت شود. ⚠️ **این فاز تور ایمنی رانتایم اضافه نمی‌کند** — گارد زمانِ تست است. تور ایمنی واقعی یعنی ستون tenant روی هر ۲۵ جدول، که ۲۵ migration با backfill می‌خواهد و فقط اگر اینجا نشتی واقعی پیدا شد ارزشش را دارد. تصمیم گرفته‌شده: **اول گارد، بعد در صورت لزوم ستون.** ## معیار پذیرش - ✅ موفق: تستی وجود دارد که همهٔ repositoryهای فرزند aggregate را می‌گردد و ثابت می‌کند هر `createQueryBuilder`/DQL روی آن‌ها به ریشه JOIN می‌زند؛ روی کد فعلی سبز است - ❌ خطا: اگر کوئری‌ای بدون JOIN به ریشه پیدا شد، تست با **نام کلاس و متد** قرمز شود — نه پیام کلی - ⚠️ مرزی: کوئری‌ای که عمداً بدون JOIN است (مثلاً شمارش سراسری در کامند ادمین) باید راه ثبت استثنا داشته باشد، وگرنه تیم تست را خاموش می‌کند ## فایل‌های مرتبط | فایل | نقش | |------|-----| | `src/Shared/Tenant/GlobalTables.php` | فهرست `AGGREGATE_CHILDREN` — ورودی این تست | | `src/Patient/Repository/*` | بیشترین تعداد فرزند | | `src/Billing/Repository/*` · `src/ClinicService/Repository/*` · `src/Inventory/Repository/*` | بقیه | | `tests/Shared/TenantSchemaCoverageTest.php` | تست همسایه؛ سبک را از آن بگیر | | `docs/architecture/tenancy.md` | بخش «فرزندان aggregate تور ایمنی ندارند» باید به‌روز شود | ## وضعیت فعلی ```php // src/Shared/Tenant/GlobalTables.php /** * ⚠️ TenantFilter روی این‌ها اعمال نمی‌شود. کوئری مستقیم روی این جدول‌ها بدون * JOIN به ریشه، cross-tenant است — همیشه از ریشه شروع کن. * * @var array فرزند => ریشه */ public const AGGREGATE_CHILDREN = [ \App\Patient\Entity\PatientAttachment::class => \App\Patient\Entity\PatientRecord::class, // … ۲۴ ردیف دیگر ]; ``` این هشدار فقط کامنت است؛ چیزی اجرایش نمی‌کند. ## وظایف ### ۱. سنجش وضع موجود — اول اندازه بگیر قبل از نوشتن گارد، بفهم چند کوئری واقعاً از جدول فرزند شروع می‌شوند: ```bash ddev exec grep -rln "AGGREGATE_CHILDREN\|PatientNote\|SessionPayment\|ClaimItem" src/*/Repository --include="*.php" ``` برای هر repositoryِ یک کلاس فرزند، `createQueryBuilder`/`findBy`/`findOneBy` را فهرست کن و دستی تعیین کن کدام از ریشه شروع می‌شود. **اگر خروجی صفر بود** — یعنی هیچ کوئری‌ای مستقیم روی فرزندان نیست — این فاز به یک تستِ ساده تقلیل می‌یابد و باید همان را گزارش کنی، نه اینکه گارد پیچیده بسازی. **نحوه تست:** خروجی این وظیفه یک جدول در گزارش است: کلاس، متد، «از ریشه شروع می‌شود؟». --- ### ۲. گارد تست بسته به یافتهٔ وظیفهٔ ۱، یکی از دو شکل: **الف) اگر کوئری مستقیم کم است** — تستی که DQL هر repositoryِ فرزند را می‌گیرد و بررسی می‌کند نام ریشه در آن هست: ```php // tests/Shared/AggregateChildQueryGuardTest.php public function testEveryAggregateChildQueryJoinsItsRoot(): void { // برای هر کلاس در AGGREGATE_CHILDREN، repository متناظر را پیدا کن، // متدهای عمومی‌اش را با Reflection بگرد، و DQL تولیدی را بررسی کن. } ``` **ب) اگر زیاد است** — گارد ایستا: فایل‌های `src/*/Repository/*.php` را بخوان و هر `createQueryBuilder('x')` روی کلاس فرزند را که در همان متد `join`/`innerJoin` به ریشه ندارد گزارش کن. راه (ب) از (الف) شکننده‌تر است ولی نیازی به اجرای کوئری ندارد. **دلیل انتخاب را بنویس.** استثناها در یک ثابت با دلیل، قرینهٔ `GlobalTables`: ```php /** @var array "Class::method" => دلیل */ private const INTENTIONAL_ROOTLESS_QUERIES = []; ``` **نحوه تست:** `ddev exec php bin/phpunit tests/Shared/AggregateChildQueryGuardTest.php` — روی کد فعلی سبز؛ سپس یک کوئری بدون JOIN موقتاً اضافه کن و تأیید کن قرمز می‌شود (و بعد برش دار). --- ### ۳. رفع هر نشتی واقعی اگر وظیفهٔ ۱ کوئری بدون JOIN پیدا کرد که کاربر غیرادمین به آن می‌رسد، **آن یک نشتی واقعی است**: JOIN به ریشه اضافه کن و یک تست cross-tenant بگیر — دقیقاً همان الگویی که فاز ۵ برای `ClaimRepository` استفاده کرد (`ClaimsByPatientTest::testAnotherTenantsClaimsNeverAppearInTheDashboard`). **نحوه تست:** تست جدید: کاربر محیط A نباید ردیف فرزندِ محیط B را ببیند. --- ### ۴. سند بخش «فرزندان aggregate تور ایمنی ندارند» در [tenancy.md](../../docs/architecture/tenancy.md) به‌روز شود: چه چیزی حالا اجبار می‌شود (زمان تست) و چه چیزی همچنان نه (رانتایم). ## نکات مهم - **این فاز ممکن است به «هیچ کاری لازم نیست» ختم شود.** آدیت فاز ۵ هیچ کوئری بدون JOIN پیدا نکرد. اگر وظیفهٔ ۱ هم چیزی پیدا نکرد، خروجی درست همان گارد + گزارش است، نه ساختن abstraction برای مسئله‌ای که وجود ندارد. - **گارد نباید شکننده باشد.** تستی که با هر refactor بی‌ربط قرمز شود، خاموش می‌شود و بدتر از نبودنش است. اگر تشخیص مطمئن ممکن نبود، دامنه را کوچک‌تر بگیر (مثلاً فقط جدول‌های بیمار) و همان را قابل اتکا کن. - **بدون migration، بدون تغییر قرارداد API.** - اگر نتیجه گرفتی که گارد تست کافی نیست و ستون tenant لازم است، **آن را پیاده نکن** — پرامپت جدا بنویس و دلیلش را با شواهد وظیفهٔ ۱ مستند کن.