fix(insurance): read a doctor's own settings first, then their clinic's

A clinic owner configures insurance on the doctor (`doctor_uuid`), but an
appointment booked at the clinic belongs to the clinic — so at confirm time
the engine looked for contracts under the clinic, found none, and the operator
had no insurance to pick and no way to save one ("this insurance has no active
contract"). The two sides were writing and reading different tenants.

Contracts, service kinds and the visit price now resolve doctor-first with the
appointment's clinic as fallback, each judged separately: a doctor who holds
their own contracts but leaves the visit price to the clinic gets each from the
right place. The confirm modal asks the same question the engine answers, via
`inherit=1` on the two read endpoints; the settings pages deliberately do not
send it, since editing must target the doctor's own row.

Two further things came out of the same sweep. The service-kind settings
repository had the tenant-filter blindness already fixed for contracts and
pricing — reads pinned to the caller's environment while the target is another
tenant — so it is now exempted the same way. And a coverage percentage of zero
is accepted as a real choice meaning "this contract does not cover that service
kind"; what is still rejected is leaving an enabled kind with no percentage at
all, inheriting a central default of zero included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-18 17:18:35 +03:30
co-authored by Claude Opus 5
parent 1f64b516d2
commit b24f45cc83
14 changed files with 437 additions and 29 deletions
+7 -2
View File
@@ -74,10 +74,15 @@ function percentsToStrings(map?: Record<string, number>): Record<string, string>
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;
}
/**
@@ -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);
// نوبتِ بدون هزینهٔ ویزیت، سرِ ساختِ مراجعه «قیمت ویزیت آزاد» تنظیمات را می‌گیرد؛
// مودال هم باید همان را نشان دهد، وگرنه صفر نشان می‌دهد و مبلغ ثبت‌شده فرق می‌کند.
@@ -72,7 +72,8 @@ describe('TurnsTimeline', () => {
it('نوبتِ دارای بیمه، چیپ «نوع خدمت · بیمه» نشان می‌دهد', async () => {
(api.get as ReturnType<typeof vi.fn>).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,
@@ -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),
+12 -5
View File
@@ -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<ApiResponse<PricingPayload>>({
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<ApiResponse<{ data: TenantContract[] }>>({
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,
});
+8
View File
@@ -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)
@@ -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)
);
}
}
@@ -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 = [];
@@ -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<string, bool> 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
@@ -0,0 +1,72 @@
<?php
namespace App\Insurance\Service;
use App\Insurance\Repository\EntityInsurancePricingRepository;
use App\Insurance\Repository\TenantInsuranceRepository;
use App\Insurance\Repository\TenantServiceCategorySettingRepository;
/**
* «این تنظیمِ بیمه از کدام محیط خوانده شود؟»
*
* پزشکِ عضو کلینیک دو محیط دارد و تنظیمات بیمه‌اش می‌تواند در هر کدام ثبت شده باشد:
* پنل کلینیک آن را روی خودِ پزشک می‌نویسد (`doctor_uuid`)، ولی نوبتِ ثبت‌شده در
* کلینیک محیطش «کلینیک» است. تا امروز این دو هرگز به هم نمی‌رسیدند: قرارداد روی
* پزشک ذخیره می‌شد و هنگام قطعی‌کردنِ نوبت، لیست از محیط کلینیک خوانده می‌شد و
* خالی بود — کاربر می‌دید «بیمه‌ای برای انتخاب نیست».
*
* قاعده: اول پزشک، بعد کلینیک. تنظیمِ خودِ پزشک همیشه برنده است؛ نبودنش یعنی
* «کلینیک برایم تصمیم می‌گیرد». هر نوع تنظیم جدا سنجیده می‌شود، چون پزشک ممکن است
* قرارداد بیمهٔ خودش را داشته باشد ولی قیمت ویزیت را به کلینیک سپرده باشد.
*/
final class InsuranceScopeResolver
{
public function __construct(
private readonly TenantInsuranceRepository $contracts,
private readonly EntityInsurancePricingRepository $pricing,
private readonly TenantServiceCategorySettingRepository $categorySettings,
) {}
/** @return array{0: string, 1: int} */
public function forContracts(int $doctorId, ?int $clinicId): array
{
return $this->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];
}
}
@@ -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()),
+13 -2
View File
@@ -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);
@@ -0,0 +1,179 @@
<?php
namespace App\Tests\Insurance;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\Auth\Entity\UserActiveContext;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Insurance\Entity\Insurance;
use App\Insurance\Enum\InsuranceType;
use App\Tests\ApiTestCase;
/**
* تنظیمات بیمه: اول خودِ پزشک، در نبودِ تنظیمِ او کلینیک.
*
* پنل کلینیک قرارداد را روی خودِ پزشک ذخیره می‌کند (`doctor_uuid`)، ولی نوبتِ
* ثبت‌شده در کلینیک محیطش «کلینیک» است. تا پیش از این، هنگام قطعی‌کردنِ نوبت لیست
* بیمه از محیط کلینیک خوانده می‌شد و خالی بود: کاربر بیمه‌ای برای انتخاب نداشت و
* انتخابِ دستی هم با «قرارداد فعال ندارد» رد می‌شد.
*/
class DoctorFirstThenClinicScopeTest extends ApiTestCase
{
/** @return array{0: User, 1: Doctor, 2: Clinic} */
private function clinicWithMemberDoctor(): array
{
$doctorUser = $this->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']);
}
}
@@ -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();