# فاز ۶ — نشانه‌گذاری محیط روی جدول‌های مالی > ادامهٔ سری 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 واقعی به‌روز شوند.