fix(patient): consistent debt/paid status, invoice totals, global service search
- patient_debt_rials now = session remaining (final - discount - paid), so the card status and invoice status stay consistent with is_paid even after a session is edited post-invoicing (no more '0 remaining but تکمیل پرداخت'). - InvoiceSummaryModal: remaining subtracts discount; the فاکتور status reflects actual settlement (تسویه شده / بدهکار), and the payments table shows a «مجموع پرداختیها» total row. - Add GET /api/v1/service-items (all owner services) and make the service picker searchable across all services without first choosing a section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -64,11 +64,13 @@ export default function InvoiceSummaryModal({ invoiceUuid, onClose }: { invoiceU
|
|||||||
const session = inv?.session ?? null;
|
const session = inv?.session ?? null;
|
||||||
|
|
||||||
const paid = inv?.status === 'paid';
|
const paid = inv?.status === 'paid';
|
||||||
// با session: مبالغ واقعی پرداخت؛ بدون آن (فاکتور قدیمی): heuristic قبلی.
|
// با session: مبالغ واقعی پرداخت (مبلغ نهایی منهای تخفیف و پرداختها)؛ بدون آن heuristic قبلی.
|
||||||
const remaining = session
|
const remaining = session
|
||||||
? session.final_price_rials - session.paid_total_rials
|
? Math.max(0, session.final_price_rials - (session.discount_rials ?? 0) - session.paid_total_rials)
|
||||||
: inv ? (paid ? 0 : inv.patient_rials) : 0;
|
: inv ? (paid ? 0 : inv.patient_rials) : 0;
|
||||||
const paidAmount = session ? session.paid_total_rials : inv ? inv.total_rials - remaining : 0;
|
const paidAmount = session ? session.paid_total_rials : inv ? inv.total_rials - remaining : 0;
|
||||||
|
// وضعیت واقعی پرداخت (مستقل از وضعیت فریزشدهی فاکتور): تسویهشده اگر مانده صفر.
|
||||||
|
const statusLabel = session ? (remaining <= 0 ? 'تسویه شده' : 'بدهکار') : (inv ? (STATUS_LABEL[inv.status] ?? inv.status) : '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={!!invoiceUuid} onClose={onClose} title="خلاصه فاکتور" size="xl">
|
<Modal open={!!invoiceUuid} onClose={onClose} title="خلاصه فاکتور" size="xl">
|
||||||
@@ -82,7 +84,7 @@ export default function InvoiceSummaryModal({ invoiceUuid, onClose }: { invoiceU
|
|||||||
rows={[[
|
rows={[[
|
||||||
formatDate(session?.session_at ?? inv.issued_at),
|
formatDate(session?.session_at ?? inv.issued_at),
|
||||||
session?.paid_at ? formatDate(session.paid_at) : paid ? formatDate(inv.issued_at) : '—',
|
session?.paid_at ? formatDate(session.paid_at) : paid ? formatDate(inv.issued_at) : '—',
|
||||||
STATUS_LABEL[inv.status] ?? inv.status,
|
<span style={{ color: remaining > 0 ? '#d32f2f' : '#388e3c', fontWeight: 600 }}>{statusLabel}</span>,
|
||||||
]]}
|
]]}
|
||||||
/>
|
/>
|
||||||
<SectionTable
|
<SectionTable
|
||||||
@@ -125,13 +127,16 @@ export default function InvoiceSummaryModal({ invoiceUuid, onClose }: { invoiceU
|
|||||||
title="پرداختی ها"
|
title="پرداختی ها"
|
||||||
cols={['ردیف', 'شیوه پرداخت', 'مبلغ', 'تاریخ و ساعت', 'ثبتکننده']}
|
cols={['ردیف', 'شیوه پرداخت', 'مبلغ', 'تاریخ و ساعت', 'ثبتکننده']}
|
||||||
rows={session.payments.length
|
rows={session.payments.length
|
||||||
? session.payments.map((p, i) => [
|
? [
|
||||||
i + 1,
|
...session.payments.map((p, i) => [
|
||||||
METHOD_LABELS[p.method] ?? p.method,
|
i + 1,
|
||||||
formatRial(p.amount_rials),
|
METHOD_LABELS[p.method] ?? p.method,
|
||||||
p.paid_at ? formatDateTime(p.paid_at) : '-',
|
formatRial(p.amount_rials),
|
||||||
p.created_by_name ?? '-',
|
p.paid_at ? formatDateTime(p.paid_at) : '-',
|
||||||
])
|
p.created_by_name ?? '-',
|
||||||
|
]),
|
||||||
|
['', <span style={{ fontWeight: 700 }}>مجموع پرداختیها</span>, <span style={{ fontWeight: 700 }}>{formatRial(session.paid_total_rials)}</span>, '', ''],
|
||||||
|
]
|
||||||
: [['-', '-', '-', '-', '-']]}
|
: [['-', '-', '-', '-', '-']]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -90,6 +90,11 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
|
|||||||
const { data: sectionsData } = useQuery<ApiResponse<ServiceSection[]>>({
|
const { data: sectionsData } = useQuery<ApiResponse<ServiceSection[]>>({
|
||||||
queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections'),
|
queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections'),
|
||||||
});
|
});
|
||||||
|
// همهی سرویسها (سراسری) تا بتوان بدون انتخاب بخش هم جستجو و انتخاب کرد.
|
||||||
|
const { data: allItemsData } = useQuery<ApiResponse<ServiceItem[]>>({
|
||||||
|
queryKey: ['service-items-all'],
|
||||||
|
queryFn: () => api.get('/api/v1/service-items'),
|
||||||
|
});
|
||||||
const { data: itemsData } = useQuery<ApiResponse<ServiceItem[]>>({
|
const { data: itemsData } = useQuery<ApiResponse<ServiceItem[]>>({
|
||||||
queryKey: ['service-items-for-session', sectionUuid],
|
queryKey: ['service-items-for-session', sectionUuid],
|
||||||
queryFn: () => api.get(`/api/v1/service-items/${sectionUuid}`),
|
queryFn: () => api.get(`/api/v1/service-items/${sectionUuid}`),
|
||||||
@@ -144,7 +149,8 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
|
|||||||
const suppOpts = contracts.filter(c => c.insurance_kind === 'supplementary').map(c => ({ value: String(c.insurance_id), label: c.insurance_name ?? `#${c.insurance_id}` }));
|
const suppOpts = contracts.filter(c => c.insurance_kind === 'supplementary').map(c => ({ value: String(c.insurance_id), label: c.insurance_name ?? `#${c.insurance_id}` }));
|
||||||
|
|
||||||
const sectionOptions = (sectionsData?.data ?? []).map(s => ({ value: s.uuid, label: s.name }));
|
const sectionOptions = (sectionsData?.data ?? []).map(s => ({ value: s.uuid, label: s.name }));
|
||||||
const serviceItems = (itemsData?.data ?? []).filter(i => i.active);
|
// با انتخاب بخش، فقط سرویسهای همان بخش؛ بدون بخش، همهی سرویسها (قابل جستجو).
|
||||||
|
const serviceItems = (sectionUuid ? (itemsData?.data ?? []) : (allItemsData?.data ?? [])).filter(i => i.active);
|
||||||
const itemOptions = serviceItems.map(i => ({ value: i.uuid, label: i.name }));
|
const itemOptions = serviceItems.map(i => ({ value: i.uuid, label: i.name }));
|
||||||
const currentItem = serviceItems.find(i => i.uuid === itemUuid);
|
const currentItem = serviceItems.find(i => i.uuid === itemUuid);
|
||||||
|
|
||||||
@@ -325,7 +331,7 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||||
<div style={{ width: '50%' }}>
|
<div style={{ width: '50%' }}>
|
||||||
<span style={fieldLabel}>انتخاب سرویس</span>
|
<span style={fieldLabel}>انتخاب سرویس</span>
|
||||||
<SearchableSelect inputId="service-select" options={itemOptions} value={itemUuid} onChange={v => setItemUuid(v ? String(v) : '')} placeholder="انتخاب کنید..." isDisabled={!sectionUuid} />
|
<SearchableSelect inputId="service-select" options={itemOptions} value={itemUuid} onChange={v => setItemUuid(v ? String(v) : '')} placeholder="جستجو و انتخاب سرویس..." />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 20, flexShrink: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 20, flexShrink: 0 }}>
|
||||||
<FilesServiceAddCard color="#6B7280" />
|
<FilesServiceAddCard color="#6B7280" />
|
||||||
|
|||||||
@@ -76,6 +76,10 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## GET /api/v1/service-items
|
||||||
|
|
||||||
|
همهی سرویسهای owner در همهی بخشها (برای انتخاب/جستجوی سراسری در فرم ثبت/ویرایش مراجعه). پاسخ مثل لیست هر بخش (آرایهی `ServiceItem::toArray`)، مرتب بر نام.
|
||||||
|
|
||||||
## GET /api/v1/service-items/{sectionUuid}
|
## GET /api/v1/service-items/{sectionUuid}
|
||||||
|
|
||||||
لیست سرویسهای یک بخش.
|
لیست سرویسهای یک بخش.
|
||||||
|
|||||||
@@ -122,6 +122,20 @@ class ClinicServiceController extends BaseController
|
|||||||
|
|
||||||
// ── Service Items ────────────────────────────────────────────────────────
|
// ── Service Items ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** همهی سرویسهای owner در همهی بخشها — برای انتخاب/جستجوی سراسری. */
|
||||||
|
#[Route('/api/v1/service-items', methods: ['GET'])]
|
||||||
|
public function listAllItems(#[CurrentUser] User $user): JsonResponse
|
||||||
|
{
|
||||||
|
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||||
|
|
||||||
|
$items = array_map(
|
||||||
|
fn(ServiceItem $i) => $i->toArray(),
|
||||||
|
$this->itemRepo->findByEntity($entityType, $entityId)
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->success($items);
|
||||||
|
}
|
||||||
|
|
||||||
#[Route('/api/v1/service-items/{sectionUuid}', methods: ['GET'])]
|
#[Route('/api/v1/service-items/{sectionUuid}', methods: ['GET'])]
|
||||||
public function listItems(string $sectionUuid, #[CurrentUser] User $user): JsonResponse
|
public function listItems(string $sectionUuid, #[CurrentUser] User $user): JsonResponse
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,6 +29,18 @@ class ServiceItemRepository extends ServiceEntityRepository
|
|||||||
->getResult();
|
->getResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** همهی سرویسهای یک owner (در همهی بخشها) — برای انتخاب/جستجوی سراسری. */
|
||||||
|
public function findByEntity(string $entityType, int $entityId): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('i')
|
||||||
|
->join('i.section', 's')
|
||||||
|
->where('s.entityType = :type')->setParameter('type', $entityType)
|
||||||
|
->andWhere('s.entityId = :id')->setParameter('id', $entityId)
|
||||||
|
->orderBy('i.name', 'ASC')
|
||||||
|
->getQuery()
|
||||||
|
->getResult();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Count services per section in a single query (avoids N+1 in the section list).
|
* Count services per section in a single query (avoids N+1 in the section list).
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -982,13 +982,9 @@ class PatientController extends BaseController
|
|||||||
$data['invoice_uuid'] = $invoice?->getUuid();
|
$data['invoice_uuid'] = $invoice?->getUuid();
|
||||||
$data['invoice_status'] = $invoice?->getStatus();
|
$data['invoice_status'] = $invoice?->getStatus();
|
||||||
|
|
||||||
if ($data['is_paid']) {
|
// ماندهی بدهی همیشه از خودِ مراجعه (سازگار با is_paid): مبلغ نهایی منهای تخفیف
|
||||||
$data['patient_debt_rials'] = 0;
|
// و پرداختها. اگر مراجعه پس از صدور فاکتور ویرایش شود، همین منبعِ واحد ملاک است.
|
||||||
} else {
|
$data['patient_debt_rials'] = $session->getRemainingRials();
|
||||||
// سهم بیمار (از فاکتور در صورت وجود) منهای تخفیف تسویه و پرداختهای جزئی
|
|
||||||
$share = $invoice !== null ? $invoice->getPatientRials() : $session->getFinalPriceRials();
|
|
||||||
$data['patient_debt_rials'] = max(0, $share - $session->getDiscountRials() - $session->getPaidTotalRials());
|
|
||||||
}
|
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user