fix: issue invoice on demand so view-invoice buttons actually work
The admin SPA never called POST /billing/invoices, so every session had invoice_uuid = null and the view-invoice buttons on the patient services tab (and MyPatients visit modal) were permanently disabled. Add a shared useIssueInvoice hook (idempotent create + finalize when draft) and wire it into: - DetailsStep: the wizard's final step now really issues the invoice - SessionServiceCard / PatientDetailPage: clicking view-invoice on a session without an invoice issues it first, then opens the summary - MyPatientsPage visit modal: same, replacing the dead disabled button Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -45,11 +45,14 @@ function Row({ label, value, valueStyle }: { label: string; value: string; value
|
||||
* pixel-for-pixel from tauri files/services/ServiceCard. Paid cards show
|
||||
* «مشاهده فاکتور», unpaid ones «تکمیل پرداخت».
|
||||
*/
|
||||
export default function SessionServiceCard({ session, onSettle, onViewInvoice, settling }: {
|
||||
export default function SessionServiceCard({ session, onSettle, onViewInvoice, settling, issuing }: {
|
||||
session: SessionCardData;
|
||||
onSettle: (uuid: string) => void;
|
||||
onViewInvoice: (invoiceUuid: string) => void;
|
||||
/** کل session پاس میشود؛ اگر invoice_uuid نداشت، caller فاکتور را میسازد. */
|
||||
onViewInvoice: (session: SessionCardData) => void;
|
||||
settling?: boolean;
|
||||
/** true وقتی صدور فاکتور همین لحظه در جریان است (دکمه قفل میشود). */
|
||||
issuing?: boolean;
|
||||
}) {
|
||||
const paid = !!session.is_paid;
|
||||
const names = (session.services ?? []).map((s) => s.service_name || s.name).filter(Boolean) as string[];
|
||||
@@ -94,11 +97,11 @@ export default function SessionServiceCard({ session, onSettle, onViewInvoice, s
|
||||
{paid ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!session.invoice_uuid}
|
||||
onClick={() => session.invoice_uuid && onViewInvoice(session.invoice_uuid)}
|
||||
disabled={issuing}
|
||||
onClick={() => onViewInvoice(session)}
|
||||
style={{ marginTop: 8, height: 40, width: '100%', borderRadius: 4, fontSize: 13, fontWeight: 500, cursor: 'pointer', color: '#4F46E5', background: 'transparent', border: '1px solid #5559ce' }}
|
||||
>
|
||||
مشاهده فاکتور
|
||||
{issuing ? 'در حال صدور…' : 'مشاهده فاکتور'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../../test/utils';
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock('../../lib/api', () => ({
|
||||
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||
ApiError: class extends Error {},
|
||||
}));
|
||||
|
||||
import { api } from '../../lib/api';
|
||||
import { toast } from 'sonner';
|
||||
import DetailsStep from './DetailsStep';
|
||||
import type { SessionCardData } from '../SessionServiceCard';
|
||||
|
||||
const post = api.post as ReturnType<typeof vi.fn>;
|
||||
|
||||
const session: SessionCardData = {
|
||||
uuid: 's1',
|
||||
services: [{ service_name: 'لیزر' }],
|
||||
doctor_name: 'دکتر فتحی',
|
||||
final_price_rials: 1_000_000,
|
||||
patient_debt_rials: 0,
|
||||
discount_rials: 0,
|
||||
visit_price_rials: 0,
|
||||
payments: [],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
post.mockReset();
|
||||
(toast.error as ReturnType<typeof vi.fn>).mockReset();
|
||||
});
|
||||
|
||||
describe('DetailsStep — صدور فاکتور', () => {
|
||||
it('فاکتور را میسازد، draft را finalize میکند و بعد onFinish', async () => {
|
||||
post.mockImplementation((url: string) =>
|
||||
url === '/api/v1/billing/invoices'
|
||||
? Promise.resolve({ success: true, data: { data: { uuid: 'iv1', status: 'draft' } } })
|
||||
: Promise.resolve({ success: true, data: {} }),
|
||||
);
|
||||
const onFinish = vi.fn();
|
||||
renderWithProviders(<DetailsStep session={session} recordUuid="r1" onBack={() => {}} onFinish={onFinish} />);
|
||||
|
||||
fireEvent.click(screen.getByText('صدور فاکتور'));
|
||||
|
||||
await waitFor(() => expect(onFinish).toHaveBeenCalled());
|
||||
expect(post).toHaveBeenCalledWith('/api/v1/billing/invoices', { session_uuid: 's1' });
|
||||
expect(post).toHaveBeenCalledWith('/api/v1/billing/invoices/iv1/finalize', {});
|
||||
});
|
||||
|
||||
it('فاکتور غیر draft (idempotent create) دیگر finalize نمیشود', async () => {
|
||||
post.mockResolvedValue({ success: true, data: { data: { uuid: 'iv1', status: 'finalized' } } });
|
||||
const onFinish = vi.fn();
|
||||
renderWithProviders(<DetailsStep session={session} onBack={() => {}} onFinish={onFinish} />);
|
||||
|
||||
fireEvent.click(screen.getByText('صدور فاکتور'));
|
||||
|
||||
await waitFor(() => expect(onFinish).toHaveBeenCalled());
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('session با invoice_uuid موجود: بدون درخواست، مستقیم onFinish', async () => {
|
||||
const onFinish = vi.fn();
|
||||
renderWithProviders(
|
||||
<DetailsStep session={{ ...session, invoice_uuid: 'iv0' }} onBack={() => {}} onFinish={onFinish} />,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('صدور فاکتور'));
|
||||
|
||||
await waitFor(() => expect(onFinish).toHaveBeenCalled());
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('خطای صدور: toast خطا و onFinish صدا زده نمیشود', async () => {
|
||||
post.mockRejectedValue(new Error('boom'));
|
||||
const onFinish = vi.fn();
|
||||
renderWithProviders(<DetailsStep session={session} onBack={() => {}} onFinish={onFinish} />);
|
||||
|
||||
fireEvent.click(screen.getByText('صدور فاکتور'));
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('صدور فاکتور ناموفق بود'));
|
||||
expect(onFinish).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,16 @@
|
||||
import { toast } from 'sonner';
|
||||
import { formatRial } from '../../lib/utils';
|
||||
import type { SessionCardData } from '../SessionServiceCard';
|
||||
import { METHOD_LABELS } from './PaymentStep';
|
||||
import { useIssueInvoice } from '../../hooks/useIssueInvoice';
|
||||
|
||||
const primaryBtn: React.CSSProperties = { background: '#5559CE', color: '#fff', border: 'none', borderRadius: 4, height: 46, fontSize: 14, fontWeight: 500, cursor: 'pointer' };
|
||||
const ghostBtn: React.CSSProperties = { background: 'transparent', color: '#5559CE', border: '1px solid #5559CE', borderRadius: 4, height: 46, fontSize: 14, fontWeight: 500, cursor: 'pointer' };
|
||||
|
||||
interface Props {
|
||||
session: SessionCardData;
|
||||
/** برای invalidate شدن لیست sessions بعد از صدور فاکتور. */
|
||||
recordUuid?: string;
|
||||
onBack: () => void;
|
||||
onFinish: () => void;
|
||||
}
|
||||
@@ -15,8 +19,17 @@ interface Props {
|
||||
* گام «جزییات» — پورت tauri Step3Final با دیتای واقعی:
|
||||
* خلاصهی سرویس/هزینه/تخفیف + لیست پرداختشدهها + مانده. مشترک بین
|
||||
* NewSessionPage (ویزارد سهگامه) و SessionPaymentPage (دوگامه).
|
||||
* «صدور فاکتور» فاکتور را واقعاً صادر میکند (create + finalize) و بعد onFinish.
|
||||
*/
|
||||
export default function DetailsStep({ session, onBack, onFinish }: Props) {
|
||||
export default function DetailsStep({ session, recordUuid, onBack, onFinish }: Props) {
|
||||
const issueMut = useIssueInvoice(recordUuid);
|
||||
const issue = () => {
|
||||
if (session.invoice_uuid) { onFinish(); return; }
|
||||
issueMut.mutate(session.uuid, {
|
||||
onSuccess: () => onFinish(),
|
||||
onError: () => toast.error('صدور فاکتور ناموفق بود'),
|
||||
});
|
||||
};
|
||||
const finalPrice = session.final_price_rials ?? 0;
|
||||
const discountRials = session.discount_rials ?? 0;
|
||||
const debt = session.patient_debt_rials ?? 0;
|
||||
@@ -71,7 +84,9 @@ export default function DetailsStep({ session, onBack, onFinish }: Props) {
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 16, width: '100%', maxWidth: 320, margin: '16px auto 0' }}>
|
||||
<button type="button" style={{ ...ghostBtn, flex: 1 }} onClick={onBack}>انصراف</button>
|
||||
<button type="button" style={{ ...primaryBtn, flex: 1 }} onClick={onFinish}>صدور فاکتور</button>
|
||||
<button type="button" disabled={issueMut.isPending} style={{ ...primaryBtn, flex: 1 }} onClick={issue}>
|
||||
{issueMut.isPending ? 'در حال صدور…' : 'صدور فاکتور'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../lib/api';
|
||||
|
||||
interface InvoicePayload { uuid: string; status: string }
|
||||
|
||||
/**
|
||||
* صدور فاکتور یک مراجعه: ساخت idempotent (`POST /billing/invoices`) و سپس
|
||||
* نهاییسازی draft. خروجی mutation، uuid فاکتور است تا caller مودال «خلاصه
|
||||
* فاکتور» را باز کند. با recordUuid، لیست sessions همان بیمار invalidate
|
||||
* میشود تا invoice_uuid کارتها بهروز شود.
|
||||
*/
|
||||
export function useIssueInvoice(recordUuid?: string) {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (sessionUuid: string): Promise<string> => {
|
||||
const res = await api.post<any>('/api/v1/billing/invoices', { session_uuid: sessionUuid });
|
||||
const invoice = ((res as any)?.data?.data ?? (res as any)?.data) as InvoicePayload;
|
||||
if (invoice.status === 'draft') {
|
||||
await api.post(`/api/v1/billing/invoices/${invoice.uuid}/finalize`, {});
|
||||
}
|
||||
return invoice.uuid;
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (recordUuid) qc.invalidateQueries({ queryKey: ['patient-sessions', recordUuid] });
|
||||
qc.invalidateQueries({ queryKey: ['invoice'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
} from "../lib/patientForm";
|
||||
import type { ApiResponse, PaginatedResponse } from "../lib/api";
|
||||
import { api } from "../lib/api";
|
||||
import { useIssueInvoice } from "../hooks/useIssueInvoice";
|
||||
import {
|
||||
formatDate,
|
||||
formatDateTime,
|
||||
@@ -210,6 +211,16 @@ function MyPatientsPageInner() {
|
||||
>("visits");
|
||||
const [expandedVisit, setExpandedVisit] = useState<string | null>(null);
|
||||
const [invoiceUuid, setInvoiceUuid] = useState<string | null>(null);
|
||||
|
||||
// «مشاهده فاکتور»: نبودِ invoice_uuid یعنی اول صادر شود (idempotent create + finalize).
|
||||
const issueInvoiceMut = useIssueInvoice(selectedRecord?.uuid);
|
||||
const viewInvoice = (s: PatientSession) => {
|
||||
if (s.invoice_uuid) { setExpandedVisit(null); setInvoiceUuid(s.invoice_uuid); return; }
|
||||
issueInvoiceMut.mutate(s.uuid, {
|
||||
onSuccess: (iv) => { setExpandedVisit(null); setInvoiceUuid(iv); },
|
||||
onError: () => toast.error("صدور فاکتور ناموفق بود"),
|
||||
});
|
||||
};
|
||||
const [sessionPage, setSessionPage] = useState(1);
|
||||
const [sessionModal, setSessionModal] = useState(false);
|
||||
const [editSession, setEditSession] = useState<PatientSession | null>(null);
|
||||
@@ -1340,7 +1351,8 @@ function MyPatientsPageInner() {
|
||||
onClose={() => setExpandedVisit(null)}
|
||||
settling={settleSessionMut.isPending}
|
||||
onSettle={(uuid) => settleSessionMut.mutate(uuid)}
|
||||
onViewInvoice={(uuid) => { setExpandedVisit(null); setInvoiceUuid(uuid); }}
|
||||
onViewInvoice={viewInvoice}
|
||||
issuing={issueInvoiceMut.isPending}
|
||||
onEdit={(s) => { setExpandedVisit(null); setEditSession(s); }}
|
||||
/>
|
||||
|
||||
@@ -1878,6 +1890,7 @@ function VisitDetailModal({
|
||||
session,
|
||||
onClose,
|
||||
settling,
|
||||
issuing,
|
||||
onSettle,
|
||||
onViewInvoice,
|
||||
onEdit,
|
||||
@@ -1885,8 +1898,10 @@ function VisitDetailModal({
|
||||
session: PatientSession | null;
|
||||
onClose: () => void;
|
||||
settling: boolean;
|
||||
issuing?: boolean;
|
||||
onSettle: (uuid: string) => void;
|
||||
onViewInvoice: (uuid: string) => void;
|
||||
/** کل session پاس میشود؛ نبودِ invoice_uuid یعنی caller باید فاکتور را صادر کند. */
|
||||
onViewInvoice: (session: PatientSession) => void;
|
||||
onEdit: (s: PatientSession) => void;
|
||||
}) {
|
||||
if (!session) return null;
|
||||
@@ -1916,11 +1931,9 @@ function VisitDetailModal({
|
||||
<PencilIcon style={{ width: 15 }} /> ویرایش
|
||||
</button>
|
||||
{paid ? (
|
||||
session.invoice_uuid ? (
|
||||
<button className="cp-btn-secondary" onClick={() => onViewInvoice(session.invoice_uuid!)}>مشاهده فاکتور</button>
|
||||
) : (
|
||||
<button className="cp-btn-secondary" disabled>پرداخت شده</button>
|
||||
)
|
||||
<button className="cp-btn-secondary" disabled={issuing} onClick={() => onViewInvoice(session)}>
|
||||
{issuing ? 'در حال صدور…' : 'مشاهده فاکتور'}
|
||||
</button>
|
||||
) : (
|
||||
<button className="cp-btn-primary" disabled={settling} onClick={() => onSettle(session.uuid)}>تکمیل پرداخت</button>
|
||||
)}
|
||||
|
||||
@@ -110,7 +110,7 @@ export default function NewSessionPage() {
|
||||
)}
|
||||
|
||||
{activeStep === 2 && session && (
|
||||
<DetailsStep session={session} onBack={() => setActiveStep(1)} onFinish={finish} />
|
||||
<DetailsStep session={session} recordUuid={recordUuid} onBack={() => setActiveStep(1)} onFinish={finish} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -100,6 +100,33 @@ describe('PatientDetailPage (پرونده تبدار)', () => {
|
||||
expect(await screen.findByText('اطلاعات فاکتور')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('issues the invoice first (create + finalize) when the paid session has none', async () => {
|
||||
const base = get.getMockImplementation()!;
|
||||
get.mockImplementation((url: string) => {
|
||||
if (url === '/api/v1/patient/r1/sessions') return Promise.resolve({ success: true, data: [
|
||||
{ uuid: 's3', services: [{ service_name: 'لیزر' }], doctor_name: 'دکتر فتحی', final_price_rials: 1000000, is_paid: true, patient_debt_rials: 0, created_at: 1700000000, visit_price_rials: 0 },
|
||||
], meta: { totalRecords: 1 } });
|
||||
if (url === '/api/v1/billing/invoices/iv9') return Promise.resolve({ success: true, data: { data: {
|
||||
uuid: 'iv9', status: 'finalized', issued_at: 1700000000, total_rials: 1000000,
|
||||
base_insurance_rials: 0, supplementary_rials: 0, patient_rials: 1000000, items: [],
|
||||
} } });
|
||||
return base(url);
|
||||
});
|
||||
const post = api.post as ReturnType<typeof vi.fn>;
|
||||
post.mockReset();
|
||||
post.mockImplementation((url: string) => {
|
||||
if (url === '/api/v1/billing/invoices') return Promise.resolve({ success: true, data: { data: { uuid: 'iv9', status: 'draft' } } });
|
||||
return Promise.resolve({ success: true, data: {} });
|
||||
});
|
||||
|
||||
renderDetail();
|
||||
fireEvent.click(await screen.findByText('مشاهده فاکتور'));
|
||||
|
||||
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/billing/invoices', { session_uuid: 's3' }));
|
||||
expect(post).toHaveBeenCalledWith('/api/v1/billing/invoices/iv9/finalize', {});
|
||||
expect(await screen.findByText('خلاصه فاکتور')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the editable «اطلاعات پرونده» form prefilled from the profile', async () => {
|
||||
renderDetail();
|
||||
await loaded();
|
||||
|
||||
@@ -22,6 +22,7 @@ import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
import PersianDateInput from '../components/ui/PersianDateInput';
|
||||
import PatientCaseBanner, { Breadcrumb } from '../components/PatientCaseBanner';
|
||||
import SessionServiceCard, { type SessionCardData } from '../components/SessionServiceCard';
|
||||
import { useIssueInvoice } from '../hooks/useIssueInvoice';
|
||||
import AppointmentTurnCard, { type AppointmentCardData } from '../components/AppointmentTurnCard';
|
||||
import SessionPaymentAccordion, { type SessionPaymentData } from '../components/SessionPaymentAccordion';
|
||||
import InvoiceSummaryModal from '../components/InvoiceSummaryModal';
|
||||
@@ -81,6 +82,16 @@ export default function PatientDetailPage() {
|
||||
const nav = useNavigate();
|
||||
const [invoiceUuid, setInvoiceUuid] = useState<string | null>(null);
|
||||
|
||||
// «مشاهده فاکتور»: اگر session هنوز فاکتور ندارد، اول صادر میشود (idempotent).
|
||||
const issueInvoiceMut = useIssueInvoice(uuid);
|
||||
const viewInvoice = (s: SessionCardData) => {
|
||||
if (s.invoice_uuid) { setInvoiceUuid(s.invoice_uuid); return; }
|
||||
issueInvoiceMut.mutate(s.uuid, {
|
||||
onSuccess: (iv) => setInvoiceUuid(iv),
|
||||
onError: () => toast.error('صدور فاکتور ناموفق بود'),
|
||||
});
|
||||
};
|
||||
|
||||
// ── فرم «اطلاعات پرونده» (اینلاین، معادل tauri FileInfoSection) ──────────────
|
||||
// استان/شهر (Location) و بیمهٔ پایه (insurance-pricing) برای گزینههای فرم.
|
||||
const [editProvinceId, setEditProvinceId] = useState<number | null>(null);
|
||||
@@ -217,7 +228,7 @@ export default function PatientDetailPage() {
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', columnGap: 6, rowGap: 10, alignItems: 'stretch' }}>
|
||||
{sessions.map((s) => (
|
||||
<SessionServiceCard key={s.uuid} session={s} onSettle={(u) => nav(`/admin/patients/${uuid}/session/${u}/pay`)} onViewInvoice={(iv) => setInvoiceUuid(iv)} />
|
||||
<SessionServiceCard key={s.uuid} session={s} onSettle={(u) => nav(`/admin/patients/${uuid}/session/${u}/pay`)} onViewInvoice={viewInvoice} issuing={issueInvoiceMut.isPending} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -98,7 +98,7 @@ export default function SessionPaymentPage() {
|
||||
onCancel={() => nav(-1)}
|
||||
/>
|
||||
) : (
|
||||
<DetailsStep session={session} onBack={() => setActiveStep(0)} onFinish={finish} />
|
||||
<DetailsStep session={session} recordUuid={recordUuid} onBack={() => setActiveStep(0)} onFinish={finish} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+5
-1
@@ -270,4 +270,8 @@
|
||||
|
||||
## ارسال مطالبه (ClaimSubmitter)
|
||||
|
||||
عملِ `submit` از طریق interface `App\Billing\Contract\ClaimSubmitterInterface` انجام میشود. پیادهسازی پیشفرض `ManualClaimSubmitter` است (ارسال دستی/آفلاین — همیشه موفق). برای اتصال آینده به API شرکتهای بیمهی ایران کافی است یک پیادهسازی جدید از این interface ساخته و در `config/services.yaml` bind شود؛ `ClaimService` تغییر نمیکند (Dependency Inversion). اگر `submit` ناموفق باشد، انتقال وضعیت با `422` متوقف میشود.
|
||||
عملِ `submit` از طریق interface `App\Billing\Contract\ClaimSubmitterInterface` انجام میشود. پیادهسازی پیشفرض `ManualClaimSubmitter` است (ارسال دستی/آفلاین — همیشه موفق). برای اتصال آینده به API شرکتهای بیمهی ایران کافی است یک پیادهسازی جدید از این interface ساخته و در `config/services.yaml` bind شود؛ `ClaimService` تغییر نمیکند (Dependency Inversion). اگر `submit` ناموفق باشد، انتقال وضعیت با `422` متوقف میشود.
|
||||
|
||||
## جریان صدور فاکتور در پنل ادمین
|
||||
|
||||
«صدور فاکتور» (گام «جزییات» ویزارد مراجعه) و «مشاهده فاکتور» (کارت مراجعه / مودال جزئیات) هر دو از `useIssueInvoice` استفاده میکنند: `POST /billing/invoices` (idempotent) و سپس `finalize` اگر `draft` باشد. یعنی session بدون فاکتور، در اولین مشاهده صاحب فاکتور نهاییشده میشود.
|
||||
|
||||
Reference in New Issue
Block a user