# فاز ۶ — نشانه‌گذاری محیط روی جدول‌های مالی

> ادامهٔ سری 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 واقعی به‌روز شوند.
