The API and React components were already parameterized by doctor uuid, but 14 copy-pasted identity checks limited every endpoint to "the doctor themselves or an admin", so a clinic owner could not touch a member doctor's booking setup. - Replaces those 14 checks with one denyDoctorAccess() that also admits the owner of a clinic the doctor belongs to, and a member doctor holding the clinic's appointment_settings permission (view for GET, update for writes). A doctor's own settings short-circuit before any permission lookup. - Moves ScheduleSection and its tabs out of DoctorDetailPage into components/schedule/ScheduleSection.tsx so the doctor panel and the new clinic page render the same module instead of one page importing another. Pure relocation — no logic changed. - Adds ClinicAppointmentSettingsPage: one tab per clinic doctor, each rendering that same section. The tab wrapper is keyed by doctor uuid so in-progress schedule edits cannot leak onto the wrong doctor. - insurance-pricing accepts an optional doctor_uuid (query on GET, body on PUT) under the same access rule, so the visit-price card works inside the clinic tabs. Fixes saveInsurancePricing calling getInsurancePricing with the wrong argument by extracting the shared pricingPayload(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
651 lines
29 KiB
PHP
651 lines
29 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\TenantInsuranceService;
|
|
use App\Shared\Constant\ErrorCodes;
|
|
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 ServiceItemRepository $serviceItemRepo,
|
|
private readonly FileValidatorService $fileValidator,
|
|
private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker,
|
|
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
|
|
{
|
|
if ($user->hasRole('ROLE_DOCTOR')) {
|
|
$doctor = $this->doctorRepo->findByUser($user);
|
|
return $doctor !== null ? [EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId()] : [EntityInsurancePricing::TYPE_DOCTOR, null];
|
|
}
|
|
if ($user->hasRole('ROLE_CLINIC')) {
|
|
$clinic = $this->clinicRepo->findByUser($user);
|
|
return $clinic !== null ? [EntityInsurancePricing::TYPE_CLINIC, $clinic->getId()] : [EntityInsurancePricing::TYPE_CLINIC, null];
|
|
}
|
|
return ['unknown', null];
|
|
}
|
|
|
|
// ── 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);
|
|
}
|
|
|
|
$items = array_map(fn(Insurance $i) => $i->toArray(), $this->insuranceRepo->findActive($type));
|
|
return $this->success(['data' => $items]);
|
|
}
|
|
|
|
// ── 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(
|
|
array_map(fn(Insurance $i) => $i->toArray(), $rows),
|
|
(int) $total, $page, $limit
|
|
);
|
|
}
|
|
|
|
// ── 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
|
|
{
|
|
[$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();
|
|
}
|
|
}
|
|
|
|
$insurances = array_map(function (Insurance $i) use ($perInsurance) {
|
|
return [
|
|
'insurance_id' => $i->getId(),
|
|
'insurance_name' => $i->getName(),
|
|
'type' => $i->getType()->value,
|
|
'patient_share_rials' => $perInsurance[$i->getId()] ?? null,
|
|
];
|
|
}, $this->insuranceRepo->findActive(null));
|
|
|
|
return [
|
|
'entity_type' => $entityType,
|
|
'entity_id' => $entityId,
|
|
'free_visit_price_rials' => $freeVisitPriceRials,
|
|
'require_visit_price' => $requireVisitPrice,
|
|
'insurances' => $insurances,
|
|
];
|
|
}
|
|
|
|
#[Route('/api/v1/insurance-pricing', methods: ['PUT'])]
|
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
|
public function saveInsurancePricing(Request $request, #[CurrentUser] User $user): JsonResponse
|
|
{
|
|
$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();
|
|
|
|
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(#[CurrentUser] User $user): JsonResponse
|
|
{
|
|
[$entityType, $entityId] = $this->resolveEntity($user);
|
|
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];
|
|
}
|
|
|
|
$data = array_map(function (TenantInsurance $c) use ($byId) {
|
|
$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;
|
|
}, $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
|
|
{
|
|
[$entityType, $entityId] = $this->resolveEntity($user);
|
|
if ($entityId === null) {
|
|
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
|
}
|
|
|
|
$data = json_decode($request->getContent(), true) ?? [];
|
|
$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,
|
|
);
|
|
|
|
return $this->success(['data' => $contract->toArray()], 201);
|
|
}
|
|
|
|
#[Route('/api/v1/billing/tenant-insurances/{uuid}', methods: ['PATCH'])]
|
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
|
public function updateTenantInsurance(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
|
{
|
|
[$entityType, $entityId] = $this->resolveEntity($user);
|
|
$contract = $this->tenantInsuranceRepo->findByUuid($uuid);
|
|
if ($contract === null || $contract->getEntityType() !== $entityType || $contract->getEntityId() !== $entityId) {
|
|
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'قرارداد یافت نشد', 404);
|
|
}
|
|
|
|
$data = json_decode($request->getContent(), true) ?? [];
|
|
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);
|
|
|
|
return $this->success(['data' => $contract->toArray()]);
|
|
}
|
|
|
|
#[Route('/api/v1/billing/tenant-insurances/{uuid}', methods: ['DELETE'])]
|
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
|
public function deactivateTenantInsurance(string $uuid, #[CurrentUser] User $user): JsonResponse
|
|
{
|
|
[$entityType, $entityId] = $this->resolveEntity($user);
|
|
$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, #[CurrentUser] User $user): JsonResponse
|
|
{
|
|
[$entityType, $entityId] = $this->resolveEntity($user);
|
|
$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());
|
|
|
|
// Batch-fetch the referenced service items once instead of one find()
|
|
// per coverage row (N+1).
|
|
$itemIds = array_values(array_unique(array_map(fn($r) => $r->getServiceItemId(), $rows)));
|
|
$uuidById = [];
|
|
if ($itemIds !== []) {
|
|
foreach ($this->serviceItemRepo->findBy(['id' => $itemIds]) as $item) {
|
|
$uuidById[$item->getId()] = $item->getUuid();
|
|
}
|
|
}
|
|
|
|
$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
|
|
{
|
|
[$entityType, $entityId] = $this->resolveEntity($user);
|
|
$contract = $this->tenantInsuranceRepo->findByUuid($uuid);
|
|
if ($contract === null || $contract->getEntityType() !== $entityType || $contract->getEntityId() !== $entityId) {
|
|
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'قرارداد یافت نشد', 404);
|
|
}
|
|
|
|
$data = json_decode($request->getContent(), true) ?? [];
|
|
|
|
$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
|
|
{
|
|
$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
|
|
{
|
|
$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
|
|
{
|
|
$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
|
|
{
|
|
$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' => 'بیمه پزشک با موفقیت حذف شد']);
|
|
}
|
|
}
|