From c189daa860c3c5fecfe5ea12e7258f7012de2bf1 Mon Sep 17 00:00:00 2001
From: hamed <15238-genius.ha@users.noreply.drupalcode.org>
Date: Wed, 24 Jun 2026 04:48:11 +0330
Subject: [PATCH] feat(claims): enrich claims with insurance details and item
specifics
---
assets/admin/pages/ClaimsPage.tsx | 45 +++++++++++++++-
docs/api/billing.md | 30 ++++++++++-
src/Billing/Controller/BillingController.php | 51 ++++++++++++++++---
.../Repository/InvoiceItemRepository.php | 34 +++++++++++++
src/Billing/Service/InvoiceService.php | 5 +-
src/Patient/Entity/PatientSession.php | 8 +++
.../Repository/PatientSessionRepository.php | 25 +++++++++
src/Patient/Service/PatientService.php | 1 +
8 files changed, 188 insertions(+), 11 deletions(-)
diff --git a/assets/admin/pages/ClaimsPage.tsx b/assets/admin/pages/ClaimsPage.tsx
index 66d85d1d..f088a62f 100644
--- a/assets/admin/pages/ClaimsPage.tsx
+++ b/assets/admin/pages/ClaimsPage.tsx
@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { CheckIcon, XMarkIcon, PaperAirplaneIcon, BanknotesIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
-import { formatRial, formatNumber } from '../lib/utils';
+import { formatRial, formatNumber, formatDate } from '../lib/utils';
import PageHeader from '../components/ui/PageHeader';
import Modal from '../components/ui/Modal';
import SearchableSelect from '../components/ui/SearchableSelect';
@@ -13,6 +13,11 @@ interface ClaimItem {
invoice_item_id: number;
claimed_rials: number;
approved_rials: number | null;
+ title: string | null;
+ is_visit: boolean;
+ quantity: number;
+ total_rials: number | null;
+ visit_date: number | null;
}
interface Claim {
@@ -167,6 +172,44 @@ export default function ClaimsPage() {
)}
+
+ {c.items.length > 0 && (
+
+
+
+
+ | شرح |
+ تاریخ مراجعه |
+ تعداد |
+ مبلغ کل |
+ سهم بیمه (ادعا) |
+ تأییدشده |
+
+
+
+ {c.items.map((it, idx) => (
+
+ |
+
+ {it.is_visit ? 'ویزیت' : 'خدمت'}
+
+ {it.title ?? '—'}
+ |
+
+ {it.visit_date ? formatDate(it.visit_date) : '—'}
+ |
+ {formatNumber(it.quantity)} |
+ {it.total_rials != null ? formatRial(it.total_rials) : '—'} |
+ {formatRial(it.claimed_rials)} |
+
+ {it.approved_rials != null ? formatRial(it.approved_rials) : '—'}
+ |
+
+ ))}
+
+
+
+ )}
);
})}
diff --git a/docs/api/billing.md b/docs/api/billing.md
index cb52ec23..4fd24dbd 100644
--- a/docs/api/billing.md
+++ b/docs/api/billing.md
@@ -111,7 +111,35 @@
هر مطالبه در پاسخ علاوه بر `insurance_id`، فیلد `insurance_name` (نام بیمه، یا `null` اگر بیمه حذف شده باشد) را نیز دارد. در `POST` و `transition` و `GET` یکسان است.
## GET /api/v1/billing/claims?status=
-لیست مطالبات tenant. فیلتر اختیاری `status` (`pending|submitted|approved|rejected|paid`). هر آیتم شامل `insurance_name` است.
+لیست مطالبات tenant. فیلتر اختیاری `status` (`pending|submitted|approved|rejected|paid`). هر مطالبه شامل `insurance_name` و آرایهی `items` با جزئیات هر ردیف است.
+
+هر عضو `items` علاوه بر `invoice_item_id`، `claimed_rials`، `approved_rials`، این فیلدهای نمایشی را دارد (برای جدول صفحهی مطالبات):
+
+| فیلد | نوع | توضیح |
+|------|-----|-------|
+| `title` | string \| null | شرح ردیف؛ «ویزیت» یا نام خدمت |
+| `is_visit` | bool | `true` اگر ردیف ویزیت باشد (`service_item_id` تهی) |
+| `quantity` | int | تعداد |
+| `total_rials` | int \| null | مبلغ کل آن ردیف (قیمت کامل، قبل از بیمه) |
+| `visit_date` | int \| null | تاریخ ثبت مراجعه (Unix؛ نمایش شمسی) |
+
+```json
+{
+ "success": true,
+ "data": { "data": [
+ {
+ "uuid": "…", "insurance_id": 3, "insurance_name": "تأمین اجتماعی", "insurance_kind": "base",
+ "total_claimed_rials": 240000, "status": "pending",
+ "items": [
+ { "invoice_item_id": 12, "title": "ویزیت", "is_visit": true, "quantity": 1, "total_rials": 300000, "claimed_rials": 240000, "approved_rials": null, "visit_date": 1718000000 },
+ { "invoice_item_id": 13, "title": "سرم ۵۰۰cc", "is_visit": false, "quantity": 2, "total_rials": 170000, "claimed_rials": 0, "approved_rials": null, "visit_date": 1718000000 }
+ ]
+ }
+ ] }
+}
+```
+
+> این فیلدها با یک کوئری گروهی (`InvoiceItemRepository::detailsForIds` + `PatientSessionRepository::datesForIds`) پر میشوند تا N+1 رخ ندهد. همان enrichment روی پاسخ `POST /claims` و `POST /claims/{uuid}/{action}` هم اعمال میشود.
## POST /api/v1/billing/claims/{uuid}/{action}
انتقال وضعیت. `action` ∈ `submit|approve|reject|pay`.
diff --git a/src/Billing/Controller/BillingController.php b/src/Billing/Controller/BillingController.php
index 24c376bb..d93cd47b 100644
--- a/src/Billing/Controller/BillingController.php
+++ b/src/Billing/Controller/BillingController.php
@@ -5,6 +5,7 @@ namespace App\Billing\Controller;
use App\Auth\Entity\User;
use App\Billing\Entity\Claim;
use App\Billing\Repository\ClaimRepository;
+use App\Billing\Repository\InvoiceItemRepository;
use App\Billing\Repository\InvoiceRepository;
use App\Billing\Service\ClaimService;
use App\Billing\Service\InvoiceService;
@@ -28,6 +29,7 @@ class BillingController extends BaseController
public function __construct(
private readonly InvoiceService $invoiceService,
private readonly InvoiceRepository $invoiceRepo,
+ private readonly InvoiceItemRepository $invoiceItemRepo,
private readonly ClaimService $claimService,
private readonly ClaimRepository $claimRepo,
private readonly PatientSessionRepository $sessionRepo,
@@ -36,11 +38,46 @@ class BillingController extends BaseController
private readonly InsuranceRepository $insuranceRepo,
) {}
- private function claimArray(Claim $claim): array
+ /**
+ * مطالبات را با نام بیمه و جزئیات هر آیتم (نوع/نام، تاریخ مراجعه، تعداد) غنی میکند.
+ * @param Claim[] $claims
+ */
+ private function enrichClaims(array $claims): array
{
- $data = $claim->toArray();
- $data['insurance_name'] = $this->insuranceRepo->find($claim->getInsuranceId())?->getName();
- return $data;
+ // جمعآوری همهی invoice_item_idها در یک رفتوآمد
+ $invoiceItemIds = [];
+ foreach ($claims as $claim) {
+ foreach ($claim->getItems() as $ci) {
+ $invoiceItemIds[] = $ci->getInvoiceItemId();
+ }
+ }
+ $itemDetails = $this->invoiceItemRepo->detailsForIds(array_values(array_unique($invoiceItemIds)));
+
+ $sessionIds = [];
+ foreach ($itemDetails as $d) {
+ if ($d['patient_session_id'] !== null) {
+ $sessionIds[] = $d['patient_session_id'];
+ }
+ }
+ $sessionDates = $this->sessionRepo->datesForIds(array_values(array_unique($sessionIds)));
+
+ return array_map(function (Claim $claim) use ($itemDetails, $sessionDates) {
+ $data = $claim->toArray();
+ $data['insurance_name'] = $this->insuranceRepo->find($claim->getInsuranceId())?->getName();
+
+ $data['items'] = array_map(function (array $item) use ($itemDetails, $sessionDates) {
+ $detail = $itemDetails[$item['invoice_item_id']] ?? null;
+ $sessionId = $detail['patient_session_id'] ?? null;
+ $item['title'] = $detail['title'] ?? null;
+ $item['is_visit'] = $detail !== null && $detail['service_item_id'] === null;
+ $item['quantity'] = $detail['quantity'] ?? 1;
+ $item['total_rials'] = $detail['total_rials'] ?? null;
+ $item['visit_date'] = $sessionId !== null ? ($sessionDates[$sessionId] ?? null) : null;
+ return $item;
+ }, $data['items']);
+
+ return $data;
+ }, $claims);
}
#[Route('/api/v1/billing/invoices', methods: ['POST'])]
@@ -116,7 +153,7 @@ class BillingController extends BaseController
$claims = $this->claimService->createFromInvoice($invoice);
- return $this->success(['data' => array_map(fn(Claim $c) => $this->claimArray($c), $claims)], 201);
+ return $this->success(['data' => $this->enrichClaims($claims)], 201);
}
#[Route('/api/v1/billing/claims', methods: ['GET'])]
@@ -130,7 +167,7 @@ class BillingController extends BaseController
$status = $request->query->get('status') ?: null;
$claims = $this->claimRepo->findByTenant($entityType, $entityId, $status);
- return $this->success(['data' => array_map(fn(Claim $c) => $this->claimArray($c), $claims)]);
+ return $this->success(['data' => $this->enrichClaims($claims)]);
}
#[Route('/api/v1/billing/claims/{uuid}/{action}', methods: ['POST'], requirements: ['action' => 'submit|approve|reject|pay'])]
@@ -160,7 +197,7 @@ class BillingController extends BaseController
'reason' => trim($data['reason'] ?? ''),
]);
- return $this->success(['data' => $this->claimArray($claim)]);
+ return $this->success(['data' => $this->enrichClaims([$claim])[0]]);
}
#[Route('/api/v1/billing/reports/insurance-debt', methods: ['GET'])]
diff --git a/src/Billing/Repository/InvoiceItemRepository.php b/src/Billing/Repository/InvoiceItemRepository.php
index 22b4a1e2..aa46dfa0 100644
--- a/src/Billing/Repository/InvoiceItemRepository.php
+++ b/src/Billing/Repository/InvoiceItemRepository.php
@@ -20,4 +20,38 @@ class InvoiceItemRepository extends ServiceEntityRepository
$this->getEntityManager()->flush();
}
}
+
+ /**
+ * جزئیات چند آیتم صورتحساب برای نمایش در مطالبات — کلیددار بر اساس id آیتم.
+ * @param int[] $ids
+ * @return array
+ */
+ public function detailsForIds(array $ids): array
+ {
+ if ($ids === []) {
+ return [];
+ }
+
+ $rows = $this->createQueryBuilder('ii')
+ ->select('ii.id AS id', 'ii.title AS title', 'ii.serviceItemId AS service_item_id',
+ 'ii.quantity AS quantity', 'ii.totalRials AS total_rials',
+ 'inv.patientSessionId AS patient_session_id')
+ ->join('ii.invoice', 'inv')
+ ->where('ii.id IN (:ids)')
+ ->setParameter('ids', $ids)
+ ->getQuery()
+ ->getArrayResult();
+
+ $out = [];
+ foreach ($rows as $r) {
+ $out[(int) $r['id']] = [
+ 'title' => $r['title'],
+ 'service_item_id' => $r['service_item_id'] !== null ? (int) $r['service_item_id'] : null,
+ 'quantity' => (int) $r['quantity'],
+ 'total_rials' => (int) $r['total_rials'],
+ 'patient_session_id' => $r['patient_session_id'] !== null ? (int) $r['patient_session_id'] : null,
+ ];
+ }
+ return $out;
+ }
}
diff --git a/src/Billing/Service/InvoiceService.php b/src/Billing/Service/InvoiceService.php
index f6701273..e4c92c0a 100644
--- a/src/Billing/Service/InvoiceService.php
+++ b/src/Billing/Service/InvoiceService.php
@@ -52,14 +52,15 @@ class InvoiceService
// خدمات
foreach ($session->getServices() as $sessionService) {
$item = $sessionService->getServiceItem();
+ $qty = max(1, $sessionService->getQuantity());
$unitPrice = $this->tariffService->resolvePrice($item);
- $total = new Money($unitPrice);
+ $total = new Money($unitPrice * $qty);
$baseRule = $this->tenantInsuranceService->coverageRuleForService($entityType, $entityId, $baseId, $item->getId());
$suppRule = $this->tenantInsuranceService->coverageRuleForService($entityType, $entityId, $suppId, $item->getId());
$breakdown = $this->calculator->calculateItem($total, $baseRule, $suppRule);
- $invoice->addItem(new InvoiceItem($invoice, $item->getName(), $unitPrice, 1, $breakdown, $item->getId()));
+ $invoice->addItem(new InvoiceItem($invoice, $item->getName(), $unitPrice, $qty, $breakdown, $item->getId()));
}
$invoice->recalculateTotals();
diff --git a/src/Patient/Entity/PatientSession.php b/src/Patient/Entity/PatientSession.php
index d53435af..28db893f 100644
--- a/src/Patient/Entity/PatientSession.php
+++ b/src/Patient/Entity/PatientSession.php
@@ -82,6 +82,14 @@ class PatientSession
public function getInsuranceBaseId(): ?int { return $this->insuranceBaseId; }
public function getInsuranceSupplementaryId(): ?int { return $this->insuranceSupplementaryId; }
public function getServices(): Collection { return $this->services; }
+
+ public function addService(SessionService $service): self
+ {
+ if (!$this->services->contains($service)) {
+ $this->services->add($service);
+ }
+ return $this;
+ }
public function getVisitPriceRials(): int { return $this->visitPriceRials; }
public function getBaseInsuranceDiscountPercent(): float { return (float) $this->baseInsuranceDiscountPercent; }
public function getSupplementaryDiscountPercent(): float { return (float) $this->supplementaryDiscountPercent; }
diff --git a/src/Patient/Repository/PatientSessionRepository.php b/src/Patient/Repository/PatientSessionRepository.php
index 27471b6d..1be247f9 100644
--- a/src/Patient/Repository/PatientSessionRepository.php
+++ b/src/Patient/Repository/PatientSessionRepository.php
@@ -64,4 +64,29 @@ class PatientSessionRepository extends ServiceEntityRepository
$this->getEntityManager()->persist($session);
$this->getEntityManager()->flush();
}
+
+ /**
+ * تاریخ ثبت چند مراجعه — کلیددار بر اساس id جلسه.
+ * @param int[] $ids
+ * @return array sessionId => createdAt
+ */
+ public function datesForIds(array $ids): array
+ {
+ if ($ids === []) {
+ return [];
+ }
+
+ $rows = $this->createQueryBuilder('s')
+ ->select('s.id AS id', 's.createdAt AS created_at')
+ ->where('s.id IN (:ids)')
+ ->setParameter('ids', $ids)
+ ->getQuery()
+ ->getArrayResult();
+
+ $out = [];
+ foreach ($rows as $r) {
+ $out[(int) $r['id']] = (int) $r['created_at'];
+ }
+ return $out;
+ }
}
diff --git a/src/Patient/Service/PatientService.php b/src/Patient/Service/PatientService.php
index 6fc28710..e6680032 100644
--- a/src/Patient/Service/PatientService.php
+++ b/src/Patient/Service/PatientService.php
@@ -173,6 +173,7 @@ class PatientService
$qty = max(1, (int) ($svc['quantity'] ?? 1));
$ss = new SessionService($session, $item, $staff, $qty);
$this->sessionServiceRepo->save($ss);
+ $session->addService($ss);
}
return $session;