diff --git a/.claude/prompt/per-doctor-insurance-contracts.md b/.claude/prompt/per-doctor-insurance-contracts.md new file mode 100644 index 00000000..4ae821af --- /dev/null +++ b/.claude/prompt/per-doctor-insurance-contracts.md @@ -0,0 +1,223 @@ +# تنظیمات بیمه به‌ازای هر پزشک در کلینیک چندپزشکه + +## زمینه + +درصد پوشش و شرایط هر بیمه (فرانشیز، سقف تعهد سالانه، تاریخ اعتبار، پوشش خدمات) می‌تواند برای هر پزشک متفاوت باشد. در یک کلینیک با ۳ پزشک، بیمه‌ها باید برای هر پزشک جداگانه تنظیم شوند، نه یک‌بار در سطح کلینیک. + +مدل داده **از قبل** این را پشتیبانی می‌کند: هم `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()` هم‌اکنون در کنترلر موجود است و درست کار می‌کند: + +```php +// 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` این‌طور مصرف می‌شود: + +```php +[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $request->query->get('doctor_uuid'), 'view'); +if ($err !== null) { return $err; } +``` + +### اندپوینت‌هایی که هنوز clinic-only هستند (باید اصلاح شوند) + +هر ۶ اندپوینت زیر از `resolveEntity($user)` استفاده می‌کنند و `doctor_uuid` را نادیده می‌گیرند: + +```php +// 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 می‌شود + +```php +// 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). + +### فرانت‌اند فعلی — بدون انتخاب پزشک + +```tsx +// 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'`. + +```php +#[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` بررسی فعلی این است: + +```php +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 تعلق دارد: + ```php + 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` استفاده کن (قانون پروژه: هرگز ``. رشته‌های UI فارسی. +- **مجوز کاربر غیرمالک**: مدیر/پرسنل کلینیک با مجوز `services` روی کلینیک هم می‌تواند بیمهٔ پزشکان را تنظیم کند (منطق `permChecker` از قبل این را پوشش می‌دهد). diff --git a/assets/admin/components/InsuranceModal.tsx b/assets/admin/components/InsuranceModal.tsx index a85a3cc8..b7c45eab 100644 --- a/assets/admin/components/InsuranceModal.tsx +++ b/assets/admin/components/InsuranceModal.tsx @@ -59,8 +59,12 @@ export function contractToForm(c: Contract): InsuranceFormValues { }; } -/** Build the API payload from form values (toman → rials, Y-m-d → unix). */ -export function buildInsurancePayload(v: InsuranceFormValues) { +/** + * Build the API payload from form values (toman → rials, Y-m-d → unix). + * When `doctorUuid` is set, the contract is targeted at that doctor (multi-doctor + * clinic); otherwise it falls back to the caller's own tenant on the backend. + */ +export function buildInsurancePayload(v: InsuranceFormValues, doctorUuid?: string | null) { return { insurance_id: Number(v.insuranceId), kind: v.kind || null, @@ -69,6 +73,7 @@ export function buildInsurancePayload(v: InsuranceFormValues) { annual_ceiling_rials: v.ceiling === '' ? null : tomanToRial(Number(v.ceiling)), effective_from: isoToUnix(v.effectiveFrom), effective_to: isoToUnix(v.effectiveTo), + ...(doctorUuid ? { doctor_uuid: doctorUuid } : {}), }; } @@ -79,6 +84,8 @@ interface Props { options: InsuranceOption[]; /** Insurance kind of the active tab ('basic'|'supplementary'); assigned to new contracts, not user-editable. */ kind: string; + /** Target doctor in a multi-doctor clinic; threaded into the payload as `doctor_uuid`. */ + doctorUuid?: string | null; onClose: () => void; onSubmit: (payload: ReturnType) => void; isPending?: boolean; @@ -89,7 +96,7 @@ interface Props { * state, emits the built payload via onSubmit. Fields mirror the Figma "افزودن بیمه" * modal plus the injected coverage/franchise/ceiling controls. */ -export default function InsuranceModal({ open, editContract, options, kind, onClose, onSubmit, isPending }: Props) { +export default function InsuranceModal({ open, editContract, options, kind, doctorUuid, onClose, onSubmit, isPending }: Props) { const [form, setForm] = useState(EMPTY_FORM); useEffect(() => { @@ -102,7 +109,7 @@ export default function InsuranceModal({ open, editContract, options, kind, onCl const submit = () => { if (!form.insuranceId) return; - onSubmit(buildInsurancePayload(form)); + onSubmit(buildInsurancePayload(form, doctorUuid)); }; const field = { display: 'flex', flexDirection: 'column' as const, gap: 6 }; diff --git a/assets/admin/components/TenantInsuranceContracts.tsx b/assets/admin/components/TenantInsuranceContracts.tsx index 37ac030e..5bcfb5ef 100644 --- a/assets/admin/components/TenantInsuranceContracts.tsx +++ b/assets/admin/components/TenantInsuranceContracts.tsx @@ -3,7 +3,11 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { PlusIcon, PencilIcon, MagnifyingGlassIcon, ChevronDownIcon } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import { api } from '../lib/api'; +import type { ApiResponse } from '../lib/api'; import { formatRial, formatNumber, formatDate } from '../lib/utils'; +import { useAuthStore } from '../stores/authStore'; +import SearchableSelect from './ui/SearchableSelect'; +import type { ClinicDoctorItem } from './ClinicDoctorsManager'; import InsuranceModal, { Contract, InsuranceOption, KIND_LABEL, buildInsurancePayload } from './InsuranceModal'; type Kind = 'basic' | 'supplementary'; @@ -37,20 +41,52 @@ export function contractSummary(c: Contract): string { export default function TenantInsuranceContracts() { const qc = useQueryClient(); + const { dbUuid, context, availableContexts } = useAuthStore(); const [tab, setTab] = useState('basic'); const [modalOpen, setModalOpen] = useState(false); const [editContract, setEditContract] = useState(null); const [search, setSearch] = useState(''); const [expanded, setExpanded] = useState(null); + const [pickedDoctorUuid, setPickedDoctorUuid] = useState(null); + + // A user who is both a doctor and a clinic owner may have a doctor db_uuid active; + // fall back to the clinic context so the roster query targets the clinic. Same + // resolution as ClinicAppointmentSettingsPage. + const clinicUuid = useMemo(() => { + if (context?.type === 'clinic') return dbUuid; + return availableContexts.find((c) => c.type === 'clinic')?.db_uuid ?? null; + }, [context, dbUuid, availableContexts]); + + const doctorsQuery = useQuery({ + queryKey: ['clinic-doctors', clinicUuid], + queryFn: () => api.get>(`/api/v1/clinic/doctor-list/${clinicUuid}`), + enabled: !!clinicUuid, + }); + + const doctorList: ClinicDoctorItem[] = useMemo(() => { + const raw = doctorsQuery.data?.data; + return (raw as any)?.data ?? raw ?? []; + }, [doctorsQuery.data]); + + // In a clinic, insurance is per-doctor: default to the first doctor. Solo doctors / + // personal offices have no clinic context → doctorUuid stays null → backend keeps the + // legacy tenant-scoped behavior. + const isClinic = !!clinicUuid; + const doctorUuid = useMemo( + () => (isClinic ? pickedDoctorUuid ?? doctorList[0]?.uuid ?? null : null), + [isClinic, pickedDoctorUuid, doctorList], + ); + const showDoctorPicker = isClinic && doctorList.length > 1; + const dq = doctorUuid ? `?doctor_uuid=${encodeURIComponent(doctorUuid)}` : ''; const contractsQuery = useQuery({ - queryKey: ['tenant-insurances'], - queryFn: () => api.get('/api/v1/billing/tenant-insurances'), + queryKey: ['tenant-insurances', doctorUuid], + queryFn: () => api.get(`/api/v1/billing/tenant-insurances${dq}`), }); const pricingQuery = useQuery({ - queryKey: ['insurance-pricing'], - queryFn: () => api.get('/api/v1/insurance-pricing'), + queryKey: ['insurance-pricing', doctorUuid], + queryFn: () => api.get(`/api/v1/insurance-pricing${dq}`), }); const contracts: Contract[] = (contractsQuery.data as any)?.data?.data ?? []; @@ -76,7 +112,7 @@ export default function TenantInsuranceContracts() { supplementary: contracts.filter((c) => kindOf(c) === 'supplementary').length, }), [contracts, catalogTypeById]); - const invalidate = () => qc.invalidateQueries({ queryKey: ['tenant-insurances'] }); + const invalidate = () => qc.invalidateQueries({ queryKey: ['tenant-insurances', doctorUuid] }); const toggleRow = (uuid: string) => setExpanded((p) => (p === uuid ? null : uuid)); const saveMut = useMutation({ @@ -94,7 +130,10 @@ export default function TenantInsuranceContracts() { const toggleMut = useMutation({ mutationFn: (c: Contract) => - api.patch(`/api/v1/billing/tenant-insurances/${c.uuid}`, { is_active: !c.is_active }), + api.patch(`/api/v1/billing/tenant-insurances/${c.uuid}`, { + is_active: !c.is_active, + ...(doctorUuid ? { doctor_uuid: doctorUuid } : {}), + }), onSuccess: () => invalidate(), onError: (e: Error) => toast.error(e.message), }); @@ -114,6 +153,21 @@ export default function TenantInsuranceContracts() { + {showDoctorPicker && ( +
+ + ({ value: d.uuid, label: d.name }))} + value={doctorUuid ?? ''} + onChange={(v) => { setPickedDoctorUuid(v ? String(v) : null); setExpanded(null); }} + placeholder="انتخاب پزشک..." + /> + + تنظیمات بیمه برای هر پزشک جداگانه ذخیره می‌شود. + +
+ )} +
{KINDS.map((k) => { const active = k.key === tab; @@ -181,6 +235,7 @@ export default function TenantInsuranceContracts() { editContract={editContract} options={editContract ? allInsurances : available} kind={editContract ? kindOf(editContract) : tab} + doctorUuid={doctorUuid} onClose={closeModal} onSubmit={(payload) => saveMut.mutate(payload)} isPending={saveMut.isPending} diff --git a/docs/api/insurance.md b/docs/api/insurance.md index f8011d06..105412b4 100644 --- a/docs/api/insurance.md +++ b/docs/api/insurance.md @@ -371,9 +371,13 @@ entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR tenant از `#[CurrentUser]` با `App\Patient\Security\PatientRecordScopeResolver` resolve می‌شود — همان رزولور پرونده‌ها و صورتحساب‌ها، تا قرارداد بیمه و صورتحسابی که از آن ساخته می‌شود هرگز به دو محیط متفاوت نیفتند. محیط فعال (`UserActiveContext`) تعیین‌کننده است، نه صرفاً ترتیب نقش‌ها؛ مالک کلینیکی که خودش پزشک هم هست، قراردادهای **کلینیک** خود را می‌بیند. +**تنظیمات per-doctor در کلینیک چندپزشکه:** درصد و شرایط هر بیمه می‌تواند برای هر پزشک متفاوت باشد. همهٔ اندپوینت‌های زیر یک پارامتر اختیاری `doctor_uuid` می‌پذیرند (در `GET`/`DELETE` از query، در `POST`/`PATCH`/`PUT` از بدنه). با آن، قرارداد به‌جای موجودیتِ tenantِ کاربر جاری، به‌ازای پزشک هدف (`entity_type='doctor'`) خوانده/نوشته می‌شود — دقیقاً مثل `insurance-pricing`. **بدون** آن، رفتار قبلی (tenant کاربر جاری) دست‌نخورده می‌ماند (سازگاری عقب‌رو). دسترسی با `doctor_uuid` هم مثل `insurance-pricing` بررسی می‌شود: `ROLE_ADMIN`، خودِ پزشک، یا کاربرِ عضو/مالکِ کلینیکِ آن پزشک با مجوز `services.view` (برای نوشتن `services.update`)؛ در غیر این صورت `403 ERR_ACCESS_DENIED`، و پزشکِ ناموجود `404 ERR_NOT_FOUND_001`. + ### GET `/api/v1/billing/tenant-insurances` لیست قراردادهای tenant جاری — **آخرین نسخهٔ هر بیمه، فعال یا غیرفعال** (برای toggle فعال/غیرفعال در UI مدیریت بیمه). `insurance_kind` = `kind` قرارداد در صورت تعیین، وگرنه نوع بیمه از کاتالوگ. +**Query:** `doctor_uuid` (اختیاری) — قراردادهای همان پزشک را برمی‌گرداند (نگاه کنید به «تنظیمات per-doctor» بالا). + **Permission:** `AUTH` (doctor/clinic) ```json @@ -413,6 +417,7 @@ tenant از `#[CurrentUser]` با `App\Patient\Security\PatientRecordScopeResolv | `kind` | string \| null | نوع بیمه قرارداد (`basic`/`supplementary`); خالی → پیش‌فرض نوع کاتالوگ | | `effective_from` | int \| null | تاریخ شروع قرارداد (Unix)؛ null → اکنون | | `effective_to` | int \| null | تاریخ پایان قرارداد (Unix)؛ null → نامحدود | +| `doctor_uuid` | string (UUID) \| null | اختیاری — قرارداد را به‌ازای پزشک هدف ذخیره می‌کند (نگاه کنید به «تنظیمات per-doctor» بالا) | پاسخ `201`: `{ success, data: { …contract } }`. خطاها: `404 ERR_NOT_FOUND_001` بیمه یافت نشد · `422 ERR_VALIDATION_001` insurance_id الزامی · `403 ERR_FORBIDDEN_001` پروفایل یافت نشد. @@ -420,13 +425,16 @@ tenant از `#[CurrentUser]` با `App\Patient\Security\PatientRecordScopeResolv ### PATCH `/api/v1/billing/tenant-insurances/{uuid}` ویرایش فیلدهای قرارداد (همه اختیاری، فقط کلیدهای موجود اعمال می‌شوند). فقط قرارداد متعلق به tenant جاری. -**Body:** `coverage_percent` · `franchise_rials` · `annual_ceiling_rials` · `kind` · `effective_from` · `effective_to` · `is_active`. +**Body:** `coverage_percent` · `franchise_rials` · `annual_ceiling_rials` · `kind` · `effective_from` · `effective_to` · `is_active` · `doctor_uuid` (اختیاری، برای هدف‌گیری پزشک — نگاه کنید به «تنظیمات per-doctor» بالا). - `is_active` (bool): toggle فعال/غیرفعال. برخلاف `DELETE`، مقدار `effective_to`ِ تعیین‌شدهٔ کاربر را دست‌نخورده نگه می‌دارد (برای reactivate). +- قرارداد باید به همان موجودیتِ resolve‌شده (پزشک هدف یا tenant کاربر) تعلق داشته باشد، وگرنه `404`. ### DELETE `/api/v1/billing/tenant-insurances/{uuid}` غیرفعال‌سازی نرم (soft) — `is_active=false` و `effective_to=now`. داده حذف نمی‌شود. +**Query:** `doctor_uuid` (اختیاری) — برای غیرفعال‌سازی قرارداد یک پزشک خاص. + ```json { "success": true, "data": { "message": "قرارداد بیمه غیرفعال شد" } } ``` @@ -442,6 +450,8 @@ override پوشش یک خدمت خاص تحت قرارداد یک بیمه. فی ### GET `/api/v1/billing/tenant-insurances/{uuid}/service-coverage` لیست overrideهای پوشش خدمات یک قرارداد. +**Query:** `doctor_uuid` (اختیاری) — برای قراردادِ متعلق به پزشک هدف در کلینیک چندپزشکه. + **Permission:** `AUTH` (مالک قرارداد) ```json @@ -476,8 +486,9 @@ override پوشش یک خدمت خاص تحت قرارداد یک بیمه. فی | `coverage_percent` | float \| null | null = ارث از قرارداد | | `franchise_rials` | int \| null | null = ارث از قرارداد | | `ceiling_rials` | int \| null | null = ارث از قرارداد | +| `doctor_uuid` | string (UUID) \| null | اختیاری — قراردادِ متعلق به پزشک هدف (نگاه کنید به «تنظیمات per-doctor» بالا) | -سرویس باید متعلق به همان مطب/کلینیکِ قرارداد باشد (`ServiceItem→section→entity_type/entity_id`). +سرویس باید متعلق به همان مطب/کلینیکِ قرارداد باشد (`ServiceItem→section→entity_type/entity_id`). با `doctor_uuid`، موجودیت هدف پزشک است، پس سرویس هم باید متعلق به همان پزشک باشد. **اثر جانبی — همگام‌سازی `ServiceItem.insurance_covered`:** پس از ذخیره‌ی ردیف پوشش، پرچم `insurance_covered` همان خدمت بازمحاسبه می‌شود: اگر زیر **هر** قرارداد بیمه‌ای دست‌کم یک ردیف با diff --git a/src/Insurance/Controller/InsuranceController.php b/src/Insurance/Controller/InsuranceController.php index 2a6c05e8..072a9fa6 100644 --- a/src/Insurance/Controller/InsuranceController.php +++ b/src/Insurance/Controller/InsuranceController.php @@ -368,9 +368,12 @@ class InsuranceController extends BaseController #[Route('/api/v1/billing/tenant-insurances', methods: ['GET'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] - public function listTenantInsurances(#[CurrentUser] User $user): JsonResponse + public function listTenantInsurances(Request $request, #[CurrentUser] User $user): JsonResponse { - [$entityType, $entityId] = $this->resolveEntity($user); + [$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); } @@ -397,12 +400,16 @@ class InsuranceController extends BaseController #[IsGranted('IS_AUTHENTICATED_FULLY')] public function activateTenantInsurance(Request $request, #[CurrentUser] User $user): JsonResponse { - [$entityType, $entityId] = $this->resolveEntity($user); + $data = json_decode($request->getContent(), true) ?? []; + + [$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $data['doctor_uuid'] ?? null, 'update'); + if ($err !== null) { + return $err; + } if ($entityId === null) { return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403); } - $data = json_decode($request->getContent(), true) ?? []; $insuranceId = isset($data['insurance_id']) ? (int) $data['insurance_id'] : 0; if ($insuranceId <= 0) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'insurance_id الزامی است', 422); @@ -428,13 +435,18 @@ class InsuranceController extends BaseController #[IsGranted('IS_AUTHENTICATED_FULLY')] public function updateTenantInsurance(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { - [$entityType, $entityId] = $this->resolveEntity($user); + $data = json_decode($request->getContent(), true) ?? []; + + [$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $data['doctor_uuid'] ?? null, 'update'); + if ($err !== null) { + return $err; + } + $contract = $this->tenantInsuranceRepo->findByUuid($uuid); if ($contract === null || $contract->getEntityType() !== $entityType || $contract->getEntityId() !== $entityId) { return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'قرارداد یافت نشد', 404); } - $data = json_decode($request->getContent(), true) ?? []; if (array_key_exists('coverage_percent', $data)) { $contract->setCoveragePercent((float) $data['coverage_percent']); } @@ -466,9 +478,13 @@ class InsuranceController extends BaseController #[Route('/api/v1/billing/tenant-insurances/{uuid}', methods: ['DELETE'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] - public function deactivateTenantInsurance(string $uuid, #[CurrentUser] User $user): JsonResponse + public function deactivateTenantInsurance(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { - [$entityType, $entityId] = $this->resolveEntity($user); + [$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $request->query->get('doctor_uuid'), 'update'); + if ($err !== null) { + return $err; + } + $contract = $this->tenantInsuranceRepo->findByUuid($uuid); if ($contract === null || $contract->getEntityType() !== $entityType || $contract->getEntityId() !== $entityId) { return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'قرارداد یافت نشد', 404); @@ -481,9 +497,13 @@ class InsuranceController extends BaseController #[Route('/api/v1/billing/tenant-insurances/{uuid}/service-coverage', methods: ['GET'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] - public function listServiceCoverage(string $uuid, #[CurrentUser] User $user): JsonResponse + public function listServiceCoverage(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { - [$entityType, $entityId] = $this->resolveEntity($user); + [$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $request->query->get('doctor_uuid'), 'view'); + if ($err !== null) { + return $err; + } + $contract = $this->tenantInsuranceRepo->findByUuid($uuid); if ($contract === null || $contract->getEntityType() !== $entityType || $contract->getEntityId() !== $entityId) { return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'قرارداد یافت نشد', 404); @@ -509,14 +529,18 @@ class InsuranceController extends BaseController #[IsGranted('IS_AUTHENTICATED_FULLY')] public function setServiceCoverage(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { - [$entityType, $entityId] = $this->resolveEntity($user); + $data = json_decode($request->getContent(), true) ?? []; + + [$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $data['doctor_uuid'] ?? null, 'update'); + if ($err !== null) { + return $err; + } + $contract = $this->tenantInsuranceRepo->findByUuid($uuid); if ($contract === null || $contract->getEntityType() !== $entityType || $contract->getEntityId() !== $entityId) { return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'قرارداد یافت نشد', 404); } - $data = json_decode($request->getContent(), true) ?? []; - $serviceItem = isset($data['service_item_uuid']) ? $this->serviceItemRepo->findByUuid((string) $data['service_item_uuid']) : (isset($data['service_item_id']) ? $this->serviceItemRepo->find((int) $data['service_item_id']) : null); diff --git a/tests/Insurance/TenantInsurancePerDoctorTest.php b/tests/Insurance/TenantInsurancePerDoctorTest.php new file mode 100644 index 00000000..fe84eb04 --- /dev/null +++ b/tests/Insurance/TenantInsurancePerDoctorTest.php @@ -0,0 +1,133 @@ +createUser(['ROLE_USER', 'ROLE_DOCTOR']); + $doctor = new Doctor($user, $name); + $doctor->setMobileNumber($user->getMobileNumber()); + $this->em->persist($doctor); + $this->em->flush(); + + return $doctor; + } + + /** @return array{0: \App\Auth\Entity\User, 1: Clinic} */ + private function makeClinicWith(Doctor ...$doctors): array + { + $owner = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']); + $clinic = new Clinic($owner); + $clinic->setName('کلینیک تست بیمه'); + foreach ($doctors as $d) { + $clinic->getDoctors()->add($d); + } + $this->em->persist($clinic); + $this->em->flush(); + + return [$owner, $clinic]; + } + + private function makeInsurance(InsuranceType $type = InsuranceType::Basic): Insurance + { + $insurance = new Insurance('بیمه ' . random_int(1000, 9999), $type); + $this->em->persist($insurance); + $this->em->flush(); + + return $insurance; + } + + public function testClinicOwnerCreatesContractForMemberDoctor(): void + { + $doctor = $this->makeDoctor('دکتر عضو'); + [$owner, $clinic] = $this->makeClinicWith($doctor); + $insurance = $this->makeInsurance(); + + $body = $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [ + 'doctor_uuid' => $doctor->getUuid(), + 'insurance_id' => $insurance->getId(), + 'coverage_percent' => 80, + ]); + + self::assertSame(201, $this->responseCode()); + self::assertSame('doctor', $body['data']['data']['entity_type']); + self::assertSame($doctor->getId(), $body['data']['data']['entity_id']); + } + + public function testContractsAreIsolatedPerDoctor(): void + { + $first = $this->makeDoctor('دکتر اول'); + $second = $this->makeDoctor('دکتر دوم'); + [$owner, $clinic] = $this->makeClinicWith($first, $second); + $insA = $this->makeInsurance(); + $insB = $this->makeInsurance(); + + $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [ + 'doctor_uuid' => $first->getUuid(), 'insurance_id' => $insA->getId(), 'coverage_percent' => 70, + ]); + $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [ + 'doctor_uuid' => $second->getUuid(), 'insurance_id' => $insB->getId(), 'coverage_percent' => 40, + ]); + + $body = $this->authJson('GET', "/api/v1/billing/tenant-insurances?doctor_uuid={$first->getUuid()}", $owner); + self::assertSame(200, $this->responseCode()); + + $insuranceIds = array_map(fn ($r) => $r['insurance_id'], $body['data']['data']); + self::assertContains($insA->getId(), $insuranceIds, 'قرارداد پزشک اول باید دیده شود'); + self::assertNotContains($insB->getId(), $insuranceIds, 'قرارداد پزشک دوم نباید در فهرست پزشک اول باشد'); + } + + public function testStrangerDoctorUuidIsForbidden(): void + { + $member = $this->makeDoctor('دکتر عضو'); + $outsider = $this->makeDoctor('دکتر بیرونی'); + [$owner, $clinic] = $this->makeClinicWith($member); + $insurance = $this->makeInsurance(); + + $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [ + 'doctor_uuid' => $outsider->getUuid(), + 'insurance_id' => $insurance->getId(), + 'coverage_percent' => 50, + ]); + + self::assertSame(403, $this->responseCode(), 'پزشکِ خارج از کلینیک قابل تنظیم نیست'); + } + + public function testUnknownDoctorUuidReturns404(): void + { + [$owner] = $this->makeClinicWith($this->makeDoctor('دکتر عضو')); + + $this->authJson('GET', '/api/v1/billing/tenant-insurances?doctor_uuid=' . Uuid::v4()->toRfc4122(), $owner); + + self::assertSame(404, $this->responseCode()); + } + + public function testWithoutDoctorUuidKeepsLegacyBehavior(): void + { + $doctor = $this->makeDoctor('دکتر مستقل'); + $insurance = $this->makeInsurance(); + + $body = $this->authJson('POST', '/api/v1/billing/tenant-insurances', $doctor->getUser(), [ + 'insurance_id' => $insurance->getId(), + 'coverage_percent' => 60, + ]); + + self::assertSame(201, $this->responseCode()); + self::assertSame('doctor', $body['data']['data']['entity_type']); + self::assertSame($doctor->getId(), $body['data']['data']['entity_id']); + } +}