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:
hamed
2026-07-25 17:50:14 +03:30
co-authored by Claude Opus 5
parent 58c6d9ac18
commit 1f58b1b9b3
47 changed files with 2693 additions and 86 deletions
@@ -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);
+23
View File
@@ -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;
}
}
+9
View File
@@ -3,6 +3,7 @@
namespace App\Billing\Entity;
use App\Billing\Repository\InvoiceRepository;
use App\Insurance\Enum\ServiceCategory;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
@@ -45,6 +46,10 @@ class Invoice
#[ORM\Column(name: 'supplementary_insurance_id', type: 'integer', nullable: true)]
private ?int $supplementaryInsuranceId = null;
/** نوع خدمتِ بیمه‌ای که فاکتور با آن محاسبه شده — snapshot از مراجعه. */
#[ORM\Column(name: 'service_category', type: 'string', length: 30, nullable: true, enumType: ServiceCategory::class)]
private ?ServiceCategory $serviceCategory = null;
#[ORM\Column(name: 'total_rials', type: 'integer')]
private int $totalRials = 0;
@@ -100,6 +105,8 @@ class Invoice
public function setPatientSessionId(?int $v): self { $this->patientSessionId = $v; return $this; }
public function setPatientRecordId(?int $v): self { $this->patientRecordId = $v; return $this; }
public function setBaseInsuranceId(?int $v): self { $this->baseInsuranceId = $v; return $this; }
public function setServiceCategory(?ServiceCategory $v): self { $this->serviceCategory = $v; return $this; }
public function getServiceCategory(): ?ServiceCategory { return $this->serviceCategory; }
public function setSupplementaryInsuranceId(?int $v): self { $this->supplementaryInsuranceId = $v; return $this; }
public function addItem(InvoiceItem $item): self
@@ -142,6 +149,8 @@ class Invoice
'patient_session_id' => $this->patientSessionId,
'patient_record_id' => $this->patientRecordId,
'base_insurance_id' => $this->baseInsuranceId,
'service_category' => $this->serviceCategory?->value,
'service_category_label' => $this->serviceCategory?->label(),
'supplementary_insurance_id' => $this->supplementaryInsuranceId,
'total_rials' => $this->totalRials,
'base_insurance_rials' => $this->baseInsuranceRials,
+25 -3
View File
@@ -7,6 +7,8 @@ use App\Billing\Entity\InvoiceItem;
use App\Billing\Repository\InvoiceRepository;
use App\Billing\ValueObject\Money;
use App\ClinicService\Service\TariffService;
use App\Insurance\Enum\ServiceCategory;
use App\Insurance\Repository\InsuranceRepository;
use App\Insurance\Service\TenantInsuranceService;
use App\Patient\Entity\PatientSession;
use App\Patient\Repository\PatientSessionRepository;
@@ -19,8 +21,21 @@ class InvoiceService
private readonly TenantInsuranceService $tenantInsuranceService,
private readonly BillingCalculator $calculator,
private readonly PatientSessionRepository $sessionRepo,
private readonly InsuranceRepository $insuranceRepo,
) {}
/** @var array<int, string|null> نام بیمه‌ها، یک‌بار در هر درخواست. */
private array $insuranceNameCache = [];
private function insuranceName(?int $id): ?string
{
if ($id === null) {
return null;
}
return $this->insuranceNameCache[$id] ??= $this->insuranceRepo->find($id)?->getName();
}
/**
* ساخت Invoice از یک Encounter (PatientSession).
* تعرفه‌ی هر خدمت از Tariff سال جاری (با fallback)، پوشش از قرارداد بیمه‌ی tenant.
@@ -42,11 +57,14 @@ class InvoiceService
$baseId = $session->getInsuranceBaseId();
$suppId = $session->getInsuranceSupplementaryId();
// ویزیت
// ویزیت — با نوع خدمتِ همان مراجعه (سرپایی/بستری)، snapshot روی خودِ فاکتور.
$visitCategory = $session->getInsuranceServiceCategory() ?? ServiceCategory::Outpatient;
$invoice->setServiceCategory($session->getInsuranceServiceCategory());
$visitPrice = $session->getVisitPriceRials();
if ($visitPrice > 0) {
$baseRule = $this->tenantInsuranceService->coverageRule($entityType, $entityId, $baseId);
$suppRule = $this->tenantInsuranceService->coverageRule($entityType, $entityId, $suppId);
$baseRule = $this->tenantInsuranceService->coverageRule($entityType, $entityId, $baseId, $visitCategory);
$suppRule = $this->tenantInsuranceService->coverageRule($entityType, $entityId, $suppId, $visitCategory);
$breakdown = $this->calculator->calculateItem(new Money($visitPrice), $baseRule, $suppRule);
$invoice->addItem(new InvoiceItem($invoice, 'ویزیت', $visitPrice, 1, $breakdown, null));
}
@@ -90,6 +108,10 @@ class InvoiceService
$session = $sessionId !== null ? $this->sessionRepo->find($sessionId) : null;
$data['session'] = $session?->toArray();
// نام بیمه‌ها برای چاپ روی فاکتور؛ فاکتور فقط شناسه را نگه می‌دارد.
$data['base_insurance_name'] = $this->insuranceName($invoice->getBaseInsuranceId());
$data['supplementary_insurance_name'] = $this->insuranceName($invoice->getSupplementaryInsuranceId());
return $data;
}
@@ -18,6 +18,7 @@ use App\Insurance\Repository\TenantInsuranceRepository;
use App\Insurance\Repository\TenantServiceCoverageRepository;
use App\Insurance\Service\InsuranceCoverageDefaultService;
use App\Insurance\Service\TenantInsuranceService;
use App\Insurance\Service\TenantServiceCategoryService;
use App\Shared\Constant\ErrorCodes;
use App\Secretary\Security\SecretaryAccessChecker;
use App\Shared\Controller\BaseController;
@@ -43,6 +44,7 @@ class InsuranceController extends BaseController
private readonly TenantServiceCoverageRepository $serviceCoverageRepo,
private readonly TenantInsuranceService $tenantInsuranceService,
private readonly InsuranceCoverageDefaultService $coverageDefaults,
private readonly TenantServiceCategoryService $serviceCategories,
private readonly ServiceItemRepository $serviceItemRepo,
private readonly FileValidatorService $fileValidator,
private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker,
@@ -350,11 +352,15 @@ class InsuranceController extends BaseController
}, $catalog);
return [
'entity_type' => $entityType,
'entity_id' => $entityId,
'free_visit_price_rials' => $freeVisitPriceRials,
'require_visit_price' => $requireVisitPrice,
'insurances' => $insurances,
'entity_type' => $entityType,
'entity_id' => $entityId,
'free_visit_price_rials' => $freeVisitPriceRials,
'require_visit_price' => $requireVisitPrice,
'insurances' => $insurances,
// نوع خدماتِ بیمه‌ایِ این tenant — سراسری برای همهٔ بیمه‌ها.
'service_categories' => $this->serviceCategories->settingsRows($entityType, $entityId),
// null یعنی چند نوع فعال است و کاربر باید سرِ پذیرش انتخاب کند.
'default_service_category' => $this->serviceCategories->defaultCategory($entityType, $entityId)?->value,
];
}
@@ -411,6 +417,10 @@ class InsuranceController extends BaseController
$this->pricingRepo->getEntityManager()->flush();
if (array_key_exists('service_categories', $data)) {
$this->serviceCategories->save($entityType, $entityId, (array) ($data['service_categories'] ?? []));
}
return $this->success($this->pricingPayload($entityType, $entityId));
}
@@ -0,0 +1,73 @@
<?php
namespace App\Insurance\Entity;
use App\Insurance\Enum\ServiceCategory;
use App\Insurance\Repository\TenantServiceCategorySettingRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* Which kinds of service a doctor/clinic covers with insurance at all. This is a
* tenant-wide switch shared by every insurance of that tenant — not a per-insurance
* setting. A missing row reads as enabled, so tenants created before this setting
* keep both kinds available.
*/
#[ORM\Entity(repositoryClass: TenantServiceCategorySettingRepository::class)]
#[ORM\Table(name: 'tenant_service_category_settings')]
#[ORM\UniqueConstraint(name: 'uniq_tenant_service_category_setting', columns: ['entity_type', 'entity_id', 'service_category'])]
class TenantServiceCategorySetting
{
public const TYPE_DOCTOR = 'doctor';
public const TYPE_CLINIC = 'clinic';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(name: 'entity_type', type: 'string', length: 10)]
private string $entityType;
#[ORM\Column(name: 'entity_id', type: 'integer')]
private int $entityId;
#[ORM\Column(name: 'service_category', type: 'string', length: 30, enumType: ServiceCategory::class)]
private ServiceCategory $serviceCategory;
#[ORM\Column(type: 'boolean', options: ['default' => true])]
private bool $enabled = true;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(string $entityType, int $entityId, ServiceCategory $serviceCategory, bool $enabled = true)
{
$this->entityType = $entityType;
$this->entityId = $entityId;
$this->serviceCategory = $serviceCategory;
$this->enabled = $enabled;
$this->updatedAt = time();
}
public function getId(): ?int { return $this->id; }
public function getEntityType(): string { return $this->entityType; }
public function getEntityId(): int { return $this->entityId; }
public function getServiceCategory(): ServiceCategory { return $this->serviceCategory; }
public function isEnabled(): bool { return $this->enabled; }
public function setEnabled(bool $v): self
{
$this->enabled = $v;
$this->updatedAt = time();
return $this;
}
public function toArray(): array
{
return [
'key' => $this->serviceCategory->value,
'label' => $this->serviceCategory->label(),
'enabled' => $this->enabled,
];
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Insurance\Repository;
use App\Insurance\Entity\TenantServiceCategorySetting;
use App\Insurance\Enum\ServiceCategory;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class TenantServiceCategorySettingRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TenantServiceCategorySetting::class);
}
/** @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]);
$map = [];
foreach ($rows as $row) {
$map[$row->getServiceCategory()->value] = $row->isEnabled();
}
return $map;
}
public function findOneFor(string $entityType, int $entityId, ServiceCategory $category): ?TenantServiceCategorySetting
{
return $this->findOneBy([
'entityType' => $entityType,
'entityId' => $entityId,
'serviceCategory' => $category,
]);
}
public function save(TenantServiceCategorySetting $entity, bool $flush = true): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
public function flush(): void
{
$this->getEntityManager()->flush();
}
}
@@ -216,12 +216,16 @@ class TenantInsuranceService
}
/**
* قانون پوشش ویزیت برای tenant جاری (برای BillingCalculator).
* ویزیت خدمتِ سرپایی است، پس درصد همان نوع خدمت resolve می‌شود.
* قانون پوشش ویزیت برای tenant جاری (برای BillingCalculator). درصد بر پایهٔ نوع
* خدمتِ داده‌شده resolve می‌شود؛ پیش‌فرض سرپایی است چون ویزیت آیتم سرویس نیست.
* اگر قرارداد فعالی نباشد، notCovered برمی‌گردد.
*/
public function coverageRule(string $entityType, int $entityId, ?int $insuranceId): CoverageRule
{
public function coverageRule(
string $entityType,
int $entityId,
?int $insuranceId,
ServiceCategory $category = ServiceCategory::Outpatient,
): CoverageRule {
if ($insuranceId === null) {
return CoverageRule::notCovered();
}
@@ -231,7 +235,7 @@ class TenantInsuranceService
return CoverageRule::notCovered();
}
return $this->buildRule($contract, ServiceCategory::Outpatient, null);
return $this->buildRule($contract, $category, null);
}
/**
@@ -0,0 +1,110 @@
<?php
namespace App\Insurance\Service;
use App\Insurance\Entity\TenantServiceCategorySetting;
use App\Insurance\Enum\ServiceCategory;
use App\Insurance\Repository\TenantServiceCategorySettingRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
/**
* The service kinds a tenant covers with insurance. One switch per kind, shared by
* all of that tenant's insurances. A kind with no stored row counts as enabled.
*/
class TenantServiceCategoryService
{
public function __construct(
private readonly TenantServiceCategorySettingRepository $repo,
) {}
/** @return list<string> enabled service_category values */
public function enabledKeys(string $entityType, int $entityId): array
{
$stored = $this->repo->enabledMapFor($entityType, $entityId);
return array_values(array_filter(
ServiceCategory::values(),
static fn(string $key) => $stored[$key] ?? true,
));
}
/**
* Rows shaped for the settings UI — always every category, so the panel renders a
* complete list without knowing which kinds exist.
*
* @return list<array{key: string, label: string, enabled: bool}>
*/
public function settingsRows(string $entityType, int $entityId): array
{
$stored = $this->repo->enabledMapFor($entityType, $entityId);
return array_map(static fn(ServiceCategory $c) => [
'key' => $c->value,
'label' => $c->label(),
'enabled' => $stored[$c->value] ?? true,
], ServiceCategory::cases());
}
/**
* The kind to bill with when nobody picked one: the single enabled kind, or null
* when more than one is enabled (then the panel must ask).
*/
public function defaultCategory(string $entityType, int $entityId): ?ServiceCategory
{
$enabled = $this->enabledKeys($entityType, $entityId);
return count($enabled) === 1 ? ServiceCategory::from($enabled[0]) : null;
}
public function isEnabled(string $entityType, int $entityId, ServiceCategory $category): bool
{
return in_array($category->value, $this->enabledKeys($entityType, $entityId), true);
}
/**
* @param list<array{key?: string, enabled?: mixed}> $rows
* @throws AppException on an unknown category, or when the change would disable every kind
*/
public function save(string $entityType, int $entityId, array $rows): void
{
$wanted = [];
foreach ($rows as $row) {
$category = ServiceCategory::tryFromValue(isset($row['key']) ? (string) $row['key'] : null);
if ($category === null) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
'نوع خدمت نامعتبر است: ' . implode('، ', ServiceCategory::values()),
422,
'service_categories',
);
}
$wanted[$category->value] = (bool) ($row['enabled'] ?? false);
}
$resulting = array_filter(
ServiceCategory::values(),
fn(string $key) => $wanted[$key] ?? in_array($key, $this->enabledKeys($entityType, $entityId), true),
);
if ($resulting === []) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
'حداقل یک نوع خدمت باید فعال باشد',
422,
'service_categories',
);
}
foreach ($wanted as $key => $enabled) {
$category = ServiceCategory::from($key);
$entity = $this->repo->findOneFor($entityType, $entityId, $category)
?? new TenantServiceCategorySetting($entityType, $entityId, $category);
$this->repo->save($entity->setEnabled($enabled), false);
}
$this->repo->flush();
}
}
+16 -2
View File
@@ -1002,9 +1002,23 @@ class PatientController extends BaseController
* مانده‌ی بدهیِ سهم بیمار. اگر session تسویه شده باشد (payment_method != pending)
* بدهی صفر است؛ در غیر این صورت سهم بیمار از فاکتور یا کل مبلغ نهایی.
*/
/** @var array<int, string|null> نام بیمه‌ها، یک‌بار در هر درخواست (لیست مراجعه‌ها N+1 نشود). */
private array $insuranceNameCache = [];
private function insuranceNameById(?int $id): ?string
{
if ($id === null) {
return null;
}
return $this->insuranceNameCache[$id] ??= $this->insuranceRepo->find($id)?->getName();
}
private function sessionWithBilling(\App\Patient\Entity\PatientSession $session): array
{
$data = $session->toArray();
$data['insurance_base_name'] = $this->insuranceNameById($session->getInsuranceBaseId());
$data['insurance_supplementary_name'] = $this->insuranceNameById($session->getInsuranceSupplementaryId());
$invoice = $session->getId() !== null ? $this->invoiceRepo->findBySession($session->getId()) : null;
$data['invoice_uuid'] = $invoice?->getUuid();
@@ -1035,7 +1049,7 @@ class PatientController extends BaseController
$this->autoCreateClaim($session, $entityType, $entityId);
return $this->success($session->toArray(), 201);
return $this->success($this->sessionWithBilling($session), 201);
}
/**
@@ -1130,7 +1144,7 @@ class PatientController extends BaseController
$this->sessionRepo->save($session);
return $this->success($session->toArray());
return $this->success($this->sessionWithBilling($session));
}
/**
+12
View File
@@ -3,6 +3,7 @@
namespace App\Patient\Entity;
use App\Appointment\Entity\Appointment;
use App\Insurance\Enum\ServiceCategory;
use App\Inventory\Entity\InventoryPackage;
use App\Patient\Repository\PatientSessionRepository;
use Doctrine\Common\Collections\ArrayCollection;
@@ -46,6 +47,13 @@ class PatientSession
#[ORM\Column(name: 'insurance_supplementary_id', type: 'integer', nullable: true)]
private ?int $insuranceSupplementaryId = null;
/**
* نوع خدمتِ بیمه‌ایِ این مراجعه (سرپایی/بستری) — مبنای درصد پوششِ ویزیت و
* snapshotی که فاکتور از آن می‌سازد.
*/
#[ORM\Column(name: 'insurance_service_category', type: 'string', length: 30, nullable: true, enumType: ServiceCategory::class)]
private ?ServiceCategory $insuranceServiceCategory = null;
#[ORM\Column(name: 'visit_price_rials', type: 'integer')]
private int $visitPriceRials = 0;
@@ -149,6 +157,7 @@ class PatientSession
public function getAppointment(): ?Appointment { return $this->appointment; }
public function getInsuranceBaseId(): ?int { return $this->insuranceBaseId; }
public function getInsuranceSupplementaryId(): ?int { return $this->insuranceSupplementaryId; }
public function getInsuranceServiceCategory(): ?ServiceCategory { return $this->insuranceServiceCategory; }
public function getServices(): Collection { return $this->services; }
public function addService(SessionService $service): self
@@ -230,6 +239,7 @@ class PatientSession
public function setInsuranceBaseId(?int $id): self { $this->insuranceBaseId = $id; $this->updatedAt = time(); return $this; }
public function setInsuranceSupplementaryId(?int $id): self { $this->insuranceSupplementaryId = $id; $this->updatedAt = time(); return $this; }
public function setInsuranceServiceCategory(?ServiceCategory $c): self { $this->insuranceServiceCategory = $c; $this->updatedAt = time(); return $this; }
public function setVisitPriceRials(int $v): self { $this->visitPriceRials = $v; $this->updatedAt = time(); return $this; }
public function setBaseInsuranceDiscountPercent(float $v): self { $this->baseInsuranceDiscountPercent = (string) $v; $this->updatedAt = time(); return $this; }
public function setSupplementaryDiscountPercent(float $v): self { $this->supplementaryDiscountPercent = (string) $v; $this->updatedAt = time(); return $this; }
@@ -281,6 +291,8 @@ class PatientSession
'doctor_name' => $this->appointment?->getDoctor()->getName(),
'insurance_base_id' => $this->insuranceBaseId,
'insurance_supplementary_id' => $this->insuranceSupplementaryId,
'insurance_service_category' => $this->insuranceServiceCategory?->value,
'insurance_service_category_label' => $this->insuranceServiceCategory?->label(),
'visit_price_rials' => $this->visitPriceRials,
'base_insurance_discount_percent' => (float) $this->baseInsuranceDiscountPercent,
'supplementary_discount_percent' => (float) $this->supplementaryDiscountPercent,
+56 -12
View File
@@ -3,11 +3,13 @@
namespace App\Patient\Service;
use App\Appointment\Entity\Appointment;
use App\Appointment\Service\AppointmentInsuranceService;
use App\Auth\Repository\UserRepository;
use App\Billing\Service\BillingCalculator;
use App\Billing\ValueObject\Money;
use App\ClinicService\Repository\ServiceItemRepository;
use App\Clinic\Repository\ClinicRepository;
use App\Insurance\Enum\ServiceCategory;
use App\Insurance\Repository\EntityInsurancePricingRepository;
use App\Insurance\Service\TenantInsuranceService;
use App\Inventory\Repository\InventoryItemRepository;
@@ -48,6 +50,7 @@ class PatientService
private readonly DoctorAddressRepository $addressRepo,
private readonly ClinicRepository $clinicRepo,
private readonly TenantInsuranceService $tenantInsuranceService,
private readonly AppointmentInsuranceService $appointmentInsurance,
private readonly BillingCalculator $billingCalculator,
private readonly WalletService $walletService,
private readonly EntityInsurancePricingRepository $pricingRepo,
@@ -95,6 +98,7 @@ class PatientService
* صفحه‌ی پرداخت و فاکتور نتوانند از هم واگرا شوند.
*
* @param array<array{item_id: int, price_rials: int}> $serviceItems قیمت کل هر ردیف (با احتساب تعداد)
* @param ?ServiceCategory $visitCategory نوع خدمتِ ویزیت؛ null → سرپایی
* @return array{services_total_rials: int, gross_total_rials: int, base_insurance_rials: int, supplementary_insurance_rials: int, patient_share_rials: int, final_price_rials: int}
*/
public function calculateFinalPrice(
@@ -104,7 +108,9 @@ class PatientService
int $entityId = 0,
?int $baseInsuranceId = null,
?int $suppInsuranceId = null,
?ServiceCategory $visitCategory = null,
): array {
$visitCategory ??= ServiceCategory::Outpatient;
$baseShare = 0;
$suppShare = 0;
$patientShare = 0;
@@ -112,8 +118,8 @@ class PatientService
if ($visitPrice > 0) {
$visit = $this->billingCalculator->calculateItem(
new Money($visitPrice),
$this->tenantInsuranceService->coverageRule($entityType, $entityId, $baseInsuranceId),
$this->tenantInsuranceService->coverageRule($entityType, $entityId, $suppInsuranceId),
$this->tenantInsuranceService->coverageRule($entityType, $entityId, $baseInsuranceId, $visitCategory),
$this->tenantInsuranceService->coverageRule($entityType, $entityId, $suppInsuranceId, $visitCategory),
);
$baseShare += $visit->baseInsuranceRials;
$suppShare += $visit->supplementaryRials;
@@ -148,9 +154,15 @@ class PatientService
* درصد پوشش مؤثر ویزیت (سرپایی) از همان زنجیره‌ی resolve محاسبه —
* snapshot نمایشی روی مراجعه، نه ورودی محاسبه.
*/
private function contractPercent(string $entityType, int $entityId, ?int $insuranceId): float
{
return $this->tenantInsuranceService->coverageRule($entityType, $entityId, $insuranceId)->coveragePercent;
private function contractPercent(
string $entityType,
int $entityId,
?int $insuranceId,
?ServiceCategory $category = null,
): float {
return $this->tenantInsuranceService
->coverageRule($entityType, $entityId, $insuranceId, $category ?? ServiceCategory::Outpatient)
->coveragePercent;
}
/**
@@ -219,9 +231,35 @@ class PatientService
}
$session->setServicesTotalRials($servicesTotal);
// پذیرش خودکار بیمه‌ای ندارد: تمام مبلغ سهم بیمار است.
$gross = $servicesTotal + $visitPrice;
$session->applyShares($gross, 0, 0, $gross);
// بیمهٔ انتخاب‌شده روی نوبت مبنای محاسبه است؛ نوبتِ بدون بیمه مثل قبل کاملاً
// سهم بیمار می‌ماند (coverageRule برای insuranceId=null، notCovered می‌دهد).
$category = $this->appointmentInsurance->effectiveCategory($appointment);
$session->setInsuranceServiceCategory($category);
$session->setInsuranceBaseId($appointment->getInsuranceBaseId());
$session->setBaseInsuranceDiscountPercent(
$this->contractPercent($entityType, $entityId, $appointment->getInsuranceBaseId(), $category)
);
$shares = $this->calculateFinalPrice(
$visitPrice,
array_map(
fn(SessionService $line) => ['item_id' => $line->getServiceItem()->getId(), 'price_rials' => $line->getLineTotalRials()],
$lines,
),
$entityType,
$entityId,
$appointment->getInsuranceBaseId(),
null,
$category,
);
$session->applyShares(
$shares['gross_total_rials'],
$shares['base_insurance_rials'],
$shares['supplementary_insurance_rials'],
$shares['patient_share_rials'],
);
$this->sessionRepo->save($session);
@@ -252,9 +290,11 @@ class PatientService
$session->setInsuranceBaseId(isset($data['insurance_base_id']) ? (int) $data['insurance_base_id'] : null);
$session->setInsuranceSupplementaryId(isset($data['insurance_supplementary_id']) ? (int) $data['insurance_supplementary_id'] : null);
$session->setInsuranceServiceCategory(ServiceCategory::tryFromValue($data['insurance_service_category'] ?? null));
$session->setVisitPriceRials((int) ($data['visit_price_rials'] ?? 0));
$session->setBaseInsuranceDiscountPercent($this->contractPercent($entityType, $entityId, $session->getInsuranceBaseId()));
$session->setSupplementaryDiscountPercent($this->contractPercent($entityType, $entityId, $session->getInsuranceSupplementaryId()));
$visitCategory = $session->getInsuranceServiceCategory();
$session->setBaseInsuranceDiscountPercent($this->contractPercent($entityType, $entityId, $session->getInsuranceBaseId(), $visitCategory));
$session->setSupplementaryDiscountPercent($this->contractPercent($entityType, $entityId, $session->getInsuranceSupplementaryId(), $visitCategory));
$session->setPaymentMethod($data['payment_method'] ?? 'pending');
$session->setNotes($data['notes'] ?? null);
@@ -288,6 +328,7 @@ class PatientService
$entityId,
$session->getInsuranceBaseId(),
$session->getInsuranceSupplementaryId(),
$visitCategory,
);
$session->setServicesTotalRials($priceCalc['services_total_rials']);
@@ -357,6 +398,7 @@ class PatientService
if (array_key_exists('visit_price_rials', $data)) { $session->setVisitPriceRials((int) $data['visit_price_rials']); }
if (array_key_exists('insurance_base_id', $data)) { $session->setInsuranceBaseId($data['insurance_base_id'] !== null ? (int) $data['insurance_base_id'] : null); }
if (array_key_exists('insurance_supplementary_id', $data)) { $session->setInsuranceSupplementaryId($data['insurance_supplementary_id'] !== null ? (int) $data['insurance_supplementary_id'] : null); }
if (array_key_exists('insurance_service_category', $data)) { $session->setInsuranceServiceCategory(ServiceCategory::tryFromValue($data['insurance_service_category'])); }
if (array_key_exists('base_insurance_discount_percent', $data)) { $session->setBaseInsuranceDiscountPercent((float) $data['base_insurance_discount_percent']); }
if (array_key_exists('supplementary_discount_percent', $data)) { $session->setSupplementaryDiscountPercent((float) $data['supplementary_discount_percent']); }
if (array_key_exists('notes', $data)) { $session->setNotes($data['notes'] !== null ? (string) $data['notes'] : null); }
@@ -400,8 +442,9 @@ class PatientService
fn(SessionService $s) => ['item_id' => $s->getServiceItem()->getId(), 'price_rials' => $s->getLineTotalRials()],
$session->getServices()->toArray(),
);
$session->setBaseInsuranceDiscountPercent($this->contractPercent($entityType, $entityId, $session->getInsuranceBaseId()));
$session->setSupplementaryDiscountPercent($this->contractPercent($entityType, $entityId, $session->getInsuranceSupplementaryId()));
$visitCategory = $session->getInsuranceServiceCategory();
$session->setBaseInsuranceDiscountPercent($this->contractPercent($entityType, $entityId, $session->getInsuranceBaseId(), $visitCategory));
$session->setSupplementaryDiscountPercent($this->contractPercent($entityType, $entityId, $session->getInsuranceSupplementaryId(), $visitCategory));
$priceCalc = $this->calculateFinalPrice(
$session->getVisitPriceRials(),
@@ -410,6 +453,7 @@ class PatientService
$entityId,
$session->getInsuranceBaseId(),
$session->getInsuranceSupplementaryId(),
$visitCategory,
);
$consumablesTotal = $session->getConsumablesTotalRials();
$session->setServicesTotalRials($priceCalc['services_total_rials']);