feat(insurance): bill an appointment with a chosen service kind and insurance
An appointment can now carry the insurance it is billed with: the service kind (outpatient/inpatient) and the basic insurance. Confirming it no longer hands the whole amount to the patient — the visit is split through BillingCalculator with the coverage percent of that service kind, and the choice travels to the encounter and the invoice built from it. The enabled service kinds are a tenant-wide setting (all of that tenant's insurances share it), so a tenant covering only one kind is never asked which one: the panel resolves it the same way the server does. - add tenant_service_category_settings + TenantServiceCategoryService, exposed on the existing insurance-pricing endpoint (service_categories, default_service_category); at least one kind must stay enabled - add appointments.insurance_service_category / insurance_base_id with AppointmentInsuranceService validating them against the tenant's own settings and active contracts (basic only), accepted by PATCH and by confirm - snapshot the kind on patient_sessions and invoices; the visit's coverage rule is resolved per kind (services keep using their own ServiceItem.service_category) - lib/insuranceShares becomes the single client-side mirror of BillingCalculator, shared by the confirm modal, the appointment edit page and the session form - surface the selection: confirm modal (with live shares), turns timeline chip, appointment edit page, patient record service card and invoice summary - the session form shows the insurance block whenever the tenant has an active contract and prefills the patient's own insurance, so it can be changed - fix: the confirm modal showed a zero visit price when the appointment had none — it now falls back to the tenant's free-visit price like the server - fix: useServiceCategories read one level too shallow, so Persian labels never arrived and raw enum keys leaked into the contract summary - fix: BlogsPage test asserted the public blogs endpoint after the page moved to the admin one Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,7 @@ class AppointmentController extends BaseController
|
||||
private readonly \App\Staff\Repository\ClinicStaffRepository $staffRepo,
|
||||
private readonly \App\Appointment\Repository\AppointmentEventRepository $eventRepo,
|
||||
private readonly \App\Appointment\Security\AppointmentAccessChecker $accessChecker,
|
||||
private readonly \App\Appointment\Service\AppointmentInsuranceService $appointmentInsurance,
|
||||
private readonly \Psr\Log\LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
@@ -1026,6 +1027,10 @@ class AppointmentController extends BaseController
|
||||
];
|
||||
}
|
||||
|
||||
// انتخاب بیمه سرِ پذیرش: قبل از ساخت مراجعه روی نوبت مینشیند تا سهمها با
|
||||
// همان بیمه محاسبه شوند.
|
||||
$this->appointmentInsurance->apply($appointment, $data);
|
||||
|
||||
try {
|
||||
$session = $this->appointmentConfirmation->confirmWithPayments($appointment, $version, $payments, $user);
|
||||
} catch (OptimisticLockException) {
|
||||
@@ -1043,6 +1048,13 @@ class AppointmentController extends BaseController
|
||||
'paid_total_rials' => $session->getPaidTotalRials(),
|
||||
'remaining_rials' => $session->getRemainingRials(),
|
||||
'is_paid' => $session->getRemainingRials() === 0,
|
||||
// تفکیک بیمه — مودالِ قطعیکردن همان مبلغی را نشان میدهد که ثبت شده.
|
||||
'insurance_service_category' => $session->getInsuranceServiceCategory()?->value,
|
||||
'insurance_base_id' => $session->getInsuranceBaseId(),
|
||||
'gross_total_rials' => $session->getGrossTotalRials(),
|
||||
'base_insurance_rials' => $session->getBaseInsuranceRials(),
|
||||
'supplementary_insurance_rials' => $session->getSupplementaryInsuranceRials(),
|
||||
'patient_share_rials' => $session->getPatientShareRials(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -1126,6 +1138,7 @@ class AppointmentController extends BaseController
|
||||
if (array_key_exists('note', $data)) {
|
||||
$appointment->setNote($data['note'] !== null ? trim((string) $data['note']) : null);
|
||||
}
|
||||
$this->appointmentInsurance->apply($appointment, $data);
|
||||
// جایگزینی نوبت — swap the person occupying the slot.
|
||||
if (array_key_exists('patient_name', $data)) {
|
||||
$appointment->setPatientName($data['patient_name'] !== null ? trim((string) $data['patient_name']) : null);
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Appointment\Entity;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Insurance\Enum\ServiceCategory;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
@@ -171,6 +172,17 @@ class Appointment
|
||||
#[ORM\Column(name: 'visit_price_rials', type: 'integer', nullable: true)]
|
||||
private ?int $visitPriceRials = null;
|
||||
|
||||
/**
|
||||
* نوع خدمتِ بیمهایِ این نوبت (سرپایی/بستری) — مبنای انتخاب درصد پوشش.
|
||||
* null یعنی هنوز انتخاب نشده؛ محاسبه به نوع پیشفرضِ tenant برمیگردد.
|
||||
*/
|
||||
#[ORM\Column(name: 'insurance_service_category', type: 'string', length: 30, nullable: true, enumType: ServiceCategory::class)]
|
||||
private ?ServiceCategory $insuranceServiceCategory = null;
|
||||
|
||||
/** بیمهٔ پایهٔ انتخابشده؛ ارجاع خام int مثل TenantInsurance/Tariff. */
|
||||
#[ORM\Column(name: 'insurance_base_id', type: 'integer', nullable: true)]
|
||||
private ?int $insuranceBaseId = null;
|
||||
|
||||
/**
|
||||
* Reserve-list entry (نوبت رزرو): booked for a day, not a time slot.
|
||||
* slotStart/slotEnd hold that day's midnight so date queries keep working.
|
||||
@@ -265,6 +277,8 @@ class Appointment
|
||||
public function isDepositRequired(): bool { return $this->depositRequired; }
|
||||
public function getDepositAmountRials(): ?int { return $this->depositAmountRials; }
|
||||
public function getVisitPriceRials(): ?int { return $this->visitPriceRials; }
|
||||
public function getInsuranceServiceCategory(): ?ServiceCategory { return $this->insuranceServiceCategory; }
|
||||
public function getInsuranceBaseId(): ?int { return $this->insuranceBaseId; }
|
||||
public function isReserve(): bool { return $this->isReserve; }
|
||||
|
||||
public function setServiceSection(?\App\ClinicService\Entity\ServiceSection $v): self { $this->serviceSection = $v; return $this; }
|
||||
@@ -273,6 +287,8 @@ class Appointment
|
||||
public function setDepositRequired(bool $v): self { $this->depositRequired = $v; return $this; }
|
||||
public function setDepositAmountRials(?int $v): self { $this->depositAmountRials = $v; return $this; }
|
||||
public function setVisitPriceRials(?int $v): self { $this->visitPriceRials = $v; return $this; }
|
||||
public function setInsuranceServiceCategory(?ServiceCategory $v): self { $this->insuranceServiceCategory = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setInsuranceBaseId(?int $v): self { $this->insuranceBaseId = $v; $this->updatedAt = time(); return $this; }
|
||||
|
||||
/**
|
||||
* Move the appointment to a new slot (جا به جایی نوبت) and/or flip its
|
||||
@@ -368,6 +384,10 @@ class Appointment
|
||||
'uuid' => $i->getUuid(),
|
||||
'name' => $i->getName(),
|
||||
'price_rials' => $i->getPriceRials(),
|
||||
// نوع خدمت و پرچم پوشش تا مودال بتواند سهم بیمهٔ هر خدمت را
|
||||
// مثل سرور حساب کند (درصد بهازای نوع خدمت است).
|
||||
'service_category' => $i->getServiceCategory()->value,
|
||||
'insurance_covered' => $i->isInsuranceCovered(),
|
||||
],
|
||||
$this->serviceItems->toArray()
|
||||
),
|
||||
@@ -375,6 +395,9 @@ class Appointment
|
||||
'deposit_required' => $this->depositRequired,
|
||||
'deposit_amount_rials' => $this->depositAmountRials,
|
||||
'visit_price_rials' => $this->visitPriceRials,
|
||||
'insurance_service_category' => $this->insuranceServiceCategory?->value,
|
||||
'insurance_service_category_label' => $this->insuranceServiceCategory?->label(),
|
||||
'insurance_base_id' => $this->insuranceBaseId,
|
||||
'is_reserve' => $this->isReserve,
|
||||
'version' => $this->version,
|
||||
'created_at' => $this->createdAt,
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<?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\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,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* موجودیتِ صاحب نوبت — کلینیک اگر نوبت در کلینیک باشد، وگرنه خودِ پزشک.
|
||||
* همان تفکیکی که 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()];
|
||||
}
|
||||
|
||||
/**
|
||||
* نوع خدمتِ مؤثر برای محاسبه: انتخابِ نوبت، وگرنه تنها نوع فعالِ tenant،
|
||||
* وگرنه سرپایی (رفتار تاریخیِ ویزیت).
|
||||
*/
|
||||
public function effectiveCategory(Appointment $appointment): ServiceCategory
|
||||
{
|
||||
if ($appointment->getInsuranceServiceCategory() !== null) {
|
||||
return $appointment->getInsuranceServiceCategory();
|
||||
}
|
||||
|
||||
[$entityType, $entityId] = $this->tenantOf($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
|
||||
{
|
||||
[$entityType, $entityId] = $this->tenantOf($appointment);
|
||||
|
||||
if (array_key_exists('insurance_service_category', $data)) {
|
||||
$appointment->setInsuranceServiceCategory(
|
||||
$this->resolveCategory($data['insurance_service_category'], $entityType, $entityId)
|
||||
);
|
||||
}
|
||||
|
||||
if (array_key_exists('insurance_base_id', $data)) {
|
||||
$appointment->setInsuranceBaseId(
|
||||
$this->resolveBaseInsuranceId($data['insurance_base_id'], $entityType, $entityId)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private function resolveBaseInsuranceId(mixed $raw, string $entityType, int $entityId): ?int
|
||||
{
|
||||
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,
|
||||
'insurance_base_id',
|
||||
);
|
||||
}
|
||||
|
||||
// نوعِ قرارداد بر نوع کاتالوگ اولویت دارد — همان قاعدهٔ TenantInsuranceService.
|
||||
$kind = $contract->getKind() ?? $this->insuranceRepo->find($insuranceId)?->getType()->value;
|
||||
if ($kind === InsuranceType::Supplementary->value) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
'برای نوبت فقط بیمهٔ پایه قابل انتخاب است',
|
||||
422,
|
||||
'insurance_base_id',
|
||||
);
|
||||
}
|
||||
|
||||
return $insuranceId;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user