Files
clinicpro/.claude/prompt/per-doctor-insurance-contracts.md
hamed 5507b42fd8 feat: implement per-doctor insurance settings in multi-doctor clinics
- Updated InsuranceModal to include doctorUuid in the payload for insurance contracts.
- Enhanced TenantInsuranceContracts to allow selection of doctors and pass doctorUuid in API requests.
- Modified InsuranceController to handle doctorUuid for tenant insurance endpoints, ensuring contracts are stored per doctor.
- Updated API documentation to reflect the new optional doctor_uuid parameter for tenant insurance endpoints.
- Added tests to verify the functionality of per-doctor insurance contracts and ensure isolation of contracts between doctors.
2026-07-21 19:21:55 +03:30

17 KiB
Raw Permalink Blame History

تنظیمات بیمه به‌ازای هر پزشک در کلینیک چندپزشکه

زمینه

درصد پوشش و شرایط هر بیمه (فرانشیز، سقف تعهد سالانه، تاریخ اعتبار، پوشش خدمات) می‌تواند برای هر پزشک متفاوت باشد. در یک کلینیک با ۳ پزشک، بیمه‌ها باید برای هر پزشک جداگانه تنظیم شوند، نه یک‌بار در سطح کلینیک.

مدل داده از قبل این را پشتیبانی می‌کند: هم TenantInsurance و هم EntityInsurancePricing چندریختی (polymorphic) هستند و ستون‌های entity_type ('doctor' یا 'clinic') و entity_id دارند. نیازی به تغییر Entity یا migration نیست.

مشکل فقط در «رزولوِ کردن موجودیت هدف» و UI است:

  • اندپوینت‌های insurance-pricing (GET/PUT /api/v1/insurance-pricing) از قبل پارامتر doctor_uuid را می‌پذیرند و از resolveTargetEntity() استفاده می‌کنند → قیمت‌گذاری ویزیت هم‌اکنون per-doctor کار می‌کند.
  • اما اندپوینت‌های tenant-insurances (که coverage_percent=درصد و franchise_rials/annual_ceiling_rials=شرایط را نگه می‌دارند) از resolveEntity($user) استفاده می‌کنند که برای مالک کلینیک همیشه سطح کلینیک (entity_type='clinic', entity_id=clinicId) برمی‌گرداند → یک مجموعه قرارداد مشترک برای هر ۳ پزشک. این باگ اصلی است.

مشکل / هدف

قراردادهای بیمه (tenant-insurances) و پوشش خدمات (service-coverage) را طوری کن که مالک کلینیک (یا کاربر با مجوز) بتواند برای هر پزشکِ کلینیک، بیمه‌ها را جداگانه تنظیم کند — دقیقاً با همان الگویی که برای insurance-pricing پیاده شده (doctor_uuid + resolveTargetEntity). سپس در پنل ادمین یک انتخاب‌گر پزشک اضافه شود تا کاربر پزشک هدف را برگزیند.

