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,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);