diff --git a/assets/admin/components/InsuranceModal.tsx b/assets/admin/components/InsuranceModal.tsx index e2af73a3..c69047fc 100644 --- a/assets/admin/components/InsuranceModal.tsx +++ b/assets/admin/components/InsuranceModal.tsx @@ -74,10 +74,15 @@ function percentsToStrings(map?: Record): Record return Object.fromEntries(Object.entries(map ?? {}).map(([k, v]) => [k, String(v)])); } -/** درصد پوششِ قابل ثبت: عددی بین ۱ تا ۱۰۰ — صفر یعنی قرارداد آن نوع خدمت را پوشش نمی‌دهد. */ +/** + * درصد پوششِ قابل ثبت: عددی بین ۰ تا ۱۰۰. + * + * صفر مقدار معتبری است و یعنی «این قرارداد آن نوع خدمت را پوشش نمی‌دهد» — سهم بیمار + * صددرصد. آنچه رد می‌شود خالی‌ماندنِ فیلد است، نه صفر بودنش. + */ export function isValidPercent(raw?: string): boolean { const n = Number(raw); - return raw !== undefined && raw !== '' && Number.isFinite(n) && n > 0 && n <= 100; + return raw !== undefined && raw !== '' && Number.isFinite(n) && n >= 0 && n <= 100; } /** diff --git a/assets/admin/components/appointments/ConfirmAppointmentModal.tsx b/assets/admin/components/appointments/ConfirmAppointmentModal.tsx index 389ab5ed..d60eae05 100644 --- a/assets/admin/components/appointments/ConfirmAppointmentModal.tsx +++ b/assets/admin/components/appointments/ConfirmAppointmentModal.tsx @@ -41,6 +41,7 @@ interface AppointmentLike { insurance_service_category?: string | null; insurance_base_id?: number | null; insurance_supplementary_id?: number | null; + doctor?: { uuid?: string | null } | null; } interface Props { @@ -159,7 +160,7 @@ export default function ConfirmAppointmentModal({ const appt: AppointmentLike | null = detail ?? appointment ?? null; // ── بیمه: نوع خدمت + بیمهٔ پایهٔ نوبت ────────────────────────────────────── - const insurance = useAppointmentInsurance(open); + const insurance = useAppointmentInsurance(open, appt?.doctor?.uuid ?? null); // نوبتِ بدون هزینهٔ ویزیت، سرِ ساختِ مراجعه «قیمت ویزیت آزاد» تنظیمات را می‌گیرد؛ // مودال هم باید همان را نشان دهد، وگرنه صفر نشان می‌دهد و مبلغ ثبت‌شده فرق می‌کند. diff --git a/assets/admin/components/appointments/TurnsTimeline.test.tsx b/assets/admin/components/appointments/TurnsTimeline.test.tsx index 881b5126..4b98a407 100644 --- a/assets/admin/components/appointments/TurnsTimeline.test.tsx +++ b/assets/admin/components/appointments/TurnsTimeline.test.tsx @@ -72,7 +72,8 @@ describe('TurnsTimeline', () => { it('نوبتِ دارای بیمه، چیپ «نوع خدمت · بیمه» نشان می‌دهد', async () => { (api.get as ReturnType).mockImplementation((url: string) => - url === '/api/v1/billing/tenant-insurances' + // با پزشکِ نوبت، آدرس `?doctor_uuid=…&inherit=1` هم می‌گیرد. + url.startsWith('/api/v1/billing/tenant-insurances') ? Promise.resolve({ success: true, data: { data: [{ insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic', is_active: true, coverage_percent: 70, franchise_percent: 0, annual_ceiling_rials: null, diff --git a/assets/admin/components/appointments/TurnsTimeline.tsx b/assets/admin/components/appointments/TurnsTimeline.tsx index ebbbac49..1b23f59d 100644 --- a/assets/admin/components/appointments/TurnsTimeline.tsx +++ b/assets/admin/components/appointments/TurnsTimeline.tsx @@ -108,7 +108,7 @@ function OccupiedCard({ const [confirmOpen, setConfirmOpen] = useState(false); // نام بیمه فقط با نگاشت از قراردادهای کش‌شده به دست می‌آید؛ payload نوبت نامی ندارد // تا لیست‌های نوبت به N+1 نیفتند. - const insurance = useAppointmentInsurance(!!a.insurance_base_id); + const insurance = useAppointmentInsurance(!!a.insurance_base_id, a.doctor_uuid ?? null); const insuranceChip = [ a.insurance_service_category_label, insurance.insuranceNameOf(a.insurance_base_id), diff --git a/assets/admin/hooks/useAppointmentInsurance.ts b/assets/admin/hooks/useAppointmentInsurance.ts index 85abb624..3b5e315f 100644 --- a/assets/admin/hooks/useAppointmentInsurance.ts +++ b/assets/admin/hooks/useAppointmentInsurance.ts @@ -22,16 +22,23 @@ interface PricingPayload { * به‌همراه محاسبهٔ سهم — مشترک بین مودال «قطعی کردن نوبت» و صفحهٔ ویرایش نوبت تا * هر دو یک قاعده را نشان دهند. */ -export function useAppointmentInsurance(enabled: boolean) { +export function useAppointmentInsurance(enabled: boolean, doctorUuid?: string | null) { + // بدون پزشک، محیطِ خودِ کاربر پرسیده می‌شود — همان رفتار قبلی برای مطب شخصی. + // + // با پزشک، `inherit=1` هم می‌رود: نوبتِ ثبت‌شده در کلینیک محیطش «کلینیک» است ولی + // قرارداد بیمه معمولاً روی خودِ پزشک ذخیره شده. سرور اول تنظیم پزشک را می‌دهد و + // در نبودش تنظیم کلینیک را — دقیقاً همان چیزی که سرِ قطعی‌کردن اعمال می‌شود. + const scopeQuery = doctorUuid ? `?doctor_uuid=${encodeURIComponent(doctorUuid)}&inherit=1` : ''; + const pricingQuery = useQuery>({ - queryKey: ['insurance-pricing'], - queryFn: () => api.get('/api/v1/insurance-pricing'), + queryKey: ['insurance-pricing', doctorUuid ?? null], + queryFn: () => api.get(`/api/v1/insurance-pricing${scopeQuery}`), enabled, }); const contractsQuery = useQuery>({ - queryKey: ['tenant-insurances'], - queryFn: () => api.get('/api/v1/billing/tenant-insurances'), + queryKey: ['tenant-insurances', doctorUuid ?? null], + queryFn: () => api.get(`/api/v1/billing/tenant-insurances${scopeQuery}`), enabled, }); diff --git a/docs/api/insurance.md b/docs/api/insurance.md index 973e5f9d..ec2d7bda 100644 --- a/docs/api/insurance.md +++ b/docs/api/insurance.md @@ -407,6 +407,7 @@ entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR | Param | Type | Required | Description | |-------|------|----------|-------------| | `doctor_uuid` | string (UUID) | ❌ | قیمت‌گذاری همان پزشک را برمی‌گرداند به‌جای موجودیت کاربر جاری. برای تب‌های نوبت‌دهی پنل کلینیک. | +| `inherit` | bool | ❌ | «برای نوبتِ این پزشک واقعاً چه قیمتی اعمال می‌شود؟» — اول قیمت خودِ پزشک، در نبودش قیمت کلینیکی که کاربر در آن ایستاده. مودال قطعی‌کردن نوبت آن را می‌فرستد؛ صفحهٔ تنظیمات نه، چون آنجا باید ردیفِ خودِ پزشک ویرایش شود. | با `doctor_uuid`، دسترسی این‌گونه بررسی می‌شود: `ROLE_ADMIN`، خودِ پزشک، مالک کلینیکی که پزشک عضو آن است، یا پزشکِ عضو همان کلینیک با مجوز `services.view` (برای `PUT`: `services.update`). در غیر این صورت `403 ERR_ACCESS_DENIED`؛ پزشکِ ناموجود `404 ERR_NOT_FOUND_001`. بدون این پارامتر رفتار قبلی (موجودیت کاربر جاری) دست‌نخورده است. @@ -519,11 +520,18 @@ entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR tenant از `#[CurrentUser]` با `App\Patient\Security\PatientRecordScopeResolver` resolve می‌شود — همان رزولور پرونده‌ها و صورتحساب‌ها، تا قرارداد بیمه و صورتحسابی که از آن ساخته می‌شود هرگز به دو محیط متفاوت نیفتند. محیط فعال (`UserActiveContext`) تعیین‌کننده است، نه صرفاً ترتیب نقش‌ها؛ مالک کلینیکی که خودش پزشک هم هست، قراردادهای **کلینیک** خود را می‌بیند. +**محدودهٔ تنظیم — اول پزشک، بعد کلینیک:** نوبتی که در کلینیک ثبت می‌شود محیطش «کلینیک» است، ولی تنظیمات بیمه معمولاً روی خودِ پزشک ذخیره شده‌اند. هنگام محاسبه — انتخاب بیمهٔ نوبت، نوع خدمت، و قیمت ویزیت — اول تنظیمِ خودِ پزشک خوانده می‌شود و فقط در نبودِ آن تنظیمِ کلینیک. هر سه مورد جدا سنجیده می‌شوند: پزشکی که قرارداد بیمهٔ خودش را دارد ولی قیمت ویزیت را به کلینیک سپرده، هرکدام را از جای درست می‌گیرد. مرجع: `App\Insurance\Service\InsuranceScopeResolver`. + +**درصد صفر:** `0` مقدار معتبری است و یعنی «این قرارداد آن نوع خدمت را پوشش نمی‌دهد» (سهم بیمار صددرصد). آنچه رد می‌شود، خالی‌ماندنِ درصدِ یک نوع خدمتِ فعال است؛ پیش‌فرض مرکزیِ صفر هم «تعیین‌نشده» حساب می‌شود، نه انتخابِ صفر. + **تنظیمات 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` قرارداد در صورت تعیین، وگرنه نوع بیمه از کاتالوگ. +> پارامتر `inherit=1` همان قاعدهٔ محدودهٔ بیمه را اعمال می‌کند: اول قراردادهای خودِ پزشک، و اگر پزشک هیچ قراردادی نداشته باشد قراردادهای کلینیکی که کاربر در آن ایستاده. بدون این پارامتر، پاسخ دقیقاً همان محیطِ هدف است — چیزی که صفحهٔ تنظیمات برای ویرایش لازم دارد. + + **Query:** `doctor_uuid` (اختیاری) — قراردادهای همان پزشک را برمی‌گرداند (نگاه کنید به «تنظیمات per-doctor» بالا). **Permission:** `AUTH` (doctor/clinic) diff --git a/src/Appointment/Service/AppointmentInsuranceService.php b/src/Appointment/Service/AppointmentInsuranceService.php index 0cf7bdea..89bfe8e4 100644 --- a/src/Appointment/Service/AppointmentInsuranceService.php +++ b/src/Appointment/Service/AppointmentInsuranceService.php @@ -7,6 +7,7 @@ use App\Insurance\Enum\InsuranceType; use App\Insurance\Enum\ServiceCategory; use App\Insurance\Repository\InsuranceRepository; use App\Insurance\Repository\TenantInsuranceRepository; +use App\Insurance\Service\InsuranceScopeResolver; use App\Insurance\Service\TenantServiceCategoryService; use App\Shared\Constant\ErrorCodes; use App\Shared\Exception\AppException; @@ -22,6 +23,7 @@ class AppointmentInsuranceService private readonly TenantServiceCategoryService $serviceCategories, private readonly TenantInsuranceRepository $tenantInsuranceRepo, private readonly InsuranceRepository $insuranceRepo, + private readonly InsuranceScopeResolver $scope, ) {} /** @@ -39,6 +41,32 @@ class AppointmentInsuranceService : ['doctor', (int) $appointment->getDoctor()->getId()]; } + /** + * محیطی که تنظیمات بیمهٔ این نوبت از آن خوانده می‌شود: اول خودِ پزشک، در نبودِ + * تنظیمِ او کلینیکِ همان نوبت. {@see InsuranceScopeResolver} + * + * جدا از {@see tenantOf()} است: مالکیتِ نوبت — و پرونده و پرداختش — همچنان با + * کلینیک است؛ این فقط می‌گوید قرارداد و درصدها را کجا پیدا کنیم. + * + * @return array{0: string, 1: int} + */ + public function contractScopeOf(Appointment $appointment): array + { + return $this->scope->forContracts( + (int) $appointment->getDoctor()->getId(), + $appointment->getClinic()?->getId() !== null ? (int) $appointment->getClinic()->getId() : null, + ); + } + + /** @return array{0: string, 1: int} */ + public function categoryScopeOf(Appointment $appointment): array + { + return $this->scope->forServiceCategories( + (int) $appointment->getDoctor()->getId(), + $appointment->getClinic()?->getId() !== null ? (int) $appointment->getClinic()->getId() : null, + ); + } + /** * نوع خدمتِ مؤثر برای محاسبه: انتخابِ نوبت، وگرنه تنها نوع فعالِ tenant، * وگرنه سرپایی (رفتار تاریخیِ ویزیت). @@ -49,7 +77,7 @@ class AppointmentInsuranceService return $appointment->getInsuranceServiceCategory(); } - [$entityType, $entityId] = $this->tenantOf($appointment); + [$entityType, $entityId] = $this->categoryScopeOf($appointment); return $this->serviceCategories->defaultCategory($entityType, $entityId) ?? ServiceCategory::Outpatient; } @@ -63,23 +91,26 @@ class AppointmentInsuranceService */ public function apply(Appointment $appointment, array $data): void { - [$entityType, $entityId] = $this->tenantOf($appointment); + // هر کدام محیط خودش را دارد: پزشکی که قرارداد بیمهٔ خودش را دارد ولی نوع + // خدمات را به کلینیک سپرده، هر دو را از جای درست می‌گیرد. + [$categoryType, $categoryId] = $this->categoryScopeOf($appointment); + [$contractType, $contractId] = $this->contractScopeOf($appointment); if (array_key_exists('insurance_service_category', $data)) { $appointment->setInsuranceServiceCategory( - $this->resolveCategory($data['insurance_service_category'], $entityType, $entityId) + $this->resolveCategory($data['insurance_service_category'], $categoryType, $categoryId) ); } if (array_key_exists('insurance_base_id', $data)) { $appointment->setInsuranceBaseId( - $this->resolveInsuranceId($data['insurance_base_id'], $entityType, $entityId, false) + $this->resolveInsuranceId($data['insurance_base_id'], $contractType, $contractId, false) ); } if (array_key_exists('insurance_supplementary_id', $data)) { $appointment->setInsuranceSupplementaryId( - $this->resolveInsuranceId($data['insurance_supplementary_id'], $entityType, $entityId, true) + $this->resolveInsuranceId($data['insurance_supplementary_id'], $contractType, $contractId, true) ); } } diff --git a/src/Insurance/Controller/InsuranceController.php b/src/Insurance/Controller/InsuranceController.php index e446c7b3..1dfbb29d 100644 --- a/src/Insurance/Controller/InsuranceController.php +++ b/src/Insurance/Controller/InsuranceController.php @@ -39,6 +39,7 @@ class InsuranceController extends BaseController private readonly DoctorInsuranceRepository $doctorInsuranceRepo, private readonly DoctorRepository $doctorRepo, private readonly ClinicRepository $clinicRepo, + private readonly \App\Insurance\Service\InsuranceScopeResolver $insuranceScope, private readonly EntityInsurancePricingRepository $pricingRepo, private readonly TenantInsuranceRepository $tenantInsuranceRepo, private readonly TenantServiceCoverageRepository $serviceCoverageRepo, @@ -317,9 +318,53 @@ class InsuranceController extends BaseController return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403); } + // `inherit=1` را فقط مصرف‌کننده‌هایی می‌فرستند که می‌خواهند بدانند «برای نوبتِ + // این پزشک واقعاً چه چیزی اعمال می‌شود» — مثل مودال قطعی‌کردن نوبت. صفحهٔ + // تنظیمات آن را نمی‌فرستد، چون آنجا باید ردیفِ خودِ پزشک ویرایش شود، نه + // ردیفِ به‌ارث‌رسیده از کلینیک. + if ($request->query->getBoolean('inherit')) { + [$entityType, $entityId] = $this->inheritedScope($entityType, $entityId, $user, 'visit_price'); + } + return $this->success($this->pricingPayload($entityType, $entityId)); } + /** + * محیطی که تنظیم واقعاً از آن خوانده می‌شود: اول خودِ پزشک، در نبودِ تنظیمِ او + * کلینیکی که عضوش است. همان قاعده‌ای که موتور نوبت به‌کار می‌برد، پس پنل و + * محاسبهٔ نهایی یک چیز نشان می‌دهند. {@see InsuranceScopeResolver} + * + * @return array{0: string, 1: int} + */ + private function inheritedScope(string $entityType, int $entityId, User $user, string $kind): array + { + if ($entityType !== 'doctor') { + return [$entityType, $entityId]; + } + + $doctor = $this->doctorRepo->find($entityId); + if ($doctor === null) { + return [$entityType, $entityId]; + } + + // فقط کلینیکی که کاربر واقعاً در آن ایستاده — نه «هر کلینیکی که پزشک عضوش + // است»: پزشکِ چندکلینیکه وگرنه تنظیم کلینیکِ نامربوط را می‌دید. + [$callerType, $callerId] = $this->resolveEntity($user); + if ($callerType !== 'clinic' || $callerId === null) { + return [$entityType, $entityId]; + } + + $clinic = $this->clinicRepo->find((int) $callerId); + if ($clinic === null || !$clinic->getDoctors()->contains($doctor)) { + return [$entityType, $entityId]; + } + + return match ($kind) { + 'visit_price' => $this->insuranceScope->forVisitPrice($entityId, (int) $callerId), + default => $this->insuranceScope->forContracts($entityId, (int) $callerId), + }; + } + private function pricingPayload(string $entityType, int $entityId): array { $rows = $this->pricingRepo->findByEntity($entityType, $entityId); @@ -453,6 +498,10 @@ class InsuranceController extends BaseController return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403); } + if ($request->query->getBoolean('inherit')) { + [$entityType, $entityId] = $this->inheritedScope($entityType, $entityId, $user, 'contracts'); + } + $contracts = $this->tenantInsuranceRepo->findLatestByTenant($entityType, $entityId); $byId = []; diff --git a/src/Insurance/Repository/TenantServiceCategorySettingRepository.php b/src/Insurance/Repository/TenantServiceCategorySettingRepository.php index 3ab0a584..1b83e172 100644 --- a/src/Insurance/Repository/TenantServiceCategorySettingRepository.php +++ b/src/Insurance/Repository/TenantServiceCategorySettingRepository.php @@ -4,20 +4,36 @@ namespace App\Insurance\Repository; use App\Insurance\Entity\TenantServiceCategorySetting; use App\Insurance\Enum\ServiceCategory; +use App\Shared\Tenant\TenantFilterScope; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; class TenantServiceCategorySettingRepository extends ServiceEntityRepository { - public function __construct(ManagerRegistry $registry) + public function __construct(ManagerRegistry $registry, private readonly TenantFilterScope $tenantScope) { parent::__construct($registry, TenantServiceCategorySetting::class); } + /** + * مثل بقیهٔ تنظیمات بیمه، این ردیف‌ها به محیطِ *مقصد* تعلق دارند: مالک کلینیک + * نوع خدمات بیمه‌ایِ پزشکِ زیرمجموعه را تنظیم می‌کند در حالی که محیط فعال خودش + * کلینیک است. با TenantFilter روشن، خواندن کور می‌شد و ذخیره ردیف تکراری + * می‌ساخت. مجوز در `InsuranceController::resolveTargetEntity()` سنجیده شده است. + * + * @template T + * @param callable():T $query + * @return T + */ + private function unscoped(callable $query): mixed + { + return $this->tenantScope->withoutFilter($query); + } + /** @return array service_category => enabled, only stored rows */ public function enabledMapFor(string $entityType, int $entityId): array { - $rows = $this->findBy(['entityType' => $entityType, 'entityId' => $entityId]); + $rows = $this->unscoped(fn (): array => $this->findBy(['entityType' => $entityType, 'entityId' => $entityId])); $map = []; foreach ($rows as $row) { @@ -29,11 +45,11 @@ class TenantServiceCategorySettingRepository extends ServiceEntityRepository public function findOneFor(string $entityType, int $entityId, ServiceCategory $category): ?TenantServiceCategorySetting { - return $this->findOneBy([ + return $this->unscoped(fn (): ?TenantServiceCategorySetting => $this->findOneBy([ 'entityType' => $entityType, 'entityId' => $entityId, 'serviceCategory' => $category, - ]); + ])); } public function save(TenantServiceCategorySetting $entity, bool $flush = true): void diff --git a/src/Insurance/Service/InsuranceScopeResolver.php b/src/Insurance/Service/InsuranceScopeResolver.php new file mode 100644 index 00000000..f020e026 --- /dev/null +++ b/src/Insurance/Service/InsuranceScopeResolver.php @@ -0,0 +1,72 @@ +pick( + $doctorId, + $clinicId, + fn (int $id): bool => $this->contracts->findActiveByTenant('doctor', $id) !== [], + ); + } + + /** @return array{0: string, 1: int} */ + public function forVisitPrice(int $doctorId, ?int $clinicId): array + { + return $this->pick( + $doctorId, + $clinicId, + fn (int $id): bool => $this->pricing->findByEntity('doctor', $id) !== [], + ); + } + + /** @return array{0: string, 1: int} */ + public function forServiceCategories(int $doctorId, ?int $clinicId): array + { + return $this->pick( + $doctorId, + $clinicId, + fn (int $id): bool => $this->categorySettings->enabledMapFor('doctor', $id) !== [], + ); + } + + /** + * @param callable(int):bool $doctorHasOwn + * @return array{0: string, 1: int} + */ + private function pick(int $doctorId, ?int $clinicId, callable $doctorHasOwn): array + { + if ($clinicId === null || $doctorHasOwn($doctorId)) { + return ['doctor', $doctorId]; + } + + return ['clinic', $clinicId]; + } +} diff --git a/src/Insurance/Service/TenantInsuranceService.php b/src/Insurance/Service/TenantInsuranceService.php index 4afe08d5..a1d02a85 100644 --- a/src/Insurance/Service/TenantInsuranceService.php +++ b/src/Insurance/Service/TenantInsuranceService.php @@ -133,8 +133,11 @@ class TenantInsuranceService /** * هر نوع خدمتی که tenant آن را بیمه‌ای کرده باید در پایانِ این ذخیره‌سازی درصد * پوشش مؤثر داشته باشد — از خودِ payload، از override قبلی، یا از پیش‌فرض مرکزی - * ادمین. fallback زنده حفظ می‌شود؛ چیزی که رد می‌شود قراردادی است که نوع خدمتِ - * فعال را عملاً صفر درصد می‌کند. + * ادمین. fallback زنده حفظ می‌شود؛ چیزی که رد می‌شود نوع خدمتِ فعالی است که هیچ + * درصدی برایش تعیین نشده. + * + * صفر مقدارِ معتبری است و یعنی «این قرارداد آن نوع خدمت را پوشش نمی‌دهد» — سهم + * بیمار صددرصد. آنچه ممنوع است، خالی‌ماندنِ مقدار است، نه صفر بودنش. * * نیامدنِ کلید `category_coverages` اصلاً به اینجا نمی‌رسد — آن حالت یعنی * «قرارداد دست‌نخورده روی همان مسیر resolve بماند». @@ -153,17 +156,22 @@ class TenantInsuranceService $defaults = $this->coverageDefaults->percentMap($contract->getInsuranceId()); foreach ($this->serviceCategories->enabledKeys($contract->getEntityType(), $contract->getEntityId()) as $key) { - // ارسال صریحِ null یعنی «override را بردار»، پس نباید خودِ همان override - // که همین حالا حذف می‌شود، اعتبارسنجی را نجات بدهد. // ستون قدیمیِ coverage_percent قرارداد عمداً fallback حساب نمی‌شود: با آن، // نوع خدمتی که درصدش نیامده بی‌صدا نرخ نوع دیگر را ارث می‌برد. - $percent = match (true) { + // `null` صریح یعنی «override را بردار»، پس ردیفی که همین حالا حذف می‌شود + // نباید اعتبارسنجی را نجات بدهد؛ آن حالت به پیش‌فرض مرکزی برمی‌گردد. + // صفرِ *انتخاب‌شده* معتبر است، صفرِ *به‌ارث‌رسیده* نه: پیش‌فرض مرکزیِ صفر + // یعنی ادمین هنوز نرخی نگذاشته، و قراردادی که روی آن بنشیند بی‌آنکه کسی + // تصمیم گرفته باشد، آن نوع خدمت را صددرصد به گردن بیمار می‌اندازد. + $chosen = match (true) { array_key_exists($key, $sent) && $sent[$key] !== null && $sent[$key] !== '' => (float) $sent[$key], - array_key_exists($key, $sent) => $defaults[$key] ?? 0.0, - default => $overrides[$key] ?? $defaults[$key] ?? 0.0, + array_key_exists($key, $sent) => null, + default => $overrides[$key] ?? null, }; - if ($percent <= 0) { + $percent = $chosen ?? (($defaults[$key] ?? 0.0) > 0 ? $defaults[$key] : null); + + if ($percent === null) { throw new AppException( ErrorCodes::ERR_VALIDATION_001, sprintf('درصد پوشش %s الزامی است', ServiceCategory::from($key)->label()), diff --git a/src/Patient/Service/PatientService.php b/src/Patient/Service/PatientService.php index 56efc45c..f0904c73 100644 --- a/src/Patient/Service/PatientService.php +++ b/src/Patient/Service/PatientService.php @@ -9,6 +9,7 @@ use App\Billing\ValueObject\Money; use App\ClinicService\Repository\ServiceItemRepository; use App\Insurance\Enum\ServiceCategory; use App\Insurance\Repository\EntityInsurancePricingRepository; +use App\Insurance\Service\InsuranceScopeResolver; use App\Insurance\Service\TenantInsuranceService; use App\Inventory\Repository\InventoryItemRepository; use App\Inventory\Repository\InventoryPackageRepository; @@ -50,6 +51,7 @@ class PatientService private readonly \App\Billing\Service\SessionBillingService $sessionBilling, private readonly WalletService $walletService, private readonly EntityInsurancePricingRepository $pricingRepo, + private readonly InsuranceScopeResolver $insuranceScope, private readonly \App\Discount\Service\DiscountEngine $discountEngine, private readonly \App\Patient\Repository\SessionAuditLogRepository $auditRepo, private readonly \App\Shared\Tenant\TenantOwnershipChecker $tenantOwnership, @@ -216,9 +218,18 @@ class PatientService // زمان مراجعه = زمان واقعی نوبت. $session->setSessionAt($appointment->getSlotStart()); - // هزینه ویزیت: از نوبت، در نبود آن از «قیمت ویزیت آزاد» تنظیمات همین tenant. + // هزینه ویزیت: از نوبت، در نبود آن «قیمت ویزیت آزاد». + // + // پرونده به محیط نوبت تعلق دارد، ولی قیمت لزوماً آنجا ثبت نشده: پزشکِ عضو + // کلینیک قیمت خودش را دارد و اگر نداشت قیمت کلینیک اعمال می‌شود. + // {@see InsuranceScopeResolver} + [$priceType, $priceId] = $this->insuranceScope->forVisitPrice( + (int) $appointment->getDoctor()->getId(), + $appointment->getClinic()?->getId() !== null ? (int) $appointment->getClinic()->getId() : null, + ); + $visitPrice = (int) ($appointment->getVisitPriceRials() - ?? $this->pricingRepo->findOneForInsurance($entityType, $entityId, null)?->getPatientShareRials() + ?? $this->pricingRepo->findOneForInsurance($priceType, $priceId, null)?->getPatientShareRials() ?? 0); $session->setVisitPriceRials($visitPrice); diff --git a/tests/Insurance/DoctorFirstThenClinicScopeTest.php b/tests/Insurance/DoctorFirstThenClinicScopeTest.php new file mode 100644 index 00000000..9904be96 --- /dev/null +++ b/tests/Insurance/DoctorFirstThenClinicScopeTest.php @@ -0,0 +1,179 @@ +createUser(['ROLE_USER', 'ROLE_DOCTOR']); + $doctor = new Doctor($doctorUser, 'دکتر عضو کلینیک بیمه'); + $doctor->setMobileNumber($doctorUser->getMobileNumber()); + $this->em->persist($doctor); + + $owner = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']); + $clinic = new Clinic($owner); + $clinic->setName('کلینیک محدودهٔ بیمه'); + $clinic->getDoctors()->add($doctor); + $this->em->persist($clinic); + $this->em->flush(); + + $this->em->persist(new UserActiveContext( + $this->em->find(User::class, $owner->getId()), + $clinic->getUuid(), + 'clinic', + )); + $this->em->flush(); + + return [$owner, $doctor, $clinic]; + } + + private function insurance(): Insurance + { + $insurance = new Insurance('بیمه محدوده ' . random_int(1000, 9999), InsuranceType::Basic); + $this->em->persist($insurance); + $this->em->flush(); + + return $insurance; + } + + private function appointmentAtClinic(Doctor $doctor, Clinic $clinic, User $patient): Appointment + { + $appointment = $this->newAppointment($doctor, $patient, time() + 3600, time() + 5400, $clinic); + $this->em->persist($appointment); + $this->em->flush(); + + return $appointment; + } + + public function testAContractStoredOnTheDoctorAppliesToTheirClinicAppointment(): void + { + [$owner, $doctor, $clinic] = $this->clinicWithMemberDoctor(); + $insurance = $this->insurance(); + + $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()); + + $appointment = $this->appointmentAtClinic($doctor, $clinic, $this->createUser(['ROLE_USER'])); + + $this->authJson('PATCH', '/api/v1/appointment/' . $appointment->getUuid(), $owner, [ + 'insurance_base_id' => $insurance->getId(), + ]); + + self::assertSame(200, $this->responseCode(), 'قرارداد پزشک باید روی نوبت کلینیک اعمال شود'); + } + + public function testTheClinicContractIsInheritedWhenTheDoctorHasNone(): void + { + [$owner, $doctor, $clinic] = $this->clinicWithMemberDoctor(); + $insurance = $this->insurance(); + + // قرارداد روی خودِ کلینیک، بدون هیچ قراردادی برای پزشک. + $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [ + 'insurance_id' => $insurance->getId(), + 'coverage_percent' => 60, + ]); + self::assertSame(201, $this->responseCode()); + + $appointment = $this->appointmentAtClinic($doctor, $clinic, $this->createUser(['ROLE_USER'])); + + $this->authJson('PATCH', '/api/v1/appointment/' . $appointment->getUuid(), $owner, [ + 'insurance_base_id' => $insurance->getId(), + ]); + + self::assertSame(200, $this->responseCode(), 'در نبود قرارداد پزشک، قرارداد کلینیک اعمال می‌شود'); + } + + /** + * همان قاعده در خواندن: مودالِ قطعی‌کردن با `inherit=1` می‌پرسد «برای این پزشک + * چه چیزی اعمال می‌شود» و باید قرارداد پزشک را ببیند، نه فهرست خالیِ کلینیک. + */ + public function testInheritReadReturnsTheDoctorContractToTheClinicOwner(): void + { + [$owner, $doctor, ] = $this->clinicWithMemberDoctor(); + $insurance = $this->insurance(); + + $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [ + 'doctor_uuid' => $doctor->getUuid(), + 'insurance_id' => $insurance->getId(), + 'coverage_percent' => 80, + ]); + + $body = $this->authJson( + 'GET', + '/api/v1/billing/tenant-insurances?doctor_uuid=' . $doctor->getUuid() . '&inherit=1', + $owner, + ); + + $ids = array_map(static fn (array $row): int => (int) $row['insurance_id'], $body['data']['data']); + self::assertContains($insurance->getId(), $ids); + } + + public function testTheVisitPriceOfTheDoctorWinsOverTheClinicOne(): void + { + [$owner, $doctor, ] = $this->clinicWithMemberDoctor(); + + $this->authJson('PUT', '/api/v1/insurance-pricing', $owner, ['free_visit_price_rials' => 1_000_000]); + $this->authJson('PUT', '/api/v1/insurance-pricing', $owner, [ + 'doctor_uuid' => $doctor->getUuid(), + 'free_visit_price_rials' => 2_500_000, + ]); + + $inherited = $this->authJson( + 'GET', + '/api/v1/insurance-pricing?doctor_uuid=' . $doctor->getUuid() . '&inherit=1', + $owner, + ); + + self::assertSame(2_500_000, $inherited['data']['free_visit_price_rials']); + } + + public function testTheClinicVisitPriceIsInheritedWhenTheDoctorHasNone(): void + { + [$owner, $doctor, ] = $this->clinicWithMemberDoctor(); + + $this->authJson('PUT', '/api/v1/insurance-pricing', $owner, ['free_visit_price_rials' => 1_000_000]); + + $inherited = $this->authJson( + 'GET', + '/api/v1/insurance-pricing?doctor_uuid=' . $doctor->getUuid() . '&inherit=1', + $owner, + ); + + self::assertSame(1_000_000, $inherited['data']['free_visit_price_rials']); + } + + /** بدون `inherit`، صفحهٔ تنظیمات باید ردیفِ خودِ پزشک را ببیند — حتی وقتی خالی است. */ + public function testTheSettingsReadStaysOnTheDoctorWithoutTheInheritFlag(): void + { + [$owner, $doctor, ] = $this->clinicWithMemberDoctor(); + + $this->authJson('PUT', '/api/v1/insurance-pricing', $owner, ['free_visit_price_rials' => 1_000_000]); + + $own = $this->authJson('GET', '/api/v1/insurance-pricing?doctor_uuid=' . $doctor->getUuid(), $owner); + + self::assertSame(0, $own['data']['free_visit_price_rials']); + } +} diff --git a/tests/Insurance/TenantInsuranceCategoryCoverageApiTest.php b/tests/Insurance/TenantInsuranceCategoryCoverageApiTest.php index cfc081ce..73938ac4 100644 --- a/tests/Insurance/TenantInsuranceCategoryCoverageApiTest.php +++ b/tests/Insurance/TenantInsuranceCategoryCoverageApiTest.php @@ -88,6 +88,26 @@ class TenantInsuranceCategoryCoverageApiTest extends ApiTestCase $this->assertStringContainsString('خدمات بستری', $body['errors'][0]['message']); } + /** + * صفر مقدارِ انتخاب‌شدنی است: یعنی این قرارداد آن نوع خدمت را پوشش نمی‌دهد و سهم + * بیمار صددرصد است. آنچه رد می‌شود، خالی‌گذاشتنِ مقدار است. + */ + public function testZeroIsAValidChosenPercentMeaningNoCoverage(): void + { + [$owner, $insurance] = $this->makeDoctorAndInsurance(); + + $body = $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [ + 'insurance_id' => $insurance->getId(), + 'category_coverages' => [ + ['key' => 'outpatient', 'coverage_percent' => 70], + ['key' => 'inpatient', 'coverage_percent' => 0], + ], + ]); + + $this->assertSame(201, $this->responseCode()); + $this->assertEquals(0.0, $body['data']['data']['category_coverages']['inpatient']); + } + public function testADisabledServiceKindNeedsNoPercent(): void { [$owner, $insurance] = $this->makeDoctorAndInsurance();