Spec (EN): In a multi-doctor clinic, insurance contracts and their coverage percent / franchise / ceiling / service-coverage must be stored per doctor, not once per clinic. Extend the six tenant-insurances endpoints to accept an optional doctor_uuid and resolve the target entity via the existing resolveTargetEntity() (falling back to today's behavior when absent). Add a doctor selector to the admin insurance page for clinic owners that have more than one doctor, threading doctor_uuid through every query and mutation.

فایل‌های مرتبط

فایل نقش
src/Insurance/Controller/InsuranceController.php ۶ اندپوینت tenant-insurances که باید doctor_uuid بپذیرند
src/Patient/Security/PatientRecordScopeResolver.php چرا مالک کلینیک همیشه clinic-level می‌شود (فقط برای درک — تغییر نکند)
src/Insurance/Service/TenantInsuranceService.php متدهای activate / deactivate / setServiceCoverage (بررسی امضاها)
src/Insurance/Entity/TenantInsurance.php مدل چندریختی — بدون تغییر
src/Insurance/Entity/EntityInsurancePricing.php مدل چندریختی — بدون تغییر
assets/admin/components/TenantInsuranceContracts.tsx UI اصلی؛ باید انتخاب‌گر پزشک + پاس‌دادن doctor_uuid اضافه شود
assets/admin/pages/InsurancePricingPage.tsx صفحه میزبان؛ محل قرارگرفتن انتخاب‌گر پزشک
assets/admin/components/InsuranceModal.tsx buildInsurancePayload — باید doctor_uuid را در payload بگنجاند
assets/admin/components/ServiceInsuranceModal.tsx پوشش خدمات per-contract — باید doctor_uuid را پاس دهد
docs/api/insurance.md مستندسازی پارامتر جدید doctor_uuid روی اندپوینت‌های tenant-insurances

وضعیت فعلی

الگوی مرجع که از قبل per-doctor است (باید تکرار شود)

resolveTargetEntity() هم‌اکنون در کنترلر موجود است و درست کار می‌کند:

// src/Insurance/Controller/InsuranceController.php:57
private function resolveTargetEntity(User $user, ?string $doctorUuid, string $action): array
{
    if ($doctorUuid === null || $doctorUuid === '') {
        [$type, $id] = $this->resolveEntity($user);
        return [$type, $id, null];                 // رفتار قبلی حفظ می‌شود
    }

    $doctor = $this->doctorRepo->findByUuid($doctorUuid);
    if ($doctor === null) {
        return ['unknown', null, $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404)];
    }

    if ($user->hasRole('ROLE_ADMIN') || $doctor->getUser()->getId() === $user->getId()) {
        return [EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId(), null];
    }

    foreach ($this->clinicRepo->findByDoctor($doctor) as $clinic) {
        if ($this->permChecker->can($user, $clinic, 'services', $action)) {
            return [EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId(), null];
        }
    }

    return ['unknown', null, $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403)];
}

و در getInsurancePricing/saveInsurancePricing این‌طور مصرف می‌شود:

[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $request->query->get('doctor_uuid'), 'view');
if ($err !== null) { return $err; }

اندپوینت‌هایی که هنوز clinic-only هستند (باید اصلاح شوند)

هر ۶ اندپوینت زیر از resolveEntity($user) استفاده می‌کنند و doctor_uuid را نادیده می‌گیرند:

