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>
16 KiB
فاز ۶ — نشانهگذاری محیط روی جدولهای مالی
ادامهٔ سری tenant. پیشنیاز: فازهای ۱ تا ۵ کامل و کامیتشده. فازهای ۱–۵ در
.claude/prompt/tenant-0{1..5}-*.md؛ سند معماری در docs/architecture/tenancy.md.
زمینه
فاز ۴ هشت entity مالی را در GlobalTables::DEFERRED گذاشت با این استدلال که «مالکیتشان دوگانه است — پرداختکننده در برابر دریافتکننده — و تحلیل جدا میخواهد». آن تحلیل حالا انجام شده و استدلال درست نبود:
payments → type=appointment : ۱۱ ردیف، همه appointment_id دارند
type=subscription : ۳ ردیف، هیچکدام ندارند
فقط همین دو نوع وجود دارد و محیط هر دو مشتقشدنی است. نگرانیِ «بیمار هم باید پرداختش را ببیند» هم خودبهخود منتفی است: بیمار ROLE_USER است، محیط انتخابشده ندارد، پس TenantFilter برایش خاموش میماند (tenancy.md).
زنجیرهٔ مالکیت (از روی روابط واقعی 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/ |
صفحهٔ روشهای پرداخت — تغییر معنا |
وضعیت فعلی
// 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 ثبت شده، نه روی محیط',
];
// src/PaymentMethod/Entity/BankAccount.php — مالکیت فعلی
#[ORM\Index(columns: ['user_id'], name: 'idx_bank_accounts_user')]
#[ORM\ManyToOne(targetEntity: User::class)]
private User $user;
// 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:
-- پرداخت نوبت: محیط همان نوبت (از فاز ۲ جفت دارد)
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 باید متوقف شود — نوع سومی از پرداخت وجود دارد که این تحلیل ندیده و باید انسان تصمیم بگیرد.
نحوه تست:
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 دنبال میکند.
\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 خودش را بگیرد:
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 فقط برای موارد بدون ابهام:
-- کاربری که دقیقاً یک محیط دارد
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() گزارش کند تا در خروجی دیپلوی دیده شود.
نحوه تست:
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).
public function listBankAccounts(EntityContext $context): array
user_id هنگام ساخت همچنان از کاربر جاری پر میشود (چه کسی ثبت کرد)، ولی اسکوپ خواندن و ویرایش، محیط است.
⚠️ تغییر رفتار قابل مشاهده: پزشکی که هم مطب شخصی دارد و هم کلینیک، بسته به محیط فعالش کارتهای متفاوتی میبیند. این هدفِ کار است، ولی باید در docs/api/ ثبت شود.
نحوه تست: سه سناریوی curl روی /api/v1/my/payment-methods/bank-accounts — محیط A، محیط B، و بدون توکن.
۶. خالیکردن DEFERRED و پایینآوردن سقف
public const DEFERRED = [];
و در TenantSchemaCoverageTest:
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 باید حذف یا بازنویسی شود، و نقطهضعفِ «حساب بدون محیط دیده نمیشود» صریح ثبت شود.
- کلاینتها:
/api/v1/my/payment-methods/*را پنل ادمین مصرف میکند.nobat724_frontوclinic-pro-tauriبررسی شدند و این مسیر را صدا نمیزنند — دوباره تأیید کن، چون در build خطا نمیدهد. docs/api/payment.mdو سند روشهای پرداخت با JSON واقعی بهروز شوند.