Files
clinicpro/src/Appointment/Service/AppointmentInsuranceService.php
T
hamedandClaude Opus 5 b24f45cc83 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>
2026-08-18 17:18:35 +03:30

186 lines
7.8 KiB
PHP

<?php
namespace App\Appointment\Service;
use App\Appointment\Entity\Appointment;
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;
/**
* The insurance an appointment is billed with: which service kind (outpatient/inpatient)
* and which basic insurance. Single place so the PATCH endpoint, the confirm endpoint and
* the session/invoice pipeline agree on the same rules.
*/
class AppointmentInsuranceService
{
public function __construct(
private readonly TenantServiceCategoryService $serviceCategories,
private readonly TenantInsuranceRepository $tenantInsuranceRepo,
private readonly InsuranceRepository $insuranceRepo,
private readonly InsuranceScopeResolver $scope,
) {}
/**
* موجودیتِ صاحب نوبت — کلینیک اگر نوبت در کلینیک باشد، وگرنه خودِ پزشک.
* همان تفکیکی که PatientService برای ساخت پرونده به‌کار می‌برد.
*
* @return array{0: string, 1: int}
*/
public function tenantOf(Appointment $appointment): array
{
$clinic = $appointment->getClinic();
return $clinic !== null
? ['clinic', (int) $clinic->getId()]
: ['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،
* وگرنه سرپایی (رفتار تاریخیِ ویزیت).
*/
public function effectiveCategory(Appointment $appointment): ServiceCategory
{
if ($appointment->getInsuranceServiceCategory() !== null) {
return $appointment->getInsuranceServiceCategory();
}
[$entityType, $entityId] = $this->categoryScopeOf($appointment);
return $this->serviceCategories->defaultCategory($entityType, $entityId) ?? ServiceCategory::Outpatient;
}
/**
* انتخاب بیمهٔ نوبت را از بدنهٔ درخواست اعمال می‌کند. کلیدهای نیامده دست‌نخورده
* می‌مانند؛ رشتهٔ خالی یا null یعنی پاک‌کردن انتخاب.
*
* @param array<string, mixed> $data
* @throws AppException ۴۲۲ برای نوع خدمتِ نامعتبر/غیرفعال یا بیمهٔ بدون قرارداد فعال
*/
public function apply(Appointment $appointment, array $data): void
{
// هر کدام محیط خودش را دارد: پزشکی که قرارداد بیمهٔ خودش را دارد ولی نوع
// خدمات را به کلینیک سپرده، هر دو را از جای درست می‌گیرد.
[$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'], $categoryType, $categoryId)
);
}
if (array_key_exists('insurance_base_id', $data)) {
$appointment->setInsuranceBaseId(
$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'], $contractType, $contractId, true)
);
}
}
private function resolveCategory(mixed $raw, string $entityType, int $entityId): ?ServiceCategory
{
if ($raw === null || $raw === '') {
return null;
}
$category = ServiceCategory::tryFromValue((string) $raw);
if ($category === null) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
'نوع خدمت نامعتبر است: ' . implode('، ', ServiceCategory::values()),
422,
'insurance_service_category',
);
}
if (!$this->serviceCategories->isEnabled($entityType, $entityId, $category)) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
sprintf('«%s» در تنظیمات بیمه فعال نیست', $category->label()),
422,
'insurance_service_category',
);
}
return $category;
}
/**
* شناسهٔ بیمهٔ معتبر برای این نوبت، یا null وقتی انتخاب پاک شده است.
* قرارداد باید فعال باشد و نوعش با جایگاهی که در آن انتخاب شده بخواند —
* بیمهٔ تکمیلی نمی‌تواند جای بیمهٔ پایه بنشیند و برعکس.
*/
private function resolveInsuranceId(mixed $raw, string $entityType, int $entityId, bool $supplementary): ?int
{
$field = $supplementary ? 'insurance_supplementary_id' : 'insurance_base_id';
if ($raw === null || $raw === '' || (int) $raw <= 0) {
return null;
}
$insuranceId = (int) $raw;
$contract = $this->tenantInsuranceRepo->findActiveContract($entityType, $entityId, $insuranceId);
if ($contract === null) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
'این بیمه برای این پزشک/کلینیک قرارداد فعال ندارد',
422,
$field,
);
}
// نوعِ قرارداد بر نوع کاتالوگ اولویت دارد — همان قاعدهٔ TenantInsuranceService.
$kind = $contract->getKind() ?? $this->insuranceRepo->find($insuranceId)?->getType()->value;
$isSupp = $kind === InsuranceType::Supplementary->value;
if ($isSupp !== $supplementary) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
$supplementary ? 'اینجا فقط بیمهٔ تکمیلی قابل انتخاب است' : 'اینجا فقط بیمهٔ پایه قابل انتخاب است',
422,
$field,
);
}
return $insuranceId;
}
}