feat(insurance): resolve coverage percent per service category

Base insurance is a percentage-only rule: patient share is now total minus the
base share, and the contract franchise no longer inflates it (franchise stays
meaningful for supplementary contracts only).

Coverage percentages are managed centrally by admin per service category
(outpatient/inpatient, extensible via the ServiceCategory enum). A tenant
contract may override a category, otherwise it follows the admin default live —
changing the central value immediately applies to every contract that did not
override it.

- add ServiceCategory enum + GET /api/v1/service-categories as the single source
  of the category list for every client
- add insurance_coverage_defaults (+ GET/PUT admin coverage-defaults endpoints)
  and expose coverage_defaults on the insurance list and insurance-pricing
- add tenant_insurance_category_coverage; tenant-insurances accepts optional
  category_coverages (needs insurances.update) and returns the effective
  percentages with their source
- add service_items.service_category; visits always resolve as outpatient
- drop the reverse-engineered percent from patient_share_rials in MyPatientsPage
  and align the client-side BillingCalculator mirror in CreateStep

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-25 16:21:19 +03:30
co-authored by Claude Opus 5
parent 1a9eda3576
commit 58c6d9ac18
41 changed files with 2558 additions and 143 deletions
+103 -11
View File
@@ -16,6 +16,7 @@ use App\Insurance\Repository\EntityInsurancePricingRepository;
use App\Insurance\Repository\InsuranceRepository;
use App\Insurance\Repository\TenantInsuranceRepository;
use App\Insurance\Repository\TenantServiceCoverageRepository;
use App\Insurance\Service\InsuranceCoverageDefaultService;
use App\Insurance\Service\TenantInsuranceService;
use App\Shared\Constant\ErrorCodes;
use App\Secretary\Security\SecretaryAccessChecker;
@@ -41,6 +42,7 @@ class InsuranceController extends BaseController
private readonly TenantInsuranceRepository $tenantInsuranceRepo,
private readonly TenantServiceCoverageRepository $serviceCoverageRepo,
private readonly TenantInsuranceService $tenantInsuranceService,
private readonly InsuranceCoverageDefaultService $coverageDefaults,
private readonly ServiceItemRepository $serviceItemRepo,
private readonly FileValidatorService $fileValidator,
private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker,
@@ -103,8 +105,7 @@ class InsuranceController extends BaseController
$type = InsuranceType::tryFrom($typeParam);
}
$items = array_map(fn(Insurance $i) => $i->toArray(), $this->insuranceRepo->findActive($type));
return $this->success(['data' => $items]);
return $this->success(['data' => $this->withCoverageDefaults($this->insuranceRepo->findActive($type))]);
}
// ── Admin CRUD — Insurance ────────────────────────────────────────────────
@@ -195,10 +196,59 @@ class InsuranceController extends BaseController
$total = (clone $qb)->select('COUNT(i.id)')->getQuery()->getSingleScalarResult();
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)->getQuery()->getResult();
return $this->paginated(
array_map(fn(Insurance $i) => $i->toArray(), $rows),
(int) $total, $page, $limit
return $this->paginated($this->withCoverageDefaults($rows), (int) $total, $page, $limit);
}
/**
* Insurance rows carrying their central coverage percentages, resolved in one
* query for the whole page.
*
* @param Insurance[] $insurances
* @return list<array<string, mixed>>
*/
private function withCoverageDefaults(array $insurances): array
{
$defaults = $this->coverageDefaults->percentMapForMany(
array_map(static fn(Insurance $i) => (int) $i->getId(), $insurances)
);
return array_map(
static fn(Insurance $i) => $i->toArray() + ['coverage_defaults' => $defaults[$i->getId()] ?? []],
$insurances,
);
}
// ── Admin — central coverage percentages per service category ─────────────
#[Route('/api/v1/admin/insurance/{id}/coverage-defaults', methods: ['GET'])]
#[IsGranted('ROLE_ADMIN')]
public function getCoverageDefaults(int $id): JsonResponse
{
if ($this->insuranceRepo->find($id) === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه یافت نشد', 404);
}
return $this->success([
'insurance_id' => $id,
'categories' => $this->coverageDefaults->settingsRows($id),
]);
}
#[Route('/api/v1/admin/insurance/{id}/coverage-defaults', methods: ['PUT'])]
#[IsGranted('ROLE_ADMIN')]
public function saveCoverageDefaults(int $id, Request $request): JsonResponse
{
if ($this->insuranceRepo->find($id) === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه یافت نشد', 404);
}
$data = json_decode($request->getContent(), true) ?? [];
$this->coverageDefaults->save($id, $data['categories'] ?? []);
return $this->success([
'insurance_id' => $id,
'categories' => $this->coverageDefaults->settingsRows($id),
]);
}
// ── Upload logo ───────────────────────────────────────────────────────────
@@ -284,14 +334,20 @@ class InsuranceController extends BaseController
}
}
$insurances = array_map(function (Insurance $i) use ($perInsurance) {
$catalog = $this->insuranceRepo->findActive(null);
$defaults = $this->coverageDefaults->percentMapForMany(
array_map(static fn(Insurance $i) => (int) $i->getId(), $catalog)
);
$insurances = array_map(function (Insurance $i) use ($perInsurance, $defaults) {
return [
'insurance_id' => $i->getId(),
'insurance_name' => $i->getName(),
'type' => $i->getType()->value,
'patient_share_rials' => $perInsurance[$i->getId()] ?? null,
'coverage_defaults' => $defaults[$i->getId()] ?? [],
];
}, $this->insuranceRepo->findActive(null));
}, $catalog);
return [
'entity_type' => $entityType,
@@ -394,12 +450,14 @@ class InsuranceController extends BaseController
$byId[$ins->getId()] = ['name' => $ins->getName(), 'type' => $ins->getType()->value];
}
$data = array_map(function (TenantInsurance $c) use ($byId) {
$coverageView = $this->tenantInsuranceService->categoryCoverageViewForMany($contracts);
$data = array_map(function (TenantInsurance $c) use ($byId, $coverageView) {
$row = $c->toArray();
$row['insurance_name'] = $byId[$c->getInsuranceId()]['name'] ?? null;
// Contract-level kind wins over the catalog type when the tenant categorised it.
$row['insurance_kind'] = $c->getKind() ?? ($byId[$c->getInsuranceId()]['type'] ?? null);
return $row;
return $row + $coverageView[$c->getId()];
}, $contracts);
return $this->success(['data' => $data]);
@@ -439,7 +497,37 @@ class InsuranceController extends BaseController
isset($data['kind']) && $data['kind'] !== '' ? (string) $data['kind'] : null,
);
return $this->success(['data' => $contract->toArray()], 201);
if (($err = $this->applyCategoryCoverages($contract, $data, $user)) !== null) {
return $err;
}
return $this->success(['data' => $this->tenantInsuranceRow($contract)], 201);
}
/**
* Persists the optional per-category overrides of a contract. Sending nothing keeps
* the contract on the central admin defaults; overriding needs the update permission.
*/
private function applyCategoryCoverages(TenantInsurance $contract, array $data, User $user): ?JsonResponse
{
if (!array_key_exists('category_coverages', $data)) {
return null;
}
if (!$this->secretaryAccess->canOrNonSecretary($user, 'insurances', 'update')
|| !$this->clinicDoctorAccess->canOrNonMember($user, 'insurances', 'update')) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'اجازه‌ی تغییر درصد پوشش را ندارید', 403);
}
$this->tenantInsuranceService->setCategoryCoverages($contract, $data['category_coverages'] ?? []);
return null;
}
/** @return array<string, mixed> contract row carrying its effective category percentages */
private function tenantInsuranceRow(TenantInsurance $contract): array
{
return $contract->toArray() + $this->tenantInsuranceService->categoryCoverageView($contract);
}
#[Route('/api/v1/billing/tenant-insurances/{uuid}', methods: ['PATCH'])]
@@ -486,7 +574,11 @@ class InsuranceController extends BaseController
$this->tenantInsuranceRepo->save($contract);
return $this->success(['data' => $contract->toArray()]);
if (($err = $this->applyCategoryCoverages($contract, $data, $user)) !== null) {
return $err;
}
return $this->success(['data' => $this->tenantInsuranceRow($contract)]);
}
#[Route('/api/v1/billing/tenant-insurances/{uuid}', methods: ['DELETE'])]
@@ -0,0 +1,65 @@
<?php
namespace App\Insurance\Entity;
use App\Insurance\Enum\ServiceCategory;
use App\Insurance\Repository\InsuranceCoverageDefaultRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* Central (admin-managed) coverage percentage of an insurance per service category.
* Single source of truth: tenant contracts fall back to these values live, so a
* change here immediately applies to every doctor/clinic that has not overridden it.
*/
#[ORM\Entity(repositoryClass: InsuranceCoverageDefaultRepository::class)]
#[ORM\Table(name: 'insurance_coverage_defaults')]
#[ORM\UniqueConstraint(name: 'uniq_insurance_service_category', columns: ['insurance_id', 'service_category'])]
class InsuranceCoverageDefault
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(name: 'insurance_id', type: 'integer')]
private int $insuranceId;
#[ORM\Column(name: 'service_category', type: 'string', length: 30, enumType: ServiceCategory::class)]
private ServiceCategory $serviceCategory;
#[ORM\Column(name: 'coverage_percent', type: 'decimal', precision: 5, scale: 2)]
private string $coveragePercent = '0.00';
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(int $insuranceId, ServiceCategory $serviceCategory, float $coveragePercent = 0.0)
{
$this->insuranceId = $insuranceId;
$this->serviceCategory = $serviceCategory;
$this->coveragePercent = (string) $coveragePercent;
$this->updatedAt = time();
}
public function getId(): ?int { return $this->id; }
public function getInsuranceId(): int { return $this->insuranceId; }
public function getServiceCategory(): ServiceCategory { return $this->serviceCategory; }
public function getCoveragePercent(): float { return (float) $this->coveragePercent; }
public function setCoveragePercent(float $v): self
{
$this->coveragePercent = (string) $v;
$this->updatedAt = time();
return $this;
}
public function toArray(): array
{
return [
'insurance_id' => $this->insuranceId,
'service_category' => $this->serviceCategory->value,
'label' => $this->serviceCategory->label(),
'coverage_percent' => (float) $this->coveragePercent,
];
}
}
@@ -0,0 +1,64 @@
<?php
namespace App\Insurance\Entity;
use App\Insurance\Enum\ServiceCategory;
use App\Insurance\Repository\TenantInsuranceCategoryCoverageRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* Per-service-category coverage percentage a tenant explicitly overrode on its own
* contract. A missing row means "follow the central admin default", so contracts
* are never snapshots of it.
*/
#[ORM\Entity(repositoryClass: TenantInsuranceCategoryCoverageRepository::class)]
#[ORM\Table(name: 'tenant_insurance_category_coverage')]
#[ORM\UniqueConstraint(name: 'uniq_tenant_insurance_category', columns: ['tenant_insurance_id', 'service_category'])]
class TenantInsuranceCategoryCoverage
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(name: 'tenant_insurance_id', type: 'integer')]
private int $tenantInsuranceId;
#[ORM\Column(name: 'service_category', type: 'string', length: 30, enumType: ServiceCategory::class)]
private ServiceCategory $serviceCategory;
#[ORM\Column(name: 'coverage_percent', type: 'decimal', precision: 5, scale: 2)]
private string $coveragePercent = '0.00';
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(int $tenantInsuranceId, ServiceCategory $serviceCategory, float $coveragePercent = 0.0)
{
$this->tenantInsuranceId = $tenantInsuranceId;
$this->serviceCategory = $serviceCategory;
$this->coveragePercent = (string) $coveragePercent;
$this->updatedAt = time();
}
public function getId(): ?int { return $this->id; }
public function getTenantInsuranceId(): int { return $this->tenantInsuranceId; }
public function getServiceCategory(): ServiceCategory { return $this->serviceCategory; }
public function getCoveragePercent(): float { return (float) $this->coveragePercent; }
public function setCoveragePercent(float $v): self
{
$this->coveragePercent = (string) $v;
$this->updatedAt = time();
return $this;
}
public function toArray(): array
{
return [
'service_category' => $this->serviceCategory->value,
'label' => $this->serviceCategory->label(),
'coverage_percent' => (float) $this->coveragePercent,
];
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Insurance\Enum;
/**
* Service kind an insurance coverage percentage is defined for.
* Adding a new kind is a single case here: every list (admin settings, contract
* overrides, service form) is derived from these cases, never hardcoded.
*/
enum ServiceCategory: string
{
case Outpatient = 'outpatient';
case Inpatient = 'inpatient';
public function label(): string
{
return match ($this) {
self::Outpatient => 'خدمات سرپایی',
self::Inpatient => 'خدمات بستری',
};
}
/** @return list<string> */
public static function values(): array
{
return array_map(static fn(self $c) => $c->value, self::cases());
}
public static function tryFromValue(?string $value): ?self
{
return $value !== null ? self::tryFrom($value) : null;
}
}
@@ -0,0 +1,89 @@
<?php
namespace App\Insurance\Repository;
use App\Insurance\Entity\InsuranceCoverageDefault;
use App\Insurance\Enum\ServiceCategory;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class InsuranceCoverageDefaultRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, InsuranceCoverageDefault::class);
}
/** @return InsuranceCoverageDefault[] */
public function findByInsurance(int $insuranceId): array
{
return $this->findBy(['insuranceId' => $insuranceId]);
}
public function findOneFor(int $insuranceId, ServiceCategory $category): ?InsuranceCoverageDefault
{
return $this->findOneBy(['insuranceId' => $insuranceId, 'serviceCategory' => $category]);
}
/** @return array<string, float> service_category => percent */
public function percentMapFor(int $insuranceId): array
{
$map = [];
foreach ($this->findByInsurance($insuranceId) as $row) {
$map[$row->getServiceCategory()->value] = $row->getCoveragePercent();
}
return $map;
}
/**
* @param list<int> $insuranceIds
* @return array<int, array<string, float>> insurance_id => (service_category => percent)
*/
public function percentMapForMany(array $insuranceIds): array
{
if ($insuranceIds === []) {
return [];
}
$rows = $this->createQueryBuilder('d')
->select('d.insuranceId AS insurance_id', 'd.serviceCategory AS service_category', 'd.coveragePercent AS coverage_percent')
->where('d.insuranceId IN (:ids)')
->setParameter('ids', $insuranceIds)
->getQuery()
->getArrayResult();
$map = [];
foreach ($rows as $row) {
$category = $row['service_category'] instanceof ServiceCategory
? $row['service_category']->value
: (string) $row['service_category'];
$map[(int) $row['insurance_id']][$category] = (float) $row['coverage_percent'];
}
return $map;
}
public function save(InsuranceCoverageDefault $entity, bool $flush = true): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
public function flush(): void
{
$this->getEntityManager()->flush();
}
public function removeByInsurance(int $insuranceId): void
{
$this->createQueryBuilder('d')
->delete()
->where('d.insuranceId = :id')
->setParameter('id', $insuranceId)
->getQuery()
->execute();
}
}
@@ -0,0 +1,91 @@
<?php
namespace App\Insurance\Repository;
use App\Insurance\Entity\TenantInsuranceCategoryCoverage;
use App\Insurance\Enum\ServiceCategory;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class TenantInsuranceCategoryCoverageRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TenantInsuranceCategoryCoverage::class);
}
public function findOneFor(int $tenantInsuranceId, ServiceCategory $category): ?TenantInsuranceCategoryCoverage
{
return $this->findOneBy(['tenantInsuranceId' => $tenantInsuranceId, 'serviceCategory' => $category]);
}
/** @return array<string, float> service_category => percent, only overridden categories */
public function percentMapFor(int $tenantInsuranceId): array
{
$map = [];
foreach ($this->findBy(['tenantInsuranceId' => $tenantInsuranceId]) as $row) {
$map[$row->getServiceCategory()->value] = $row->getCoveragePercent();
}
return $map;
}
/**
* @param list<int> $tenantInsuranceIds
* @return array<int, array<string, float>> tenant_insurance_id => (service_category => percent)
*/
public function percentMapForMany(array $tenantInsuranceIds): array
{
if ($tenantInsuranceIds === []) {
return [];
}
$rows = $this->createQueryBuilder('c')
->select('c.tenantInsuranceId AS tenant_insurance_id', 'c.serviceCategory AS service_category', 'c.coveragePercent AS coverage_percent')
->where('c.tenantInsuranceId IN (:ids)')
->setParameter('ids', $tenantInsuranceIds)
->getQuery()
->getArrayResult();
$map = [];
foreach ($rows as $row) {
$category = $row['service_category'] instanceof ServiceCategory
? $row['service_category']->value
: (string) $row['service_category'];
$map[(int) $row['tenant_insurance_id']][$category] = (float) $row['coverage_percent'];
}
return $map;
}
public function save(TenantInsuranceCategoryCoverage $entity, bool $flush = true): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
public function remove(TenantInsuranceCategoryCoverage $entity, bool $flush = false): void
{
$this->getEntityManager()->remove($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
public function flush(): void
{
$this->getEntityManager()->flush();
}
public function removeByContract(int $tenantInsuranceId): void
{
$this->createQueryBuilder('c')
->delete()
->where('c.tenantInsuranceId = :id')
->setParameter('id', $tenantInsuranceId)
->getQuery()
->execute();
}
}
@@ -0,0 +1,104 @@
<?php
namespace App\Insurance\Service;
use App\Insurance\Entity\InsuranceCoverageDefault;
use App\Insurance\Enum\ServiceCategory;
use App\Insurance\Repository\InsuranceCoverageDefaultRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
/**
* Admin-managed coverage percentages. Every read returns a complete category set
* (missing rows read as 0) so callers never have to know which categories exist.
*/
class InsuranceCoverageDefaultService
{
public function __construct(
private readonly InsuranceCoverageDefaultRepository $repo,
) {}
/** @return array<string, float> service_category => percent, all categories present */
public function percentMap(int $insuranceId): array
{
return $this->fill($this->repo->percentMapFor($insuranceId));
}
/**
* @param list<int> $insuranceIds
* @return array<int, array<string, float>>
*/
public function percentMapForMany(array $insuranceIds): array
{
$stored = $this->repo->percentMapForMany($insuranceIds);
$map = [];
foreach ($insuranceIds as $id) {
$map[$id] = $this->fill($stored[$id] ?? []);
}
return $map;
}
public function percentFor(int $insuranceId, ServiceCategory $category): ?float
{
return $this->repo->findOneFor($insuranceId, $category)?->getCoveragePercent();
}
/**
* Category rows shaped for the admin settings UI.
*
* @return list<array{key: string, label: string, coverage_percent: float}>
*/
public function settingsRows(int $insuranceId): array
{
$map = $this->percentMap($insuranceId);
return array_map(static fn(ServiceCategory $c) => [
'key' => $c->value,
'label' => $c->label(),
'coverage_percent' => $map[$c->value],
], ServiceCategory::cases());
}
/**
* @param list<array{key?: string, coverage_percent?: mixed}> $rows
* @throws AppException on an unknown category or an out-of-range percentage
*/
public function save(int $insuranceId, array $rows): void
{
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,
);
}
$percent = (float) ($row['coverage_percent'] ?? 0);
if ($percent < 0 || $percent > 100) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'درصد پوشش باید بین ۰ تا ۱۰۰ باشد', 422);
}
$entity = $this->repo->findOneFor($insuranceId, $category)
?? new InsuranceCoverageDefault($insuranceId, $category);
$this->repo->save($entity->setCoveragePercent($percent), false);
}
$this->repo->flush();
}
/** @param array<string, float> $stored */
private function fill(array $stored): array
{
$map = [];
foreach (ServiceCategory::cases() as $category) {
$map[$category->value] = $stored[$category->value] ?? 0.0;
}
return $map;
}
}
@@ -4,6 +4,7 @@ namespace App\Insurance\Service;
use App\Insurance\Entity\EntityInsurancePricing;
use App\Insurance\Entity\TenantInsurance;
use App\Insurance\Entity\TenantInsuranceCategoryCoverage;
use App\Insurance\Entity\TenantServiceCoverage;
use Doctrine\ORM\EntityManagerInterface;
@@ -32,6 +33,11 @@ final class TenantInsuranceCleanupService
'DELETE ' . TenantServiceCoverage::class . ' c
WHERE c.tenantInsuranceId IN (:ids)'
)->setParameter('ids', $tenantInsuranceIds)->execute();
$this->em->createQuery(
'DELETE ' . TenantInsuranceCategoryCoverage::class . ' k
WHERE k.tenantInsuranceId IN (:ids)'
)->setParameter('ids', $tenantInsuranceIds)->execute();
}
$this->em->createQuery(
+197 -15
View File
@@ -4,7 +4,12 @@ namespace App\Insurance\Service;
use App\ClinicService\Repository\ServiceItemRepository;
use App\Insurance\Entity\TenantInsurance;
use App\Insurance\Entity\TenantInsuranceCategoryCoverage;
use App\Insurance\Entity\TenantServiceCoverage;
use App\Insurance\Enum\InsuranceType;
use App\Insurance\Enum\ServiceCategory;
use App\Insurance\Repository\InsuranceRepository;
use App\Insurance\Repository\TenantInsuranceCategoryCoverageRepository;
use App\Insurance\Repository\TenantInsuranceRepository;
use App\Insurance\Repository\TenantServiceCoverageRepository;
use App\Insurance\ValueObject\CoverageRule;
@@ -13,11 +18,18 @@ use App\Shared\Exception\AppException;
class TenantInsuranceService
{
public const SOURCE_SERVICE_OVERRIDE = 'service_override';
public const SOURCE_OVERRIDE = 'override';
public const SOURCE_ADMIN_DEFAULT = 'admin_default';
public const SOURCE_CONTRACT = 'contract';
public function __construct(
private readonly TenantInsuranceRepository $repo,
private readonly InsuranceRepository $insuranceRepo,
private readonly TenantServiceCoverageRepository $coverageRepo,
private readonly ServiceItemRepository $serviceItemRepo,
private readonly TenantInsuranceRepository $repo,
private readonly InsuranceRepository $insuranceRepo,
private readonly TenantServiceCoverageRepository $coverageRepo,
private readonly ServiceItemRepository $serviceItemRepo,
private readonly TenantInsuranceCategoryCoverageRepository $categoryCoverageRepo,
private readonly InsuranceCoverageDefaultService $coverageDefaults,
) {}
/**
@@ -69,6 +81,128 @@ class TenantInsuranceService
$this->repo->save($contract);
}
// ── Per-category coverage percentages ─────────────────────────────────────
/**
* Replaces the contract's category overrides. A row whose percentage is null is
* dropped, which hands that category back to the central admin default.
*
* @param list<array{key?: string, coverage_percent?: mixed}> $rows
* @throws AppException on an unknown category or an out-of-range percentage
*/
public function setCategoryCoverages(TenantInsurance $contract, array $rows): void
{
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,
);
}
$existing = $this->categoryCoverageRepo->findOneFor($contract->getId(), $category);
$raw = $row['coverage_percent'] ?? null;
if ($raw === null || $raw === '') {
if ($existing !== null) {
$this->categoryCoverageRepo->remove($existing);
}
continue;
}
$percent = (float) $raw;
if ($percent < 0 || $percent > 100) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'درصد پوشش باید بین ۰ تا ۱۰۰ باشد', 422);
}
$entity = $existing ?? new TenantInsuranceCategoryCoverage($contract->getId(), $category);
$this->categoryCoverageRepo->save($entity->setCoveragePercent($percent), false);
}
$this->categoryCoverageRepo->flush();
}
/**
* Effective percentage per category plus where each value came from, so the panel
* can tell an explicit override apart from an inherited central default.
*
* @return array{category_coverages: array<string, float>, category_coverage_source: array<string, string>}
*/
public function categoryCoverageView(TenantInsurance $contract, ?array $overrides = null, ?array $defaults = null): array
{
$overrides ??= $this->categoryCoverageRepo->percentMapFor($contract->getId());
$defaults ??= $this->coverageDefaults->percentMap($contract->getInsuranceId());
$percents = [];
$sources = [];
foreach (ServiceCategory::cases() as $category) {
[$percent, $source] = $this->pickPercent($contract, $category, $overrides, $defaults);
$percents[$category->value] = $percent;
$sources[$category->value] = $source;
}
return ['category_coverages' => $percents, 'category_coverage_source' => $sources];
}
/**
* Same as categoryCoverageView() for a whole list, resolved in two queries.
*
* @param TenantInsurance[] $contracts
* @return array<int, array{category_coverages: array<string, float>, category_coverage_source: array<string, string>}>
*/
public function categoryCoverageViewForMany(array $contracts): array
{
$overrides = $this->categoryCoverageRepo->percentMapForMany(
array_map(static fn(TenantInsurance $c) => (int) $c->getId(), $contracts)
);
$defaults = $this->coverageDefaults->percentMapForMany(
array_values(array_unique(array_map(static fn(TenantInsurance $c) => $c->getInsuranceId(), $contracts)))
);
$view = [];
foreach ($contracts as $contract) {
$view[(int) $contract->getId()] = $this->categoryCoverageView(
$contract,
$overrides[$contract->getId()] ?? [],
$defaults[$contract->getInsuranceId()] ?? [],
);
}
return $view;
}
/**
* درصد پوشش مؤثر یک نوع خدمت، به ترتیب اولویت:
* ۱) override قرارداد برای همان نوع خدمت (TenantInsuranceCategoryCoverage)
* ۲) پیش‌فرض مرکزی ادمین (InsuranceCoverageDefault) — تنها وقتی تعریف شده باشد
* ۳) coverage_percent قرارداد (سازگاری با ردیف‌های قدیمی)
*
* @param array<string, float> $overrides
* @param array<string, float> $defaults
* @return array{0: float, 1: string}
*/
private function pickPercent(
TenantInsurance $contract,
ServiceCategory $category,
array $overrides,
array $defaults,
): array {
if (isset($overrides[$category->value])) {
return [$overrides[$category->value], self::SOURCE_OVERRIDE];
}
// A stored 0 means "not configured yet" (rows are pre-seeded for every
// insurance), so it must not shadow a legacy contract percentage.
$default = $defaults[$category->value] ?? 0.0;
if ($default > 0) {
return [$default, self::SOURCE_ADMIN_DEFAULT];
}
return [$contract->getCoveragePercent(), self::SOURCE_CONTRACT];
}
/**
* بررسی فعال‌بودن یک بیمه برای tenant. در پذیرش/صورتحساب استفاده می‌شود.
*/
@@ -82,7 +216,8 @@ class TenantInsuranceService
}
/**
* قانون پوشش یک بیمه برای tenant جاری (برای BillingCalculator).
* قانون پوشش ویزیت برای tenant جاری (برای BillingCalculator).
* ویزیت خدمتِ سرپایی است، پس درصد همان نوع خدمت resolve می‌شود.
* اگر قرارداد فعالی نباشد، notCovered برمی‌گردد.
*/
public function coverageRule(string $entityType, int $entityId, ?int $insuranceId): CoverageRule
@@ -96,12 +231,7 @@ class TenantInsuranceService
return CoverageRule::notCovered();
}
return new CoverageRule(
coveragePercent: $contract->getCoveragePercent(),
franchiseRials: $contract->getFranchiseRials(),
ceilingRials: $contract->getAnnualCeilingRials(),
covered: true,
);
return $this->buildRule($contract, ServiceCategory::Outpatient, null);
}
/**
@@ -130,14 +260,66 @@ class TenantInsuranceService
return CoverageRule::notCovered();
}
return $this->buildRule($contract, $service->getServiceCategory(), $override);
}
/**
* ساخت قاعدهٔ پوشش با درصد resolve‌شده. فرانشیز فقط در قرارداد تکمیلی اثر دارد؛
* در بیمهٔ پایه قاعده صرفاً درصدی است (ستون DB برای سازگاری باقی می‌ماند).
*/
private function buildRule(
TenantInsurance $contract,
ServiceCategory $category,
?TenantServiceCoverage $override,
): CoverageRule {
$franchise = $this->isSupplementary($contract)
? ($override?->getFranchiseRials() ?? $contract->getFranchiseRials())
: 0;
return new CoverageRule(
coveragePercent: $override?->getCoveragePercent() ?? $contract->getCoveragePercent(),
franchiseRials: $override?->getFranchiseRials() ?? $contract->getFranchiseRials(),
ceilingRials: $override?->getCeilingRials() ?? $contract->getAnnualCeilingRials(),
coveragePercent: $this->resolvePercent($contract, $category, $override),
franchiseRials: $franchise,
ceilingRials: $override?->getCeilingRials() ?? $contract->getAnnualCeilingRials(),
covered: true,
);
}
/**
* درصد پوشش مؤثر، به ترتیب اولویت:
* ۱) override همان خدمت (TenantServiceCoverage.coverage_percent)
* ۲) override قرارداد برای نوع خدمت (TenantInsuranceCategoryCoverage)
* ۳) پیش‌فرض مرکزی ادمین (InsuranceCoverageDefault)
* ۴) coverage_percent قرارداد (سازگاری با ردیف‌های قدیمی)
*/
public function resolvePercent(
TenantInsurance $contract,
ServiceCategory $category,
?TenantServiceCoverage $override = null,
): float {
$servicePercent = $override?->getCoveragePercent();
if ($servicePercent !== null) {
return $servicePercent;
}
[$percent] = $this->pickPercent(
$contract,
$category,
$this->categoryCoverageRepo->percentMapFor($contract->getId()),
$this->coverageDefaults->percentMap($contract->getInsuranceId()),
);
return $percent;
}
/** قرارداد تکمیلی است؟ kind قرارداد بر نوع کاتالوگ اولویت دارد. */
private function isSupplementary(TenantInsurance $contract): bool
{
$kind = $contract->getKind()
?? $this->insuranceRepo->find($contract->getInsuranceId())?->getType()->value;
return $kind === InsuranceType::Supplementary->value;
}
/** @return array{covered: bool, percent: float|null, franchise: int|null, ceiling: int|null}|null */
public function getServiceCoverage(int $tenantInsuranceId, int $serviceItemId): ?array
{
@@ -154,7 +336,7 @@ class TenantInsuranceService
?int $ceilingRials,
): void {
$override = $this->coverageRepo->findOneFor($contract->getId(), $serviceItemId)
?? new \App\Insurance\Entity\TenantServiceCoverage($contract->getId(), $serviceItemId);
?? new TenantServiceCoverage($contract->getId(), $serviceItemId);
$override->setCovered($covered)
->setCoveragePercent($coveragePercent)
@@ -6,6 +6,7 @@ final readonly class CoverageRule
{
public function __construct(
public float $coveragePercent,
/** فقط برای بیمهٔ تکمیلی معنا دارد؛ در بیمهٔ پایه در محاسبه دخالت نمی‌کند. */
public int $franchiseRials,
public ?int $ceilingRials,
public bool $covered = true,