Files
clinicpro/src/Insurance/Controller/InsuranceController.php
T
hamedandClaude Opus 5 1f58b1b9b3 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>
2026-07-25 17:50:14 +03:30

797 lines
37 KiB
PHP

<?php
namespace App\Insurance\Controller;
use App\Auth\Entity\User;
use App\ClinicService\Repository\ServiceItemRepository;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Insurance\Entity\DoctorInsurance;
use App\Insurance\Entity\EntityInsurancePricing;
use App\Insurance\Entity\Insurance;
use App\Insurance\Enum\InsuranceType;
use App\Insurance\Entity\TenantInsurance;
use App\Insurance\Repository\DoctorInsuranceRepository;
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\Insurance\Service\TenantServiceCategoryService;
use App\Shared\Constant\ErrorCodes;
use App\Secretary\Security\SecretaryAccessChecker;
use App\Shared\Controller\BaseController;
use App\Shared\Service\FileValidatorService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
use OpenApi\Attributes as OA;
#[OA\Tag(name: 'Insurance')]
class InsuranceController extends BaseController
{
public function __construct(
private readonly InsuranceRepository $insuranceRepo,
private readonly DoctorInsuranceRepository $doctorInsuranceRepo,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly EntityInsurancePricingRepository $pricingRepo,
private readonly TenantInsuranceRepository $tenantInsuranceRepo,
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,
private readonly \App\Patient\Security\PatientRecordScopeResolver $scopeResolver,
private readonly SecretaryAccessChecker $secretaryAccess,
private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess,
private readonly string $projectDir,
) {}
/**
* وقتی doctor_uuid داده شود، قیمت‌گذاری همان پزشک هدف است — برای مدیریت پزشکان
* کلینیک از پنل کلینیک. بدون آن، رفتار قبلی (موجودیتِ خودِ کاربر) حفظ می‌شود.
*
* @param 'view'|'update' $action
* @return array{0: string, 1: int|null, 2: JsonResponse|null}
*/
private function resolveTargetEntity(User $user, ?string $doctorUuid, string $action): array
{
if ($doctorUuid === null || $doctorUuid === '') {
[$type, $id] = $this->resolveEntity($user);
return [$type, $id, null];
}
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
if ($doctor === null) {
return ['unknown', null, $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404)];
}
if ($user->hasRole('ROLE_ADMIN') || $doctor->getUser()->getId() === $user->getId()) {
return [EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId(), null];
}
foreach ($this->clinicRepo->findByDoctor($doctor) as $clinic) {
if ($this->permChecker->can($user, $clinic, 'services', $action)) {
return [EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId(), null];
}
}
return ['unknown', null, $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403)];
}
/**
* قرارداد بیمه به همان محیطی تعلق دارد که پرونده‌ها در آن ثبت می‌شوند، پس همان
* رزولوِر مبنا است: مالک کلینیکی که خودش پزشک هم هست باید قراردادهای کلینیکش را
* ببیند، نه مطب شخصی‌اش.
*/
private function resolveEntity(User $user): array
{
return $this->scopeResolver->resolve($user)->toLegacyTuple();
}
// ── Public list ───────────────────────────────────────────────────────────
#[Route('/api/v1/insurances', methods: ['GET'])]
public function list(Request $request): JsonResponse
{
$typeParam = $request->query->get('type');
$type = null;
if ($typeParam !== null) {
$type = InsuranceType::tryFrom($typeParam);
}
return $this->success(['data' => $this->withCoverageDefaults($this->insuranceRepo->findActive($type))]);
}
// ── Admin CRUD — Insurance ────────────────────────────────────────────────
#[Route('/api/v1/admin/insurance', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function create(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$name = trim($data['name'] ?? '');
$typeVal = $data['type'] ?? null;
if ($name === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'name الزامی است', 422, 'name');
}
$type = InsuranceType::tryFrom((string) $typeVal);
if ($type === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'type باید basic یا supplementary باشد', 422, 'type');
}
$insurance = new Insurance($name, $type);
if (isset($data['logo_url'])) $insurance->setLogoUrl($data['logo_url']);
if (isset($data['status'])) $insurance->setStatus((int) $data['status']);
$this->insuranceRepo->save($insurance);
return $this->success(['data' => $insurance->toArray()], 201);
}
#[Route('/api/v1/admin/insurance/{id}', methods: ['PATCH'])]
#[IsGranted('ROLE_ADMIN')]
public function update(int $id, Request $request): JsonResponse
{
$insurance = $this->insuranceRepo->find($id);
if ($insurance === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه یافت نشد', 404);
}
$data = json_decode($request->getContent(), true) ?? [];
if (isset($data['name'])) $insurance->setName($data['name']);
if (isset($data['type'])) {
$type = InsuranceType::tryFrom($data['type']);
if ($type !== null) $insurance->setType($type);
}
if (array_key_exists('logo_url', $data)) $insurance->setLogoUrl($data['logo_url']);
if (isset($data['status'])) $insurance->setStatus((int) $data['status']);
$this->insuranceRepo->save($insurance);
return $this->success(['data' => $insurance->toArray()]);
}
#[Route('/api/v1/admin/insurance/{id}', methods: ['DELETE'])]
#[IsGranted('ROLE_ADMIN')]
public function delete(int $id): JsonResponse
{
$insurance = $this->insuranceRepo->find($id);
if ($insurance === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه یافت نشد', 404);
}
$this->insuranceRepo->remove($insurance);
return $this->success(['message' => 'بیمه با موفقیت حذف شد']);
}
#[Route('/api/v1/admin/insurances', methods: ['GET'])]
#[IsGranted('ROLE_ADMIN')]
public function adminList(Request $request): JsonResponse
{
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(100, max(1, (int) $request->query->get('limit', 15)));
$search = trim((string) $request->query->get('search', ''));
$typeParam = $request->query->get('type');
$qb = $this->insuranceRepo->createQueryBuilder('i');
if ($request->query->get('sort') === 'id') {
$qb->orderBy('i.id', strtoupper((string) $request->query->get('order')) === 'DESC' ? 'DESC' : 'ASC');
} else {
$qb->orderBy('i.name', 'ASC');
}
if ($search !== '') {
$qb->andWhere('i.name LIKE :s')->setParameter('s', '%' . $search . '%');
}
if ($typeParam !== null && $typeParam !== '') {
$qb->andWhere('i.type = :t')->setParameter('t', $typeParam);
}
$total = (clone $qb)->select('COUNT(i.id)')->getQuery()->getSingleScalarResult();
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)->getQuery()->getResult();
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 ───────────────────────────────────────────────────────────
#[Route('/api/v1/admin/insurance/{id}/upload-logo', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function uploadLogo(int $id, Request $request): JsonResponse
{
$insurance = $this->insuranceRepo->find($id);
if ($insurance === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه یافت نشد', 404);
}
$content = $request->getContent();
$disposition = $request->headers->get('Content-Disposition', '');
preg_match('/filename=["\']?([^"\';\s]+)["\']?/i', $disposition, $m);
$filename = $m[1] ?? 'logo.jpg';
$tmpPath = sys_get_temp_dir() . '/' . uniqid('upload_', true);
file_put_contents($tmpPath, $content);
try {
$safeFilename = $this->fileValidator->sanitizeFilename($filename);
$mime = $this->fileValidator->detectMimeType($tmpPath);
$year = date('Y');
$month = date('m');
$dir = $this->projectDir . '/public/uploads/insurances/logo/' . $year . '-' . $month;
if (!is_dir($dir)) mkdir($dir, 0755, true);
$storedName = uniqid('', true) . '_' . $safeFilename;
rename($tmpPath, $dir . '/' . $storedName);
$url = '/uploads/insurances/logo/' . $year . '-' . $month . '/' . $storedName;
$insurance->setLogoUrl($url);
$this->insuranceRepo->save($insurance);
return $this->success([
'url' => $url,
'uuid' => Uuid::v4()->toRfc4122(),
'filename' => $safeFilename,
'filemime' => $mime,
'filesize' => strlen($content),
]);
} catch (\Throwable $e) {
if (file_exists($tmpPath)) unlink($tmpPath);
return $this->error(ErrorCodes::ERR_VALIDATION_001, $e->getMessage(), 422);
}
}
// ── Entity insurance pricing (visit price by insurance) ───────────────────
#[Route('/api/v1/insurance-pricing', methods: ['GET'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function getInsurancePricing(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'view');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'view');
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $request->query->get('doctor_uuid'), 'view');
if ($err !== null) {
return $err;
}
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
return $this->success($this->pricingPayload($entityType, $entityId));
}
private function pricingPayload(string $entityType, int $entityId): array
{
$rows = $this->pricingRepo->findByEntity($entityType, $entityId);
$freeVisitPriceRials = 0;
$requireVisitPrice = false;
$perInsurance = [];
foreach ($rows as $row) {
if ($row->isFreeVisit()) {
$freeVisitPriceRials = $row->getPatientShareRials();
$requireVisitPrice = $row->isRequireVisitPrice();
} else {
$perInsurance[$row->getInsuranceId()] = $row->getPatientShareRials();
}
}
$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()] ?? [],
];
}, $catalog);
return [
'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,
];
}
#[Route('/api/v1/insurance-pricing', methods: ['PUT'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function saveInsurancePricing(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'update');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'update');
$data = json_decode($request->getContent(), true) ?? [];
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $data['doctor_uuid'] ?? null, 'update');
if ($err !== null) {
return $err;
}
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
$freeVisitRow = $this->pricingRepo->findOneForInsurance($entityType, $entityId, null);
$requireVisitPrice = array_key_exists('require_visit_price', $data)
? (bool) $data['require_visit_price']
: ($freeVisitRow?->isRequireVisitPrice() ?? false);
$freeVisitPrice = array_key_exists('free_visit_price_rials', $data)
? (int) $data['free_visit_price_rials']
: ($freeVisitRow?->getPatientShareRials() ?? 0);
if ($requireVisitPrice && $freeVisitPrice <= 0) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'با فعال بودن «الزامی کردن هزینه ویزیت»، قیمت ویزیت آزاد الزامی است', 422, 'free_visit_price_rials');
}
$touchesFreeVisit = array_key_exists('free_visit_price_rials', $data) || array_key_exists('require_visit_price', $data);
if ($touchesFreeVisit && ($freeVisitRow !== null || $freeVisitPrice > 0)) {
$this->upsertPricing($entityType, $entityId, null, $freeVisitPrice)
->setRequireVisitPrice($requireVisitPrice);
}
foreach (($data['insurances'] ?? []) as $row) {
$insuranceId = isset($row['insurance_id']) ? (int) $row['insurance_id'] : null;
if ($insuranceId === null) {
continue;
}
if (!array_key_exists('patient_share_rials', $row) || $row['patient_share_rials'] === null) {
$existing = $this->pricingRepo->findOneForInsurance($entityType, $entityId, $insuranceId);
if ($existing !== null) {
$this->pricingRepo->remove($existing, false);
}
continue;
}
$this->upsertPricing($entityType, $entityId, $insuranceId, (int) $row['patient_share_rials']);
}
$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));
}
private function upsertPricing(string $entityType, int $entityId, ?int $insuranceId, int $shareRials): EntityInsurancePricing
{
$row = $this->pricingRepo->findOneForInsurance($entityType, $entityId, $insuranceId);
if ($row === null) {
$row = new EntityInsurancePricing($entityType, $entityId, $insuranceId, $shareRials);
} else {
$row->setPatientShareRials($shareRials);
}
$this->pricingRepo->save($row, false);
return $row;
}
// ── TenantInsurance — قراردادهای بیمه‌ی tenant ─────────────────────────────
#[Route('/api/v1/billing/tenant-insurances', methods: ['GET'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function listTenantInsurances(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'view');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'view');
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $request->query->get('doctor_uuid'), 'view');
if ($err !== null) {
return $err;
}
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
$contracts = $this->tenantInsuranceRepo->findLatestByTenant($entityType, $entityId);
$byId = [];
foreach ($this->insuranceRepo->findActive(null) as $ins) {
$byId[$ins->getId()] = ['name' => $ins->getName(), 'type' => $ins->getType()->value];
}
$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 + $coverageView[$c->getId()];
}, $contracts);
return $this->success(['data' => $data]);
}
#[Route('/api/v1/billing/tenant-insurances', methods: ['POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function activateTenantInsurance(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'create');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'create');
$data = json_decode($request->getContent(), true) ?? [];
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $data['doctor_uuid'] ?? null, 'update');
if ($err !== null) {
return $err;
}
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
$insuranceId = isset($data['insurance_id']) ? (int) $data['insurance_id'] : 0;
if ($insuranceId <= 0) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'insurance_id الزامی است', 422);
}
$contract = $this->tenantInsuranceService->activate(
$entityType,
$entityId,
$insuranceId,
(float) ($data['coverage_percent'] ?? 0),
(int) ($data['franchise_rials'] ?? 0),
isset($data['annual_ceiling_rials']) && $data['annual_ceiling_rials'] !== null
? (int) $data['annual_ceiling_rials'] : null,
isset($data['effective_from']) && $data['effective_from'] !== null ? (int) $data['effective_from'] : null,
isset($data['effective_to']) && $data['effective_to'] !== null ? (int) $data['effective_to'] : null,
isset($data['kind']) && $data['kind'] !== '' ? (string) $data['kind'] : null,
);
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'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function updateTenantInsurance(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'update');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'update');
$data = json_decode($request->getContent(), true) ?? [];
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $data['doctor_uuid'] ?? null, 'update');
if ($err !== null) {
return $err;
}
$contract = $this->tenantInsuranceRepo->findByUuid($uuid);
if ($contract === null || $contract->getEntityType() !== $entityType || $contract->getEntityId() !== $entityId) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'قرارداد یافت نشد', 404);
}
if (array_key_exists('coverage_percent', $data)) {
$contract->setCoveragePercent((float) $data['coverage_percent']);
}
if (array_key_exists('franchise_rials', $data)) {
$contract->setFranchiseRials((int) $data['franchise_rials']);
}
if (array_key_exists('annual_ceiling_rials', $data)) {
$contract->setAnnualCeilingRials($data['annual_ceiling_rials'] !== null ? (int) $data['annual_ceiling_rials'] : null);
}
if (array_key_exists('kind', $data)) {
$contract->setKind($data['kind'] !== '' && $data['kind'] !== null ? (string) $data['kind'] : null);
}
if (array_key_exists('effective_from', $data) && $data['effective_from'] !== null) {
$contract->setEffectiveFrom((int) $data['effective_from']);
}
if (array_key_exists('effective_to', $data)) {
$contract->setEffectiveTo($data['effective_to'] !== null ? (int) $data['effective_to'] : null);
}
// Status toggle (فعال/غیرفعال) is set here directly so it does not clobber the
// user-chosen effective_to the way the DELETE/deactivate path does.
if (array_key_exists('is_active', $data)) {
$contract->setActive((bool) $data['is_active']);
}
$this->tenantInsuranceRepo->save($contract);
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'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function deactivateTenantInsurance(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'delete');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'delete');
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $request->query->get('doctor_uuid'), 'update');
if ($err !== null) {
return $err;
}
$contract = $this->tenantInsuranceRepo->findByUuid($uuid);
if ($contract === null || $contract->getEntityType() !== $entityType || $contract->getEntityId() !== $entityId) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'قرارداد یافت نشد', 404);
}
$this->tenantInsuranceService->deactivate($contract);
return $this->success(['message' => 'قرارداد بیمه غیرفعال شد']);
}
#[Route('/api/v1/billing/tenant-insurances/{uuid}/service-coverage', methods: ['GET'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function listServiceCoverage(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'view');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'view');
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $request->query->get('doctor_uuid'), 'view');
if ($err !== null) {
return $err;
}
$contract = $this->tenantInsuranceRepo->findByUuid($uuid);
if ($contract === null || $contract->getEntityType() !== $entityType || $contract->getEntityId() !== $entityId) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'قرارداد یافت نشد', 404);
}
$rows = $this->serviceCoverageRepo->findByContract($contract->getId());
// یک کوئری اسکالر برای همهٔ uuidها. هیدریت‌کردن entity کافی نیست: رابطهٔ
// EAGER staffMembers روی ServiceItem به ازای هر ردیف یک کوئری اضافه می‌زند.
$itemIds = array_values(array_unique(array_map(fn($r) => $r->getServiceItemId(), $rows)));
$uuidById = $this->serviceItemRepo->findUuidsByIds($itemIds);
$data = array_map(function ($r) use ($uuidById) {
$row = $r->toArray();
$row['service_item_uuid'] = $uuidById[$r->getServiceItemId()] ?? null;
return $row;
}, $rows);
return $this->success(['data' => $data]);
}
#[Route('/api/v1/billing/tenant-insurances/{uuid}/service-coverage', methods: ['PUT'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function setServiceCoverage(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'update');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'update');
$data = json_decode($request->getContent(), true) ?? [];
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $data['doctor_uuid'] ?? null, 'update');
if ($err !== null) {
return $err;
}
$contract = $this->tenantInsuranceRepo->findByUuid($uuid);
if ($contract === null || $contract->getEntityType() !== $entityType || $contract->getEntityId() !== $entityId) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'قرارداد یافت نشد', 404);
}
$serviceItem = isset($data['service_item_uuid'])
? $this->serviceItemRepo->findByUuid((string) $data['service_item_uuid'])
: (isset($data['service_item_id']) ? $this->serviceItemRepo->find((int) $data['service_item_id']) : null);
if ($serviceItem === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'سرویس یافت نشد', 422);
}
$section = $serviceItem->getSection();
if ($section->getEntityType() !== $entityType || $section->getEntityId() !== $entityId) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'این سرویس متعلق به شما نیست', 403);
}
$serviceItemId = $serviceItem->getId();
$this->tenantInsuranceService->setServiceCoverage(
$contract,
$serviceItemId,
(bool) ($data['covered'] ?? true),
isset($data['coverage_percent']) && $data['coverage_percent'] !== null ? (float) $data['coverage_percent'] : null,
isset($data['franchise_rials']) && $data['franchise_rials'] !== null ? (int) $data['franchise_rials'] : null,
isset($data['ceiling_rials']) && $data['ceiling_rials'] !== null ? (int) $data['ceiling_rials'] : null,
);
return $this->success(['message' => 'پوشش خدمت ذخیره شد']);
}
// ── DoctorInsurance CRUD ──────────────────────────────────────────────────
#[Route('/api/v1/insurance/', methods: ['POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function addDoctorInsurance(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'create');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'create');
$data = json_decode($request->getContent(), true) ?? [];
$doctorId = $data['doctor_id'] ?? null;
$insuranceId = $data['insurance_id'] ?? null;
if (!$doctorId || !$insuranceId) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_id و insurance_id الزامی است', 422);
}
$doctor = $this->doctorRepo->find((int) $doctorId);
if ($doctor === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
$insurance = $this->insuranceRepo->find((int) $insuranceId);
if ($insurance === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه یافت نشد', 404);
}
$existing = $this->doctorInsuranceRepo->findOneBy(['doctor' => $doctor, 'insurance' => $insurance]);
if ($existing !== null) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این بیمه قبلاً اضافه شده است', 409);
}
$doctorInsurance = new DoctorInsurance($doctor, $insurance);
if (isset($data['price'])) {
$doctorInsurance->setPrice((int) $data['price']);
}
$this->doctorInsuranceRepo->save($doctorInsurance);
return $this->success(['data' => $doctorInsurance->toArray()], 201);
}
#[Route('/api/v1/insurance/{id}', methods: ['GET'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function showDoctorInsurance(int $id, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'view');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'view');
$doctorInsurance = $this->doctorInsuranceRepo->find($id);
if ($doctorInsurance === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه پزشک یافت نشد', 404);
}
if ($doctorInsurance->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
return $this->success(['data' => $doctorInsurance->toArray()]);
}
#[Route('/api/v1/insurance/{id}', methods: ['PATCH'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function updateDoctorInsurance(int $id, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'update');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'update');
$doctorInsurance = $this->doctorInsuranceRepo->find($id);
if ($doctorInsurance === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه پزشک یافت نشد', 404);
}
if ($doctorInsurance->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
$data = json_decode($request->getContent(), true) ?? [];
if (array_key_exists('price', $data)) {
$doctorInsurance->setPrice($data['price'] !== null ? (int) $data['price'] : null);
}
$this->doctorInsuranceRepo->save($doctorInsurance);
return $this->success(['data' => $doctorInsurance->toArray()]);
}
#[Route('/api/v1/insurance/{id}', methods: ['DELETE'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function deleteDoctorInsurance(int $id, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'delete');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'delete');
$doctorInsurance = $this->doctorInsuranceRepo->find($id);
if ($doctorInsurance === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه پزشک یافت نشد', 404);
}
if ($doctorInsurance->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
$this->doctorInsuranceRepo->remove($doctorInsurance);
return $this->success(['message' => 'بیمه پزشک با موفقیت حذف شد']);
}
}