// src/Insurance/Controller/InsuranceController.php
#[Route('/api/v1/billing/tenant-insurances', methods: ['GET'])]
public function listTenantInsurances(#[CurrentUser] User $user): JsonResponse
{
    [$entityType, $entityId] = $this->resolveEntity($user);   // ← clinic-level برای مالک کلینیک
    ...
}

#[Route('/api/v1/billing/tenant-insurances', methods: ['POST'])]
public function activateTenantInsurance(Request $request, #[CurrentUser] User $user): JsonResponse
{
    [$entityType, $entityId] = $this->resolveEntity($user);   // ←
    ...
}

// همین‌طور:
//  updateTenantInsurance(string $uuid, ...)           PATCH  /tenant-insurances/{uuid}
//  deactivateTenantInsurance(string $uuid, ...)       DELETE /tenant-insurances/{uuid}
//  listServiceCoverage(string $uuid, ...)             GET    /tenant-insurances/{uuid}/service-coverage
//  setServiceCoverage(string $uuid, ...)              PUT    /tenant-insurances/{uuid}/service-coverage

چرا مالک کلینیک clinic-level می‌شود

// src/Patient/Security/PatientRecordScopeResolver.php:40
if ($user->hasRole('ROLE_CLINIC')) {
    $clinic = $this->clinicRepo->findByUser($user);
    return PatientRecordScope::forClinic($clinic?->getId());   // entity_type='clinic'
}

این رزولوِر عمداً برای پرونده‌ها clinic-level است و نباید تغییر کند؛ راه‌حل، عبور doctor_uuid از سمت کنترلر است (مثل insurance-pricing).

فرانت‌اند فعلی — بدون انتخاب پزشک

// assets/admin/components/TenantInsuranceContracts.tsx:46
const contractsQuery = useQuery({
  queryKey: ['tenant-insurances'],
  queryFn: () => api.get('/api/v1/billing/tenant-insurances'),
});
const pricingQuery = useQuery({
  queryKey: ['insurance-pricing'],
  queryFn: () => api.get('/api/v1/insurance-pricing'),
});

هیچ مفهومی از «پزشک انتخاب‌شده» وجود ندارد؛ mutationها هم doctor_uuid نمی‌فرستند.

وظایف

۱. Backend — عبور doctor_uuid در اندپوینت‌های tenant-insurances

در InsuranceController.php، شش اندپوینت tenant-insurances را از resolveEntity($user) به resolveTargetEntity(...) تغییر بده — دقیقاً مثل الگوی insurance-pricing:

  • listTenantInsurances (GET): doctor_uuid را از $request->query->get('doctor_uuid') بگیر، action='view'. امضای متد به (Request $request, #[CurrentUser] User $user) تغییر کند.
  • activateTenantInsurance (POST): doctor_uuid را از بدنه ($data['doctor_uuid'] ?? null) بگیر، action='update'.
  • updateTenantInsurance (PATCH): doctor_uuid از بدنه، action='update'.
  • deactivateTenantInsurance (DELETE): doctor_uuid از query، action='update'.
  • listServiceCoverage (GET): doctor_uuid از query، action='view'.
  • setServiceCoverage (PUT): doctor_uuid از بدنه، action='update'.
#[Route('/api/v1/billing/tenant-insurances', methods: ['GET'])]
public function listTenantInsurances(Request $request, #[CurrentUser] User $user): JsonResponse
{
    [$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $request->query->get('doctor_uuid'), 'view');
    if ($err !== null) { return $err; }
    if ($entityId === null) {
        return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
    }
    // ... بقیه بدون تغییر
}

نکته حیاتی درباره تطبیق مالکیت قرارداد: در update/deactivate/listServiceCoverage/setServiceCoverage بررسی فعلی این است:

if ($contract === null || $contract->getEntityType() !== $entityType || $contract->getEntityId() !== $entityId) {
    return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'قرارداد یافت نشد', 404);
}

این بررسی باید حفظ شود؛ چون $entityType/$entityId حالا از resolveTargetEntity می‌آید، وقتی doctor_uuid داده شود قرارداد باید entity_type='doctor' و همان entity_id را داشته باشد — یعنی کاربر نمی‌تواند با پاس‌دادن doctor_uuidِ یک پزشک، قرارداد پزشک دیگری را دستکاری کند. این رفتار درست است، فقط مطمئن شو ترتیب صحیح است (اول resolve، بعد تطبیق).

  • امضای TenantInsuranceService::activate/deactivate/setServiceCoverage را بررسی کن؛ چون $entityType/$entityId را همان کنترلر پاس می‌دهد، معمولاً نیازی به تغییر سرویس نیست. اگر جایی مستقیم resolveEntity صدا زده می‌شود، آن را هم اصلاح کن.

۲. Backend — یکدست‌سازی و اعتبارسنجی

  • در setServiceCoverage، بعد از resolve، این بررسی موجود است که section سرویس به همان tenant تعلق دارد:
    if ($section->getEntityType() !== $entityType || $section->getEntityId() !== $entityId) { ... 403 }
    
    مطمئن شو با entity هدفِ per-doctor سازگار می‌ماند (سرویس‌های آن پزشک باید entity_type='doctor' باشند).
  • Edge case: اگر doctor_uuid متعلق به پزشکی باشد که عضو کلینیکِ این کاربر نیست، resolveTargetEntity باید 403 برگرداند (منطق findByDoctor + permChecker این را پوشش می‌دهد). تأیید کن.

۳. Frontend — انتخاب‌گر پزشک در صفحه بیمه

در TenantInsuranceContracts.tsx:

  • یک state جدید selectedDoctorUuid: string | null اضافه کن.
  • فهرست پزشکان کلینیک را با GET /api/v1/clinic/doctor-list/{clinicUuid} بگیر (clinicUuid کلینیکِ فعال کاربر — از همان منبعی که بقیه صفحات کلینیک استفاده می‌کنند، مثلاً authStore/context؛ الگوی موجود را پیدا و تکرار کن).
  • فقط وقتی کاربر مالک کلینیک است و کلینیک بیش از یک پزشک دارد، انتخاب‌گر را نشان بده. از کامپوننت SearchableSelect استفاده کن (قانون پروژه: هرگز <select> بومی). برای مطب تک‌پزشکه یا وقتی یک پزشک است، انتخاب‌گر پنهان بماند و doctor_uuid ارسال نشود (رفتار قبلی).
  • doctor_uuid را در query string هر دو query بگنجان و در queryKey بیاور تا cache به‌ازای پزشک تفکیک شود:
const dq = selectedDoctorUuid ? `?doctor_uuid=${selectedDoctorUuid}` : '';

const contractsQuery = useQuery({
  queryKey: ['tenant-insurances', selectedDoctorUuid],
  queryFn: () => api.get(`/api/v1/billing/tenant-insurances${dq}`),
});
const pricingQuery = useQuery({
  queryKey: ['insurance-pricing', selectedDoctorUuid],
  queryFn: () => api.get(`/api/v1/insurance-pricing${dq}`),
});
  • در invalidate() هم کلید را با selectedDoctorUuid هماهنگ کن.
  • در mutationها:
    • POST/PATCH (saveMut): doctor_uuid را داخل بدنه بفرست. buildInsurancePayload در InsuranceModal.tsx باید doctor_uuid را به payload اضافه کند (پارامتر جدید بگیرد یا مقدار از props).
    • DELETE / toggle status (toggleMut از PATCH استفاده می‌کند → بدنه) و اگر مسیر DELETE هم زده می‌شود → query string.
    • ServiceInsuranceModal (پوشش خدمات) هم doctor_uuid را در GET/PUT خودش پاس دهد.

۴. مستندات

docs/api/insurance.md را به‌روزرسانی کن: برای هر شش اندپوینت tenant-insurances پارامتر اختیاری doctor_uuid (query یا body) را مستند کن، با توضیح: «در نبودش رفتار پیش‌فرض (tenant کاربر) حفظ می‌شود؛ با آن، تنظیمات به‌ازای پزشک هدف اعمال می‌شود و نیازمند مالکیت/مجوز services روی کلینیکِ آن پزشک است.» به هم‌ترازی با insurance-pricing اشاره کن.

۵. تست

  • Backend (ddev exec php bin/phpunit): تست‌های موفق/خطا/مرزی برای listTenantInsurances و activateTenantInsurance با و بدون doctor_uuid:
    • مالک کلینیک با doctor_uuid پزشک عضو → قرارداد با entity_type='doctor' ساخته/خوانده شود.
    • doctor_uuid پزشکِ غیرعضو → 403.
    • بدون doctor_uuid → رفتار قبلی (clinic-level) دست‌نخورده.
    • جداسازی: قرارداد پزشک A نباید در فهرست پزشک B ظاهر شود.
  • Frontend (yarn test / npx tsc --noEmit): بدون خطای تایپ؛ رفتار انتخاب‌گر برای کلینیک تک‌پزشکه (مخفی) و چندپزشکه (نمایش).

نکات مهم

  • بدون migration: TenantInsurance و EntityInsurancePricing از قبل entity_type/entity_id دارند و uniqueِ آن‌ها شامل این ستون‌هاست (uniq_tenant_insurance_version = entity_type+entity_id+insurance_id+version)، پس ردیف‌های per-doctor مستقل‌اند. هیچ Entity تغییر نکند.
  • سازگاری عقب‌رو: نبودِ doctor_uuid باید دقیقاً رفتار امروز را بدهد (resolveEntity fallback). این برای مطب‌های شخصی و مصرف‌کننده‌های موجود (nobat724_front, clinic-pro-tauri) حیاتی است — قرارداد فعلی نباید بشکند.
  • الگوی موجود را کپی کن، ابداع نکن: insurance-pricing نقشهٔ راه است؛ همان resolveTargetEntity, همان resource 'services', همان action 'view'|'update'.
  • BaseController: پاسخ‌ها با $this->success() / $this->error()؛ کدهای خطا از ErrorCodes.
  • Frontend: TanStack Query؛ خواندن single از data?.data، اما این کامپوننت قرارداد‌ها را از data?.data?.data می‌خواند (double-nested به‌خاطر $this->success(['data' => ...])) — این الگوی موجود را حفظ کن. SearchableSelect به‌جای <select>. رشته‌های UI فارسی.
  • مجوز کاربر غیرمالک: مدیر/پرسنل کلینیک با مجوز services روی کلینیک هم می‌تواند بیمهٔ پزشکان را تنظیم کند (منطق permChecker از قبل این را پوشش می‌دهد).