feat(claims): enrich claims with insurance details and item specifics
This commit is contained in:
@@ -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() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{c.items.length > 0 && (
|
||||
<div style={{ marginTop: 12, borderTop: '1px solid var(--border)', paddingTop: 10, overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12.5 }}>
|
||||
<thead>
|
||||
<tr style={{ color: 'var(--text-3)', textAlign: 'right' }}>
|
||||
<th style={{ fontWeight: 600, padding: '4px 8px' }}>شرح</th>
|
||||
<th style={{ fontWeight: 600, padding: '4px 8px' }}>تاریخ مراجعه</th>
|
||||
<th style={{ fontWeight: 600, padding: '4px 8px', textAlign: 'center' }}>تعداد</th>
|
||||
<th style={{ fontWeight: 600, padding: '4px 8px', textAlign: 'left' }}>مبلغ کل</th>
|
||||
<th style={{ fontWeight: 600, padding: '4px 8px', textAlign: 'left' }}>سهم بیمه (ادعا)</th>
|
||||
<th style={{ fontWeight: 600, padding: '4px 8px', textAlign: 'left' }}>تأییدشده</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{c.items.map((it, idx) => (
|
||||
<tr key={idx} style={{ borderTop: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '7px 8px' }}>
|
||||
<span className={`badge ${it.is_visit ? 'blue' : 'gray'}`} style={{ fontSize: 9.5, marginInlineEnd: 6 }}>
|
||||
{it.is_visit ? 'ویزیت' : 'خدمت'}
|
||||
</span>
|
||||
<span style={{ fontWeight: 500 }}>{it.title ?? '—'}</span>
|
||||
</td>
|
||||
<td style={{ padding: '7px 8px', color: 'var(--text-3)' }} dir="ltr">
|
||||
{it.visit_date ? formatDate(it.visit_date) : '—'}
|
||||
</td>
|
||||
<td style={{ padding: '7px 8px', textAlign: 'center' }}>{formatNumber(it.quantity)}</td>
|
||||
<td style={{ padding: '7px 8px', textAlign: 'left' }} dir="ltr">{it.total_rials != null ? formatRial(it.total_rials) : '—'}</td>
|
||||
<td style={{ padding: '7px 8px', textAlign: 'left', fontWeight: 600 }} dir="ltr">{formatRial(it.claimed_rials)}</td>
|
||||
<td style={{ padding: '7px 8px', textAlign: 'left', color: 'var(--text-3)' }} dir="ltr">
|
||||
{it.approved_rials != null ? formatRial(it.approved_rials) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
+29
-1
@@ -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`.
|
||||
|
||||
@@ -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'])]
|
||||
|
||||
@@ -20,4 +20,38 @@ class InvoiceItemRepository extends ServiceEntityRepository
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* جزئیات چند آیتم صورتحساب برای نمایش در مطالبات — کلیددار بر اساس id آیتم.
|
||||
* @param int[] $ids
|
||||
* @return array<int, array{title: string, service_item_id: int|null, quantity: int, total_rials: int, patient_session_id: int|null}>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -64,4 +64,29 @@ class PatientSessionRepository extends ServiceEntityRepository
|
||||
$this->getEntityManager()->persist($session);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* تاریخ ثبت چند مراجعه — کلیددار بر اساس id جلسه.
|
||||
* @param int[] $ids
|
||||
* @return array<int, int> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user