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:
@@ -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` از قبل این را پوشش میدهد).
|
||||
@@ -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<typeof buildInsurancePayload>) => 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<InsuranceFormValues>(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 };
|
||||
|
||||
@@ -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<Kind>('basic');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editContract, setEditContract] = useState<Contract | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
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({
|
||||
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() {
|
||||
</button>
|
||||
</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 }}>
|
||||
{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}
|
||||
|
||||
+13
-2
@@ -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` همان خدمت بازمحاسبه میشود: اگر زیر **هر** قرارداد بیمهای دستکم یک ردیف با
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user