Files
clinicpro/.claude/prompt/tenant-06-financial-tables.md
hamedandClaude Opus 5 0ae9570850 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>
2026-07-28 13:08:17 +03:30

16 KiB
Raw Permalink Blame History

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

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