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.
This commit is contained in:
hamed
2026-07-21 19:21:55 +03:30
parent 7e847b62c4
commit 5507b42fd8
6 changed files with 478 additions and 25 deletions
@@ -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` استفاده کن (قانون پروژه: هرگز `<select>` بومی). برای مطب تک‌پزشکه یا وقتی یک پزشک است، انتخاب‌گر پنهان بماند و `doctor_uuid` ارسال نشود (رفتار قبلی).
- `doctor_uuid` را در **query string** هر دو query بگنجان و در **queryKey** بیاور تا cache به‌ازای پزشک تفکیک شود:
```tsx
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` از قبل این را پوشش می‌دهد).
+11 -4
View File
@@ -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 { return {
insurance_id: Number(v.insuranceId), insurance_id: Number(v.insuranceId),
kind: v.kind || null, kind: v.kind || null,
@@ -69,6 +73,7 @@ export function buildInsurancePayload(v: InsuranceFormValues) {
annual_ceiling_rials: v.ceiling === '' ? null : tomanToRial(Number(v.ceiling)), annual_ceiling_rials: v.ceiling === '' ? null : tomanToRial(Number(v.ceiling)),
effective_from: isoToUnix(v.effectiveFrom), effective_from: isoToUnix(v.effectiveFrom),
effective_to: isoToUnix(v.effectiveTo), effective_to: isoToUnix(v.effectiveTo),
...(doctorUuid ? { doctor_uuid: doctorUuid } : {}),
}; };
} }
@@ -79,6 +84,8 @@ interface Props {
options: InsuranceOption[]; options: InsuranceOption[];
/** Insurance kind of the active tab ('basic'|'supplementary'); assigned to new contracts, not user-editable. */ /** Insurance kind of the active tab ('basic'|'supplementary'); assigned to new contracts, not user-editable. */
kind: string; kind: string;
/** Target doctor in a multi-doctor clinic; threaded into the payload as `doctor_uuid`. */
doctorUuid?: string | null;
onClose: () => void; onClose: () => void;
onSubmit: (payload: ReturnType<typeof buildInsurancePayload>) => void; onSubmit: (payload: ReturnType<typeof buildInsurancePayload>) => void;
isPending?: boolean; isPending?: boolean;
@@ -89,7 +96,7 @@ interface Props {
* state, emits the built payload via onSubmit. Fields mirror the Figma "افزودن بیمه" * state, emits the built payload via onSubmit. Fields mirror the Figma "افزودن بیمه"
* modal plus the injected coverage/franchise/ceiling controls. * 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<InsuranceFormValues>(EMPTY_FORM); const [form, setForm] = useState<InsuranceFormValues>(EMPTY_FORM);
useEffect(() => { useEffect(() => {
@@ -102,7 +109,7 @@ export default function InsuranceModal({ open, editContract, options, kind, onCl
const submit = () => { const submit = () => {
if (!form.insuranceId) return; if (!form.insuranceId) return;
onSubmit(buildInsurancePayload(form)); onSubmit(buildInsurancePayload(form, doctorUuid));
}; };
const field = { display: 'flex', flexDirection: 'column' as const, gap: 6 }; const field = { display: 'flex', flexDirection: 'column' as const, gap: 6 };
@@ -3,7 +3,11 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { PlusIcon, PencilIcon, MagnifyingGlassIcon, ChevronDownIcon } from '@heroicons/react/24/outline'; import { PlusIcon, PencilIcon, MagnifyingGlassIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '../lib/api'; import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import { formatRial, formatNumber, formatDate } from '../lib/utils'; 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'; import InsuranceModal, { Contract, InsuranceOption, KIND_LABEL, buildInsurancePayload } from './InsuranceModal';
type Kind = 'basic' | 'supplementary'; type Kind = 'basic' | 'supplementary';
@@ -37,20 +41,52 @@ export function contractSummary(c: Contract): string {
export default function TenantInsuranceContracts() { export default function TenantInsuranceContracts() {
const qc = useQueryClient(); const qc = useQueryClient();
const { dbUuid, context, availableContexts } = useAuthStore();
const [tab, setTab] = useState<Kind>('basic'); const [tab, setTab] = useState<Kind>('basic');
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [editContract, setEditContract] = useState<Contract | null>(null); const [editContract, setEditContract] = useState<Contract | null>(null);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [expanded, setExpanded] = useState<string | null>(null); const [expanded, setExpanded] = useState<string | null>(null);
const [pickedDoctorUuid, setPickedDoctorUuid] = useState<string | null>(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<ApiResponse<{ data: ClinicDoctorItem[] }>>(`/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({ const contractsQuery = useQuery({
queryKey: ['tenant-insurances'], queryKey: ['tenant-insurances', doctorUuid],
queryFn: () => api.get('/api/v1/billing/tenant-insurances'), queryFn: () => api.get(`/api/v1/billing/tenant-insurances${dq}`),
}); });
const pricingQuery = useQuery({ const pricingQuery = useQuery({
queryKey: ['insurance-pricing'], queryKey: ['insurance-pricing', doctorUuid],
queryFn: () => api.get('/api/v1/insurance-pricing'), queryFn: () => api.get(`/api/v1/insurance-pricing${dq}`),
}); });
const contracts: Contract[] = (contractsQuery.data as any)?.data?.data ?? []; 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, supplementary: contracts.filter((c) => kindOf(c) === 'supplementary').length,
}), [contracts, catalogTypeById]); }), [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 toggleRow = (uuid: string) => setExpanded((p) => (p === uuid ? null : uuid));
const saveMut = useMutation({ const saveMut = useMutation({
@@ -94,7 +130,10 @@ export default function TenantInsuranceContracts() {
const toggleMut = useMutation({ const toggleMut = useMutation({
mutationFn: (c: Contract) => 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(), onSuccess: () => invalidate(),
onError: (e: Error) => toast.error(e.message), onError: (e: Error) => toast.error(e.message),
}); });
@@ -114,6 +153,21 @@ export default function TenantInsuranceContracts() {
</button> </button>
</div> </div>
{showDoctorPicker && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 16, maxWidth: 320 }}>
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-2)' }}>پزشک</label>
<SearchableSelect
options={doctorList.map((d) => ({ value: d.uuid, label: d.name }))}
value={doctorUuid ?? ''}
onChange={(v) => { setPickedDoctorUuid(v ? String(v) : null); setExpanded(null); }}
placeholder="انتخاب پزشک..."
/>
<span style={{ fontSize: 11, color: 'var(--text-3)' }}>
تنظیمات بیمه برای هر پزشک جداگانه ذخیره میشود.
</span>
</div>
)}
<div role="tablist" style={{ display: 'flex', gap: 4, borderBottom: '1px solid var(--border)', marginBottom: 16 }}> <div role="tablist" style={{ display: 'flex', gap: 4, borderBottom: '1px solid var(--border)', marginBottom: 16 }}>
{KINDS.map((k) => { {KINDS.map((k) => {
const active = k.key === tab; const active = k.key === tab;
@@ -181,6 +235,7 @@ export default function TenantInsuranceContracts() {
editContract={editContract} editContract={editContract}
options={editContract ? allInsurances : available} options={editContract ? allInsurances : available}
kind={editContract ? kindOf(editContract) : tab} kind={editContract ? kindOf(editContract) : tab}
doctorUuid={doctorUuid}
onClose={closeModal} onClose={closeModal}
onSubmit={(payload) => saveMut.mutate(payload)} onSubmit={(payload) => saveMut.mutate(payload)}
isPending={saveMut.isPending} isPending={saveMut.isPending}
+13 -2
View File
@@ -371,9 +371,13 @@ entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR
tenant از `#[CurrentUser]` با `App\Patient\Security\PatientRecordScopeResolver` resolve می‌شود — همان رزولور پرونده‌ها و صورتحساب‌ها، تا قرارداد بیمه و صورتحسابی که از آن ساخته می‌شود هرگز به دو محیط متفاوت نیفتند. محیط فعال (`UserActiveContext`) تعیین‌کننده است، نه صرفاً ترتیب نقش‌ها؛ مالک کلینیکی که خودش پزشک هم هست، قراردادهای **کلینیک** خود را می‌بیند. 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` ### GET `/api/v1/billing/tenant-insurances`
لیست قراردادهای tenant جاری — **آخرین نسخهٔ هر بیمه، فعال یا غیرفعال** (برای toggle فعال/غیرفعال در UI مدیریت بیمه). `insurance_kind` = `kind` قرارداد در صورت تعیین، وگرنه نوع بیمه از کاتالوگ. لیست قراردادهای tenant جاری — **آخرین نسخهٔ هر بیمه، فعال یا غیرفعال** (برای toggle فعال/غیرفعال در UI مدیریت بیمه). `insurance_kind` = `kind` قرارداد در صورت تعیین، وگرنه نوع بیمه از کاتالوگ.
**Query:** `doctor_uuid` (اختیاری) — قراردادهای همان پزشک را برمی‌گرداند (نگاه کنید به «تنظیمات per-doctor» بالا).
**Permission:** `AUTH` (doctor/clinic) **Permission:** `AUTH` (doctor/clinic)
```json ```json
@@ -413,6 +417,7 @@ tenant از `#[CurrentUser]` با `App\Patient\Security\PatientRecordScopeResolv
| `kind` | string \| null | نوع بیمه قرارداد (`basic`/`supplementary`); خالی → پیش‌فرض نوع کاتالوگ | | `kind` | string \| null | نوع بیمه قرارداد (`basic`/`supplementary`); خالی → پیش‌فرض نوع کاتالوگ |
| `effective_from` | int \| null | تاریخ شروع قرارداد (Unix)؛ null → اکنون | | `effective_from` | int \| null | تاریخ شروع قرارداد (Unix)؛ null → اکنون |
| `effective_to` | int \| null | تاریخ پایان قرارداد (Unix)؛ null → نامحدود | | `effective_to` | int \| null | تاریخ پایان قرارداد (Unix)؛ null → نامحدود |
| `doctor_uuid` | string (UUID) \| null | اختیاری — قرارداد را به‌ازای پزشک هدف ذخیره می‌کند (نگاه کنید به «تنظیمات per-doctor» بالا) |
پاسخ `201`: `{ success, data: { …contract } }`. پاسخ `201`: `{ success, data: { …contract } }`.
خطاها: `404 ERR_NOT_FOUND_001` بیمه یافت نشد · `422 ERR_VALIDATION_001` insurance_id الزامی · `403 ERR_FORBIDDEN_001` پروفایل یافت نشد. خطاها: `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}` ### PATCH `/api/v1/billing/tenant-insurances/{uuid}`
ویرایش فیلدهای قرارداد (همه اختیاری، فقط کلیدهای موجود اعمال می‌شوند). فقط قرارداد متعلق به tenant جاری. ویرایش فیلدهای قرارداد (همه اختیاری، فقط کلیدهای موجود اعمال می‌شوند). فقط قرارداد متعلق به 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). - `is_active` (bool): toggle فعال/غیرفعال. برخلاف `DELETE`، مقدار `effective_to`ِ تعیین‌شدهٔ کاربر را دست‌نخورده نگه می‌دارد (برای reactivate).
- قرارداد باید به همان موجودیتِ resolve‌شده (پزشک هدف یا tenant کاربر) تعلق داشته باشد، وگرنه `404`.
### DELETE `/api/v1/billing/tenant-insurances/{uuid}` ### DELETE `/api/v1/billing/tenant-insurances/{uuid}`
غیرفعال‌سازی نرم (soft) — `is_active=false` و `effective_to=now`. داده حذف نمی‌شود. غیرفعال‌سازی نرم (soft) — `is_active=false` و `effective_to=now`. داده حذف نمی‌شود.
**Query:** `doctor_uuid` (اختیاری) — برای غیرفعال‌سازی قرارداد یک پزشک خاص.
```json ```json
{ "success": true, "data": { "message": "قرارداد بیمه غیرفعال شد" } } { "success": true, "data": { "message": "قرارداد بیمه غیرفعال شد" } }
``` ```
@@ -442,6 +450,8 @@ override پوشش یک خدمت خاص تحت قرارداد یک بیمه. فی
### GET `/api/v1/billing/tenant-insurances/{uuid}/service-coverage` ### GET `/api/v1/billing/tenant-insurances/{uuid}/service-coverage`
لیست overrideهای پوشش خدمات یک قرارداد. لیست overrideهای پوشش خدمات یک قرارداد.
**Query:** `doctor_uuid` (اختیاری) — برای قراردادِ متعلق به پزشک هدف در کلینیک چندپزشکه.
**Permission:** `AUTH` (مالک قرارداد) **Permission:** `AUTH` (مالک قرارداد)
```json ```json
@@ -476,8 +486,9 @@ override پوشش یک خدمت خاص تحت قرارداد یک بیمه. فی
| `coverage_percent` | float \| null | null = ارث از قرارداد | | `coverage_percent` | float \| null | null = ارث از قرارداد |
| `franchise_rials` | int \| null | null = ارث از قرارداد | | `franchise_rials` | int \| null | null = ارث از قرارداد |
| `ceiling_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`:** پس از ذخیره‌ی ردیف پوشش، پرچم **اثر جانبی — همگام‌سازی `ServiceItem.insurance_covered`:** پس از ذخیره‌ی ردیف پوشش، پرچم
`insurance_covered` همان خدمت بازمحاسبه می‌شود: اگر زیر **هر** قرارداد بیمه‌ای دست‌کم یک ردیف با `insurance_covered` همان خدمت بازمحاسبه می‌شود: اگر زیر **هر** قرارداد بیمه‌ای دست‌کم یک ردیف با
@@ -368,9 +368,12 @@ class InsuranceController extends BaseController
#[Route('/api/v1/billing/tenant-insurances', methods: ['GET'])] #[Route('/api/v1/billing/tenant-insurances', methods: ['GET'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')] #[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) { if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403); return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
} }
@@ -397,12 +400,16 @@ class InsuranceController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')] #[IsGranted('IS_AUTHENTICATED_FULLY')]
public function activateTenantInsurance(Request $request, #[CurrentUser] User $user): JsonResponse 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) { if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403); return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
} }
$data = json_decode($request->getContent(), true) ?? [];
$insuranceId = isset($data['insurance_id']) ? (int) $data['insurance_id'] : 0; $insuranceId = isset($data['insurance_id']) ? (int) $data['insurance_id'] : 0;
if ($insuranceId <= 0) { if ($insuranceId <= 0) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'insurance_id الزامی است', 422); return $this->error(ErrorCodes::ERR_VALIDATION_001, 'insurance_id الزامی است', 422);
@@ -428,13 +435,18 @@ class InsuranceController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')] #[IsGranted('IS_AUTHENTICATED_FULLY')]
public function updateTenantInsurance(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse 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); $contract = $this->tenantInsuranceRepo->findByUuid($uuid);
if ($contract === null || $contract->getEntityType() !== $entityType || $contract->getEntityId() !== $entityId) { if ($contract === null || $contract->getEntityType() !== $entityType || $contract->getEntityId() !== $entityId) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'قرارداد یافت نشد', 404); return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'قرارداد یافت نشد', 404);
} }
$data = json_decode($request->getContent(), true) ?? [];
if (array_key_exists('coverage_percent', $data)) { if (array_key_exists('coverage_percent', $data)) {
$contract->setCoveragePercent((float) $data['coverage_percent']); $contract->setCoveragePercent((float) $data['coverage_percent']);
} }
@@ -466,9 +478,13 @@ class InsuranceController extends BaseController
#[Route('/api/v1/billing/tenant-insurances/{uuid}', methods: ['DELETE'])] #[Route('/api/v1/billing/tenant-insurances/{uuid}', methods: ['DELETE'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')] #[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); $contract = $this->tenantInsuranceRepo->findByUuid($uuid);
if ($contract === null || $contract->getEntityType() !== $entityType || $contract->getEntityId() !== $entityId) { if ($contract === null || $contract->getEntityType() !== $entityType || $contract->getEntityId() !== $entityId) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'قرارداد یافت نشد', 404); 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'])] #[Route('/api/v1/billing/tenant-insurances/{uuid}/service-coverage', methods: ['GET'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')] #[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); $contract = $this->tenantInsuranceRepo->findByUuid($uuid);
if ($contract === null || $contract->getEntityType() !== $entityType || $contract->getEntityId() !== $entityId) { if ($contract === null || $contract->getEntityType() !== $entityType || $contract->getEntityId() !== $entityId) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'قرارداد یافت نشد', 404); return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'قرارداد یافت نشد', 404);
@@ -509,14 +529,18 @@ class InsuranceController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')] #[IsGranted('IS_AUTHENTICATED_FULLY')]
public function setServiceCoverage(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse 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); $contract = $this->tenantInsuranceRepo->findByUuid($uuid);
if ($contract === null || $contract->getEntityType() !== $entityType || $contract->getEntityId() !== $entityId) { if ($contract === null || $contract->getEntityType() !== $entityType || $contract->getEntityId() !== $entityId) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'قرارداد یافت نشد', 404); return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'قرارداد یافت نشد', 404);
} }
$data = json_decode($request->getContent(), true) ?? [];
$serviceItem = isset($data['service_item_uuid']) $serviceItem = isset($data['service_item_uuid'])
? $this->serviceItemRepo->findByUuid((string) $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); : (isset($data['service_item_id']) ? $this->serviceItemRepo->find((int) $data['service_item_id']) : null);
@@ -0,0 +1,133 @@
<?php
namespace App\Tests\Insurance;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Insurance\Entity\Insurance;
use App\Insurance\Enum\InsuranceType;
use App\Tests\ApiTestCase;
use Symfony\Component\Uid\Uuid;
/**
* درصد و شرایط هر بیمه می‌تواند برای هر پزشک متفاوت باشد؛ در کلینیک چندپزشکه قرارداد
* بیمه به‌ازای پزشک (`doctor_uuid`) ذخیره می‌شود، نه یک‌بار در سطح کلینیک. این تست
* هدف‌گیری per-doctor روی اندپوینت‌های tenant-insurances را پوشش می‌دهد.
*/
class TenantInsurancePerDoctorTest extends ApiTestCase
{
private function makeDoctor(string $name): Doctor
{
$user = $this->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']);
}
}