diff --git a/.claude/prompt/tenant-06-financial-tables.md b/.claude/prompt/tenant-06-financial-tables.md new file mode 100644 index 00000000..e74b6a85 --- /dev/null +++ b/.claude/prompt/tenant-06-financial-tables.md @@ -0,0 +1,252 @@ +# فاز ۶ — نشانه‌گذاری محیط روی جدول‌های مالی + +> ادامهٔ سری tenant. **پیش‌نیاز: فازهای ۱ تا ۵ کامل و کامیت‌شده.** +> فازهای ۱–۵ در `.claude/prompt/tenant-0{1..5}-*.md`؛ سند معماری در [docs/architecture/tenancy.md](../../docs/architecture/tenancy.md). + +## زمینه + +فاز ۴ هشت entity مالی را در `GlobalTables::DEFERRED` گذاشت با این استدلال که «مالکیتشان دوگانه است — پرداخت‌کننده در برابر دریافت‌کننده — و تحلیل جدا می‌خواهد». آن تحلیل حالا انجام شده و **استدلال درست نبود**: + +``` +payments → type=appointment : ۱۱ ردیف، همه appointment_id دارند + type=subscription : ۳ ردیف، هیچ‌کدام ندارند +``` + +فقط همین دو نوع وجود دارد و محیط هر دو **مشتق‌شدنی** است. نگرانیِ «بیمار هم باید پرداختش را ببیند» هم خودبه‌خود منتفی است: بیمار `ROLE_USER` است، محیط انتخاب‌شده ندارد، پس `TenantFilter` برایش خاموش می‌ماند ([tenancy.md](../../docs/architecture/tenancy.md#اجبار-tenantfilter)). + +زنجیرهٔ مالکیت (از روی روابط واقعی Doctrine): + +``` +Payment ─┬─ PaymentLog (payment_id اسکالر) + ├─ FinancialBreakdown ── SecretaryEarning + └─ WalletTransaction +Settlement ── فقط User +``` + +پس نشانه‌گذاری `Payment` بقیه را مشتق‌پذیر می‌کند. + +## مشکل / هدف + +**مشکل:** هشت جدول مالی هنوز خارج از هر تضمینی هستند. `testDeferredDebtDoesNotGrow` فقط جلوی رشدشان را می‌گیرد، نه ماندنشان. + +**هدف:** `GlobalTables::DEFERRED` به صفر برسد و سقف تست پایین بیاید. + +### تصمیم محصولی گرفته‌شده + +`bank_accounts` و `pos_devices` **مال محیط‌اند، نه کاربر**. پزشکی که هم مطب شخصی دارد و هم کلینیک، کارتخوان‌هایشان جداست. + +⚠️ **دادهٔ موجود این را نمی‌گوید.** در dev، کاربر ۷ دو کلینیک و دو حساب بانکی دارد و هیچ ستونی نمی‌گوید کدام حساب مال کدام کلینیک است. تصمیم: **موارد مبهم تهی می‌مانند تا مالک خودش تعیین کند** — هیچ دادهٔ مالی‌ای حدس زده نمی‌شود. + +## معیار پذیرش + +- ✅ موفق: `GlobalTables::DEFERRED === []` و `testEveryEntityIsClassified` سبز · پرداخت نوبتِ یک کلینیک با `entity_type='clinic'` و `entity_id=` ذخیره می‌شود · پرداخت اشتراک با محیط مشترک · کاربر با محیط انتخاب‌شدهٔ A فهرست پرداخت‌های محیط B را نمی‌بیند +- ❌ خطا: ساخت `Payment` بدون محیط قابل استخراج → `AppException` با کد از `ErrorCodes`، نه ردیف با `entity_id = 0` · حساب بانکیِ بدون محیط از طریق API قابل ویرایش نیست تا محیطش تعیین شود +- ⚠️ مرزی: **کاربر با چند محیط** → حساب‌های موجودش تهی می‌مانند و در پاسخ API با نشانهٔ «محیط تعیین‌نشده» می‌آیند · بیمار (`ROLE_USER`) همچنان پرداخت خودش را می‌بیند چون فیلتر برایش خاموش است · `settlements` جدول خالی است (۰ ردیف در dev) پس backfillش باید روی صفر ردیف هم درست کار کند + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Payment/Entity/Payment.php` | ریشهٔ زنجیرهٔ مالی | +| `src/Payment/Entity/PaymentLog.php` | فرزند `Payment` | +| `src/Settlement/Entity/FinancialBreakdown.php` | فرزند `Payment` | +| `src/Settlement/Entity/WalletTransaction.php` | `payment` + `user` | +| `src/Settlement/Entity/Settlement.php` | فقط `user` | +| `src/Secretary/Entity/SecretaryEarning.php` | فرزند `FinancialBreakdown` | +| `src/PaymentMethod/Entity/BankAccount.php` · `Pos.php` | تصمیم محصولی — به محیط منتقل می‌شوند | +| `src/PaymentMethod/Service/PaymentMethodService.php` | ۸ متد، همه `User`-محور | +| `src/Shared/Tenant/GlobalTables.php` | `DEFERRED` باید خالی شود | +| `tests/Shared/TenantSchemaCoverageTest.php` | سقف `testDeferredDebtDoesNotGrow` | +| `assets/admin/` | صفحهٔ روش‌های پرداخت — تغییر معنا | + +## وضعیت فعلی + +```php +// src/Shared/Tenant/GlobalTables.php +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 ثبت شده، نه روی محیط', +]; +``` + +```php +// src/PaymentMethod/Entity/BankAccount.php — مالکیت فعلی +#[ORM\Index(columns: ['user_id'], name: 'idx_bank_accounts_user')] + +#[ORM\ManyToOne(targetEntity: User::class)] +private User $user; +``` + +```php +// src/PaymentMethod/Service/PaymentMethodService.php — هر هشت متد User می‌گیرند +public function listBankAccounts(User $user): array +public function createBankAccount(User $user, array $data): array +public function updateBankAccount(User $user, string $uuid, array $data): array +public function toggleBankAccountStatus(User $user, string $uuid): array +// … و چهار متد قرینه برای Pos +``` + +## وظایف + +### ۱. `payments` — ریشهٔ زنجیره + +`TenantOwnedTrait` را اضافه کن و محیط را در نقطهٔ ساخت تعیین کن. **در سازنده مقداردهی نکن** — درس فاز ۲: `getId()` پیش از flush تهی است. + +نگاشت backfill: + +```sql +-- پرداخت نوبت: محیط همان نوبت (از فاز ۲ جفت دارد) +UPDATE payments p JOIN appointments a ON a.id = p.appointment_id +SET p.entity_type = a.entity_type, p.entity_id = a.entity_id +WHERE p.appointment_id IS NOT NULL; + +-- پرداخت اشتراک: محیط مشترک +UPDATE payments p JOIN clinic_subscriptions cs ON cs.user_id = p.user_id +SET p.entity_type = cs.entity_type, p.entity_id = cs.entity_id +WHERE p.appointment_id IS NULL AND p.entity_type IS NULL; +``` + +همان ترتیب فازهای ۲ و ۳: ستون تهی‌پذیر → backfill → `abortIf` روی باقی‌ماندهٔ NULL → `NOT NULL` → ایندکس tenant-پیشرو. + +**⚠️ اگر پرداختی ماند که هیچ‌کدام از دو کوئری پرش نکرد، migration باید متوقف شود** — نوع سومی از پرداخت وجود دارد که این تحلیل ندیده و باید انسان تصمیم بگیرد. + +**نحوه تست:** +```bash +ddev exec php bin/console dbal:run-sql "SELECT COUNT(*) FROM payments WHERE entity_type IS NULL" # صفر +ddev exec php bin/console dbal:run-sql "SELECT COUNT(*) FROM payments p JOIN appointments a ON a.id=p.appointment_id WHERE p.entity_type<>a.entity_type OR p.entity_id<>a.entity_id" # صفر +``` + +--- + +### ۲. فرزندان زنجیره + +`PaymentLog`، `FinancialBreakdown`، `WalletTransaction`، `SecretaryEarning` را در `GlobalTables::AGGREGATE_CHILDREN` با ریشهٔ صریح ثبت کن — **نه** ستون tenant جدید. `testEveryAggregateChildReachesATenantOwnedRoot` زنجیره را تا `Payment` دنبال می‌کند. + +```php +\App\Payment\Entity\PaymentLog::class => \App\Payment\Entity\Payment::class, +\App\Settlement\Entity\FinancialBreakdown::class => \App\Payment\Entity\Payment::class, +\App\Settlement\Entity\WalletTransaction::class => \App\Payment\Entity\Payment::class, +\App\Secretary\Entity\SecretaryEarning::class => \App\Settlement\Entity\FinancialBreakdown::class, +``` + +**قبل از ثبت، تأیید کن `WalletTransaction.payment` واقعاً اجباری است.** اگر تهی‌پذیر باشد، ردیف بدون پرداخت هیچ ریشه‌ای ندارد و باید ستون tenant خودش را بگیرد: + +```bash +ddev exec php bin/console dbal:run-sql "SELECT COUNT(*) FROM wallet_transactions WHERE payment_id IS NULL" +``` + +**نحوه تست:** `ddev exec php bin/phpunit tests/Shared/TenantSchemaCoverageTest.php` + +--- + +### ۳. `settlements` + +فقط `user` دارد و در dev **صفر ردیف** است. تعیین کن تسویه با «صاحب محیط» است یا با «کاربر»: + +- اگر با محیط → جفت tenant بگیرد؛ backfill روی صفر ردیف بی‌خطر است +- اگر با کاربر → در `GlobalTables::ENTITIES` با دلیل ثبت شود + +از کد `SettlementService`/`CommissionService` تصمیم را دربیاور، نه از حدس. **دلیل انتخاب را در پرامپت اجرا بنویس.** + +**نحوه تست:** تست پوشش سبز + یک تست که تسویهٔ محیط A برای کاربر محیط B دیده نشود (اگر tenant گرفت). + +--- + +### ۴. `bank_accounts` و `pos_devices` — انتقال از کاربر به محیط + +**پرریسک‌ترین وظیفهٔ این فاز.** جفت tenant اضافه کن، ولی `user_id` را نگه دار (چه کسی ثبتش کرده). + +backfill فقط برای موارد بدون ابهام: + +```sql +-- کاربری که دقیقاً یک محیط دارد +UPDATE bank_accounts b + JOIN (SELECT u.id AS user_id, + MAX(d.id) AS doctor_id, MAX(c.id) AS clinic_id, + COUNT(DISTINCT d.id) + COUNT(DISTINCT c.id) AS envs + FROM users u + LEFT JOIN doctors d ON d.user_id = u.id + LEFT JOIN clinics c ON c.user_id = u.id + GROUP BY u.id) x ON x.user_id = b.user_id +SET b.entity_type = IF(x.clinic_id IS NOT NULL, 'clinic', 'doctor'), + b.entity_id = IFNULL(x.clinic_id, x.doctor_id) +WHERE x.envs = 1; +``` + +**موارد مبهم (`envs > 1`) تهی می‌مانند — این تصمیم گرفته‌شده است، نه فراموشی.** پس: + +- ستون‌ها **تهی‌پذیر** می‌مانند (برخلاف بقیهٔ جدول‌های tenant) +- در `GlobalTables` نه در `ENTITIES` و نه در `AGGREGATE_CHILDREN` جا نمی‌گیرند؛ چون جفت را **دارند**، تست پوشش خودبه‌خود راضی است +- `TenantFilter` ردیف‌های تهی را حذف می‌کند، پس تا وقتی مالک تعیین نکرده در هیچ محیطی دیده نمی‌شوند — **این نقطهٔ ضعف باید در سند ثبت شود** + +migration باید تعداد ردیف‌های مبهم را با `$this->write()` گزارش کند تا در خروجی دیپلوی دیده شود. + +**نحوه تست:** +```bash +ddev exec php bin/console dbal:run-sql "SELECT entity_type, COUNT(*) FROM bank_accounts GROUP BY 1" +ddev exec php bin/console dbal:run-sql "SELECT COUNT(*) FROM bank_accounts WHERE entity_type IS NULL" # فقط کاربران چندمحیطی +``` +به‌علاوه تست: کاربر با دو کلینیک → حساب‌هایش تهی مانده‌اند و در هیچ‌کدام از دو محیط در فهرست نمی‌آیند. + +--- + +### ۵. `PaymentMethodService` — از `User` به `EntityContext` + +هر هشت متد باید محیط بگیرند نه کاربر. `EntityContextResolver` را تزریق کن (نه `new`). + +```php +public function listBankAccounts(EntityContext $context): array +``` + +`user_id` هنگام ساخت همچنان از کاربر جاری پر می‌شود (چه کسی ثبت کرد)، ولی **اسکوپ خواندن و ویرایش، محیط است**. + +⚠️ **تغییر رفتار قابل مشاهده:** پزشکی که هم مطب شخصی دارد و هم کلینیک، بسته به محیط فعالش کارت‌های متفاوتی می‌بیند. این هدفِ کار است، ولی باید در `docs/api/` ثبت شود. + +**نحوه تست:** سه سناریوی curl روی `/api/v1/my/payment-methods/bank-accounts` — محیط A، محیط B، و بدون توکن. + +--- + +### ۶. خالی‌کردن `DEFERRED` و پایین‌آوردن سقف + +```php +public const DEFERRED = []; +``` + +و در `TenantSchemaCoverageTest`: + +```php +self::assertSame([], GlobalTables::DEFERRED, 'بدهی طبقه‌بندی باید صفر بماند'); +``` + +جایگزین `testDeferredDebtDoesNotGrow` شود — از «رشد نکن» به «صفر بمان». + +**نحوه تست:** `ddev exec php bin/phpunit tests/Shared/` + +--- + +### ۷. پنل ادمین — نمایش محیط + +صفحهٔ روش‌های پرداخت باید بگوید این کارت‌ها مال کدام محیط‌اند، وگرنه کاربر چندمحیطی گیج می‌شود («حسابم کجا رفت؟»). + +- عنوان صفحه یا یک `badge` با نام محیط فعال +- حساب‌های بدون محیط با نشانهٔ «محیط تعیین‌نشده» و امکان انتساب + +از کامپوننت‌های موجود `assets/admin/components/ui/` استفاده کن؛ کلاس CSS جدید نساز. + +**نحوه تست:** `ddev exec npx tsc --noEmit --project tsconfig.json` و `ddev exec yarn dev`؛ سپس بررسی دستی صفحه با کاربر تست چندمحیطی. + +## نکات مهم + +- **بکاپ قبل از هر migration:** `ddev export-db --file=backups/pre-tenant-phase6-$(date +%Y%m%d-%H%M%S).sql.gz`. این فاز به دادهٔ مالی دست می‌زند؛ برگشت خودکار ندارد. +- **ترتیب migration** همان فازهای ۲ و ۳: تهی‌پذیر → backfill → `abortIf` → `NOT NULL` → ایندکس. استثنا: `bank_accounts`/`pos_devices` که عمداً تهی‌پذیر می‌مانند. +- **`isTransactional(): false`** — MariaDB روی DDL ضمنی commit می‌کند؛ ترتیب تنها محافظ است. +- **الگو: ادامهٔ `TenantOwnedTrait` و `AGGREGATE_CHILDREN`.** چیز جدیدی ساخته نمی‌شود؛ اگر لازم شد، یعنی زنجیرهٔ مالکیت را اشتباه فهمیده‌ای. +- **سند:** بعد از این فاز، بخش «بدهی باقی‌مانده» در [tenancy.md](../../docs/architecture/tenancy.md) باید حذف یا بازنویسی شود، و نقطه‌ضعفِ «حساب بدون محیط دیده نمی‌شود» صریح ثبت شود. +- **کلاینت‌ها:** `/api/v1/my/payment-methods/*` را پنل ادمین مصرف می‌کند. `nobat724_front` و `clinic-pro-tauri` بررسی شدند و این مسیر را صدا نمی‌زنند — دوباره تأیید کن، چون در build خطا نمی‌دهد. +- **`docs/api/payment.md`** و سند روش‌های پرداخت با JSON واقعی به‌روز شوند. diff --git a/.claude/prompt/tenant-07-aggregate-join-guard.md b/.claude/prompt/tenant-07-aggregate-join-guard.md new file mode 100644 index 00000000..3d0d995b --- /dev/null +++ b/.claude/prompt/tenant-07-aggregate-join-guard.md @@ -0,0 +1,130 @@ +# فاز ۷ — بستن نقطهٔ کور فرزندان 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 لازم است، **آن را پیاده نکن** — پرامپت جدا بنویس و دلیلش را با شواهد وظیفهٔ ۱ مستند کن.