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;