# فاز ۷ — بستن نقطهٔ کور فرزندان 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<class-string, class-string> فرزند => ریشه
 */
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<string, string> "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 لازم است، **آن را پیاده نکن** — پرامپت جدا بنویس و دلیلش را با شواهد وظیفهٔ ۱ مستند کن.
