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