docs(tenant): add prompts for the financial tables and the aggregate-child guard
Phase 6 covers the eight entities parked in GlobalTables::DEFERRED. The reason recorded there — dual ownership needing separate analysis — turned out to be wrong: payments carry only two types, both with a derivable environment, and a patient never has a chosen context so the filter is off for them anyway. Bank accounts and POS devices move from the user to the environment, per the product decision. Existing rows whose owner has more than one environment stay NULL rather than being guessed, since nothing in the data says which clinic an account belongs to. Phase 7 addresses the blind spot flagged in phase 4: aggregate children are not covered by the filter, and the coverage test only proves the declared chain reaches a tenant-owning root, not that queries actually start there. It may well conclude no work is needed — the phase 5 audit found no rootless query — in which case the guard plus the report is the deliverable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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=<clinic>` ذخیره میشود · پرداخت اشتراک با محیط مشترک · کاربر با محیط انتخابشدهٔ 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 واقعی بهروز شوند.
|
||||
@@ -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<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 لازم است، **آن را پیاده نکن** — پرامپت جدا بنویس و دلیلش را با شواهد وظیفهٔ ۱ مستند کن.
|
||||
Reference in New Issue
Block a user