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:
@@ -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'])]
|
||||
|
||||
Reference in New Issue
Block a user