- 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.
17 KiB
تنظیمات بیمه بهازای هر پزشک در کلینیک چندپزشکه
زمینه
درصد پوشش و شرایط هر بیمه (فرانشیز، سقف تعهد سالانه، تاریخ اعتبار، پوشش خدمات) میتواند برای هر پزشک متفاوت باشد. در یک کلینیک با ۳ پزشک، بیمهها باید برای هر پزشک جداگانه تنظیم شوند، نه یکبار در سطح کلینیک.
مدل داده از قبل این را پشتیبانی میکند: هم 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 تعلق دارد:مطمئن شو با entity هدفِ per-doctor سازگار میماند (سرویسهای آن پزشک بایدif ($section->getEntityType() !== $entityType || $section->getEntityId() !== $entityId) { ... 403 }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 خودش پاس دهد.
- POST/PATCH (
۴. مستندات
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باید دقیقاً رفتار امروز را بدهد (resolveEntityfallback). این برای مطبهای شخصی و مصرفکنندههای موجود (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از قبل این را پوشش میدهد).