diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index fcf256bb..2fa05149 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -42,6 +42,7 @@ import MyPatientsPage from './pages/MyPatientsPage'; import MyPaymentsPage from './pages/MyPaymentsPage'; import MyPaymentDetailPage from './pages/MyPaymentDetailPage'; import NewSessionPage from './pages/NewSessionPage'; +import SessionPaymentPage from './pages/SessionPaymentPage'; import InsurancePricingPage from './pages/InsurancePricingPage'; import ClaimsPage from './pages/ClaimsPage'; import DoctorClaimsPage from './pages/DoctorClaimsPage'; @@ -211,6 +212,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/assets/admin/components/SessionPaymentAccordion.tsx b/assets/admin/components/SessionPaymentAccordion.tsx index 24c7ab07..b453b9ca 100644 --- a/assets/admin/components/SessionPaymentAccordion.tsx +++ b/assets/admin/components/SessionPaymentAccordion.tsx @@ -16,6 +16,7 @@ export interface SessionPaymentData { const PAYMENT_LABELS: Record = { cash: 'نقدی', card: 'کارت', insurance: 'بیمه', online: 'آنلاین', pending: 'در انتظار', + wallet: 'کیف پول', pos: 'کارتخوان', }; /** vertical hairline divider between meta columns (tauri MUI vertical Divider). */ diff --git a/assets/admin/components/SessionServiceCard.tsx b/assets/admin/components/SessionServiceCard.tsx index 40298a39..d5b59262 100644 --- a/assets/admin/components/SessionServiceCard.tsx +++ b/assets/admin/components/SessionServiceCard.tsx @@ -2,6 +2,14 @@ import type { CSSProperties } from 'react'; import { formatDate, formatRial } from '../lib/utils'; import { FilesServiceSuccess, FilesServiceMore } from './icons/FilesServiceIcons'; +export interface SessionPaymentEntry { + uuid: string; + method: string; + amount_rials: number; + paid_at?: number; + created_by_name?: string | null; +} + export interface SessionCardData { uuid: string; services?: Array<{ service_name?: string; name?: string }>; @@ -13,6 +21,13 @@ export interface SessionCardData { invoice_uuid?: string | null; notes?: string | null; created_at?: number; + // تسویه چندتکه + تخفیف (backend session settlement fields) + discount_type?: 'percent' | 'fixed' | null; + discount_value?: number; + discount_rials?: number; + paid_total_rials?: number; + paid_at?: number | null; + payments?: SessionPaymentEntry[]; } /** label:value row inside the service card (mirrors tauri ServiceInfoRow). */ diff --git a/assets/admin/components/SessionStepper.tsx b/assets/admin/components/SessionStepper.tsx new file mode 100644 index 00000000..d9871114 --- /dev/null +++ b/assets/admin/components/SessionStepper.tsx @@ -0,0 +1,63 @@ +import { + FilesServiceAddServiceStep, + FilesAddServicePaymentStep, + FilesServiceDetailsStep, +} from './icons/FilesServiceIcons'; + +/** + * استپر صفحه‌ی سرویس/تسویه — پورت tauri files/services/CustomizedStepper + * (کانکتور بنفش، آیکون دایره‌ای هر گام، تیک سبز برای گام‌های کامل‌شده). + */ + +// هر برچسب گام آیکون ثابت خودش را دارد تا در حالت payment-only هم درست بماند. +const ICON_BY_LABEL: Record = { + 'ایجاد سرویس': 1, + 'ویرایش': 1, + 'پرداخت': 2, + 'جزییات': 3, +}; + +function StepIcon({ index, active, completed }: { index: number; active: boolean; completed: boolean }) { + if (completed && !active) { + // دایره سفید با تیک سبز (tauri ColorlibStepIconRoot completed state) + return ( +
+ + + +
+ ); + } + const shadow = active ? { boxShadow: '0 4px 10px 0 rgba(85, 89, 206, 0.25)', borderRadius: '50%' } : undefined; + switch (index) { + case 1: return
; + case 2: return
; + default: return
; + } +} + +export default function SessionStepper({ activeStep = 0, steps = [] }: { activeStep?: number; steps?: string[] }) { + return ( +
+ {steps.map((label, i) => ( +
+ {/* connector to previous step (tauri ColorlibConnector: top 22, h 3, marginX 40) */} + {i > 0 && ( +
+ )} + + + {label} + +
+ ))} +
+ ); +} diff --git a/assets/admin/components/icons/FilesServiceIcons.tsx b/assets/admin/components/icons/FilesServiceIcons.tsx index 595b835d..02452c0a 100644 --- a/assets/admin/components/icons/FilesServiceIcons.tsx +++ b/assets/admin/components/icons/FilesServiceIcons.tsx @@ -249,3 +249,112 @@ export function StatusGlobe({ color = '#616161', size = 20, style }: IconProps & ); } + +/* ── Create-service stepper (tauri files/create-service) ─────────────────── */ + +type StepIconProps = { size?: number; active?: boolean }; + +/** Step «ایجاد سرویس/ویرایش» icon — tauri FilesServiceAddServiceStep, verbatim. */ +export function FilesServiceAddServiceStep({ size = 52, active = false }: StepIconProps) { + const circleStrokeColor = active ? '#5559CE' : '#E1E1E1'; + const bgColor = active ? '#5559CE' : '#E1E1E1'; + const iconColor = active ? '#FAFAFA' : '#9B9B9B'; + return ( + + + + + + + + + + + ); +} + +/** Step «پرداخت» icon — tauri FilesAddServicePaymentStep, verbatim. */ +export function FilesAddServicePaymentStep({ size = 52, active = false }: StepIconProps) { + const circleStrokeColor = active ? '#5559CE' : '#E1E1E1'; + const bgColor = active ? '#5559CE' : '#E1E1E1'; + const iconColor = active ? '#FAFAFA' : '#9B9B9B'; + return ( + + + + + + + + + + + ); +} + +/** Step «جزییات» icon — tauri FilesServiceDetailsStep, verbatim. */ +export function FilesServiceDetailsStep({ size = 52, active = false }: StepIconProps) { + const circleStrokeColor = active ? '#5559CE' : '#E1E1E1'; + const bgColor = active ? '#5559CE' : '#E1E1E1'; + const iconColor = active ? '#FAFAFA' : '#9B9B9B'; + return ( + + + + + + + + + + + ); +} + +/* ── Payment step widgets (tauri Step2Payment) ───────────────────────────── */ + +/** Orange «هزینه سرویس» badge — tauri Step2PaymentCard, verbatim. */ +export function Step2PaymentCard({ size = 40 }: { size?: number }) { + return ( + + + + + + + ); +} + +/** «موجودی کیف پول» icon — tauri FilesServiceBalanceWallet, verbatim. */ +export function FilesServiceBalanceWallet({ size = 20 }: { size?: number }) { + return ( + + + + + + + ); +} + +/** Red trash icon (حذف تخفیف) — tauri TrashRed, verbatim. */ +export function TrashRed({ size = 20, color = '#FF5450' }: { size?: number; color?: string }) { + return ( + + + + + + + + ); +} + +/** Modal close (X) — tauri CloseModalD, verbatim. */ +export function CloseModalD() { + return ( + + + + ); +} diff --git a/assets/admin/pages/PatientDetailPage.test.tsx b/assets/admin/pages/PatientDetailPage.test.tsx index 66a3b94e..4a591eb6 100644 --- a/assets/admin/pages/PatientDetailPage.test.tsx +++ b/assets/admin/pages/PatientDetailPage.test.tsx @@ -199,16 +199,18 @@ describe('PatientDetailPage (پرونده تب‌دار)', () => { await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/patient/r1/wallet/charge', { amount_rials: 500000, payment_method: 'cash' })); }); - it('settles a session from the wallet via the payment-method chooser', async () => { - const patch = api.patch as ReturnType; - patch.mockResolvedValue({ success: true, data: {} }); - renderDetail(); + it('navigates to the session payment page when «تکمیل پرداخت» is clicked', async () => { + renderWithProviders( + + } /> + صفحه تکمیل پرداخت
} /> + , + { route: '/admin/patients/r1' }, + ); await loaded(); - // تب سرویس‌ها پیش‌فرض است؛ کارت پرداخت‌نشده → «تکمیل پرداخت» + // تب سرویس‌ها پیش‌فرض است؛ کارت پرداخت‌نشده → «تکمیل پرداخت» → صفحه‌ی استپر پرداخت fireEvent.click(await screen.findByText('تکمیل پرداخت')); - expect(await screen.findByText('روش پرداخت مراجعه')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'کیف پول بیمار' })); - await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/session/s1', { payment_method: 'wallet' })); + expect(await screen.findByText('صفحه تکمیل پرداخت')).toBeInTheDocument(); }); it('renders the notes tab with a compose box and empty state', async () => { diff --git a/assets/admin/pages/PatientDetailPage.tsx b/assets/admin/pages/PatientDetailPage.tsx index 3088a1e2..90f0195b 100644 --- a/assets/admin/pages/PatientDetailPage.tsx +++ b/assets/admin/pages/PatientDetailPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useParams, useSearchParams, Link } from 'react-router-dom'; +import { useParams, useSearchParams, Link, useNavigate } from 'react-router-dom'; import { ChevronRightIcon, PencilIcon, ClipboardDocumentCheckIcon, DocumentTextIcon, CalendarDaysIcon, CreditCardIcon, BanknotesIcon, ChatBubbleLeftRightIcon, @@ -13,7 +13,7 @@ import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; import type { PatientRecord } from '../types'; import { useAuthStore } from '../stores/authStore'; -import { formatDate, formatRial, formatTime, formatNumber } from '../lib/utils'; +import { formatDate, formatDateTime, formatRial, formatTime, formatNumber } from '../lib/utils'; import DataTable, { type Column } from '../components/ui/DataTable'; import type { WalletTxn } from '../hooks/usePatientWallet'; import type { WalletModalSubmit } from '../components/WalletTransactionModal'; @@ -78,8 +78,8 @@ export default function PatientDetailPage() { const record = data?.data; const qc = useQueryClient(); + const nav = useNavigate(); const [invoiceUuid, setInvoiceUuid] = useState(null); - const [settleTarget, setSettleTarget] = useState(null); // ── فرم «اطلاعات پرونده» (اینلاین، معادل tauri FileInfoSection) ────────────── // استان/شهر (Location) و بیمهٔ پایه (insurance-pricing) برای گزینه‌های فرم. @@ -143,19 +143,6 @@ export default function PatientDetailPage() { .filter((a: any) => (a.starts_at ?? 0) >= nowSec && !String(a.status).startsWith('cancelled')) .sort((a: any, b: any) => a.starts_at - b.starts_at)[0]?.starts_at ?? null; - const settle = useMutation({ - mutationFn: ({ sessionUuid, method }: { sessionUuid: string; method: string }) => - api.patch(`/api/v1/session/${sessionUuid}`, { payment_method: method }), - onSuccess: () => { - qc.invalidateQueries({ queryKey: ['patient-sessions', uuid] }); - // پرداخت از کیف پول موجودی را کم می‌کند → دفتر کیف پول را هم تازه کن. - qc.invalidateQueries({ queryKey: ['patient-wallet', uuid] }); - toast.success('پرداخت ثبت شد'); - setSettleTarget(null); - }, - onError: (e: any) => toast.error(e?.message || 'خطا در ثبت پرداخت'), - }); - return (
{/* breadcrumb + patient banner (tauri BreadcrumbHeader + FileServicesHeader) */} @@ -230,7 +217,7 @@ export default function PatientDetailPage() { ) : (
{sessions.map((s) => ( - setSettleTarget(u)} onViewInvoice={(iv) => setInvoiceUuid(iv)} /> + nav(`/admin/patients/${uuid}/session/${u}/pay`)} onViewInvoice={(iv) => setInvoiceUuid(iv)} /> ))}
)} @@ -254,28 +241,6 @@ export default function PatientDetailPage() { )} setInvoiceUuid(null)} /> - - {/* انتخاب روش پرداختِ مراجعه (نقدی / کارت / کیف پول) */} - setSettleTarget(null)}> -
-

روش تسویهٔ این مراجعه را انتخاب کنید:

- {[ - { method: 'cash', label: 'نقدی' }, - { method: 'card', label: 'کارت به کارت' }, - { method: 'wallet', label: 'کیف پول بیمار' }, - ].map((m) => ( - - ))} -
-
); } @@ -663,6 +628,20 @@ function CallCenterTab({ uuid }: { uuid: string }) { const [summary, setSummary] = useState(''); const [outcome, setOutcome] = useState<'success' | 'missed'>('success'); + // Keep تاریخ/ساعت تماس live while the user hasn't manually edited them, so a + // long-open tab doesn't submit a stale time without a page refresh. + const dateTouched = useRef(false); + const timeTouched = useRef(false); + useEffect(() => { + const sync = () => { + if (!dateTouched.current) setDate(nowDate()); + if (!timeTouched.current) setTime(nowTime()); + }; + const id = window.setInterval(sync, 30_000); + window.addEventListener('focus', sync); + return () => { window.clearInterval(id); window.removeEventListener('focus', sync); }; + }, []); + const { data, isLoading } = useQuery>({ queryKey: ['patient-calls', uuid], queryFn: () => api.get(`/api/v1/patient/${uuid}/calls`), @@ -679,7 +658,7 @@ function CallCenterTab({ uuid }: { uuid: string }) { const calledAt = iso ? Math.floor(new Date(iso).getTime() / 1000) : Math.floor(Date.now() / 1000); return api.post(`/api/v1/patient/${uuid}/call`, { subject, summary, outcome, called_at: calledAt, personnel: userName }); }, - onSuccess: () => { invalidate(); setDate(nowDate()); setTime(nowTime()); setSubject(''); setSummary(''); setOutcome('success'); toast.success('تماس ثبت شد'); }, + onSuccess: () => { invalidate(); dateTouched.current = false; timeTouched.current = false; setDate(nowDate()); setTime(nowTime()); setSubject(''); setSummary(''); setOutcome('success'); toast.success('تماس ثبت شد'); }, onError: (e: any) => toast.error(e.message), }); const del = useMutation({ @@ -703,9 +682,9 @@ function CallCenterTab({ uuid }: { uuid: string }) {
ثبت تماس جدید
-
+
{ dateTouched.current = true; setDate(v); }} />
-
setTime(e.target.value)} dir="ltr" />
+
{ timeTouched.current = true; setTime(e.target.value); }} dir="ltr" />
setSubject(e.target.value)} placeholder="موضوع تماس" />
@@ -746,7 +725,7 @@ function CallCenterTab({ uuid }: { uuid: string }) { {c.summary &&
{c.summary}
}
-
{formatDate(c.called_at)}
+
{formatDateTime(c.called_at)}
{c.personnel &&
{c.personnel}
}
diff --git a/assets/admin/pages/SessionPaymentPage.test.tsx b/assets/admin/pages/SessionPaymentPage.test.tsx new file mode 100644 index 00000000..00289aac --- /dev/null +++ b/assets/admin/pages/SessionPaymentPage.test.tsx @@ -0,0 +1,147 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { Routes, Route } from 'react-router-dom'; +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 { formatRial } from '../lib/utils'; +import SessionPaymentPage from './SessionPaymentPage'; + +const get = api.get as ReturnType; +const post = api.post as ReturnType; +const patch = api.patch as ReturnType; + +const session = (over: object = {}) => ({ + uuid: 's1', + services: [{ service_name: 'کاشت مو' }], + doctor_name: 'دکتر فتحی', + visit_price_rials: 0, + final_price_rials: 2_500_000, + is_paid: false, + patient_debt_rials: 2_300_000, + discount_rials: 200_000, + paid_total_rials: 0, + payments: [] as object[], + created_at: 1_700_000_000, + ...over, +}); + +function mockGets(sessions: object[]) { + get.mockImplementation((url: string) => { + if (url === '/api/v1/patient/r1') { + return Promise.resolve({ success: true, data: { uuid: 'r1', user_name: 'ساغر صابری', profile: {} } }); + } + if (url === '/api/v1/patient/r1/sessions') return Promise.resolve({ success: true, data: sessions }); + if (url === '/api/v1/patient/r1/wallet') { + return Promise.resolve({ success: true, data: { balance_rials: 300_000, recent_transactions: [] } }); + } + return Promise.resolve({ success: true, data: [] }); + }); +} + +function renderPage() { + return renderWithProviders( + + } /> + , + { route: '/admin/patients/r1/session/s1/pay' }, + ); +} + +beforeEach(() => { + get.mockReset(); + post.mockReset(); + patch.mockReset(); +}); + +describe('SessionPaymentPage (تکمیل پرداخت مراجعه)', () => { + it('renders payment step with real cost, wallet balance and remaining debt', async () => { + mockGets([session()]); + renderPage(); + + expect(await screen.findByText('هزینه سرویس:')).toBeInTheDocument(); + expect(screen.getAllByText(formatRial(2_500_000)).length).toBeGreaterThan(0); + // استپر دو گام حالت payment + expect(screen.getByText('پرداخت')).toBeInTheDocument(); + expect(screen.getByText('جزییات')).toBeInTheDocument(); + // موجودی کیف پول + expect(screen.getByText('موجودی کیف پول:')).toBeInTheDocument(); + // چهار روش پرداخت tauri + expect(screen.getByText('پرداخت از طریق کیف پول')).toBeInTheDocument(); + expect(screen.getByText('پرداخت از طریق کارت خوان')).toBeInTheDocument(); + expect(screen.getByText('پرداخت نقدی')).toBeInTheDocument(); + expect(screen.getByText('کارت به کارت')).toBeInTheDocument(); + // breadcrumb با نام بیمار + expect(screen.getByText('ساغر صابری')).toBeInTheDocument(); + }); + + it('submits a partial payment via the accordion (POST /session/{uuid}/payments)', async () => { + mockGets([session()]); + post.mockResolvedValue({ success: true, data: session({ paid_total_rials: 500_000 }) }); + renderPage(); + + fireEvent.click(await screen.findByText('پرداخت نقدی')); + fireEvent.change(screen.getByPlaceholderText('مبلغ (تومان)'), { target: { value: '500000' } }); + fireEvent.click(screen.getByText('ثبت پرداخت')); + + await waitFor(() => expect(post).toHaveBeenCalledTimes(1)); + const [url, body] = post.mock.calls[0]; + expect(url).toBe('/api/v1/session/s1/payments'); + expect(body).toMatchObject({ method: 'cash', amount_rials: 500000 }); + expect(typeof (body as any).paid_at).toBe('number'); + }); + + it('applies and removes settlement discount (PATCH /session/{uuid})', async () => { + mockGets([session()]); + patch.mockResolvedValue({ success: true, data: session() }); + renderPage(); + + await screen.findByText('هزینه سرویس:'); + // حذف تخفیف → discount_type: null + fireEvent.click(screen.getByText('حذف تخفیف')); + await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/session/s1', { discount_type: null })); + }); + + it('shows registered payments and moves to details step', async () => { + mockGets([session({ + payments: [ + { uuid: 'p1', method: 'cash', amount_rials: 1_000_000, paid_at: 1_700_000_000 }, + { uuid: 'p2', method: 'wallet', amount_rials: 300_000, paid_at: 1_700_000_000 }, + ], + paid_total_rials: 1_300_000, + patient_debt_rials: 1_000_000, + })]); + renderPage(); + + // «پرداخت نقدی» هم عنوان آکاردئون است هم برچسب پرداخت ثبت‌شده + expect((await screen.findAllByText('پرداخت نقدی')).length).toBeGreaterThanOrEqual(2); + expect(screen.getByText('پرداخت از کیف پول')).toBeInTheDocument(); + + // گام جزییات + fireEvent.click(screen.getByText('ثبت و ادامه')); + expect(screen.getByText('صدور فاکتور')).toBeInTheDocument(); + expect(screen.getByText('کاشت مو')).toBeInTheDocument(); + expect(screen.getByText('دکتر فتحی')).toBeInTheDocument(); + expect(screen.getByText('مبلغ باقی مانده:')).toBeInTheDocument(); + }); + + it('renders empty payments state (پرداختی ثبت نشده است)', async () => { + mockGets([session({ payments: [], discount_rials: 0 })]); + renderPage(); + + expect(await screen.findByText('پرداختی ثبت نشده است')).toBeInTheDocument(); + }); + + it('shows not-found message when session uuid does not exist', async () => { + mockGets([session({ uuid: 'other' })]); + renderPage(); + + expect(await screen.findByText('مراجعه یافت نشد')).toBeInTheDocument(); + }); +}); diff --git a/assets/admin/pages/SessionPaymentPage.tsx b/assets/admin/pages/SessionPaymentPage.tsx new file mode 100644 index 00000000..fa22e6c2 --- /dev/null +++ b/assets/admin/pages/SessionPaymentPage.tsx @@ -0,0 +1,355 @@ +import { useState } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { ChevronDownIcon } from '@heroicons/react/24/outline'; +import { toast } from 'sonner'; +import { api } from '../lib/api'; +import type { ApiResponse } from '../lib/api'; +import type { PatientRecord } from '../types'; +import { formatRial } from '../lib/utils'; +import type { SessionCardData } from '../components/SessionServiceCard'; +import SessionStepper from '../components/SessionStepper'; +import SearchableSelect from '../components/ui/SearchableSelect'; +import PersianDateInput from '../components/ui/PersianDateInput'; +import { + ArrowLeftPH, ArrowLeftD, CloseModalD, Step2PaymentCard, + FilesServiceBalanceWallet, TrashRed, +} from '../components/icons/FilesServiceIcons'; + +/** روش‌های پرداخت — همان چهار گزینه‌ی آکاردئون tauri Step2Payment. */ +const METHODS: { key: string; label: string }[] = [ + { key: 'wallet', label: 'پرداخت از طریق کیف پول' }, + { key: 'pos', label: 'پرداخت از طریق کارت خوان' }, + { key: 'cash', label: 'پرداخت نقدی' }, + { key: 'card', label: 'کارت به کارت' }, +]; +const METHOD_LABELS: Record = { + wallet: 'پرداخت از کیف پول', pos: 'پرداخت کارتخوان', cash: 'پرداخت نقدی', card: 'کارت به کارت', +}; + +const todayISO = () => new Date().toISOString().slice(0, 10); +/** YYYY-MM-DD → unix (ظهر همان روز تا با هر timezone یک روز بماند) */ +const isoToUnix = (iso: string) => Math.floor(new Date(`${iso}T12:00:00`).getTime() / 1000); + +/** + * تکمیل پرداخت مراجعه — پورت صفحه‌ی tauri /files/create-service در حالت payment: + * استپر دوگامی «پرداخت ← جزییات» با تخفیف تسویه، پرداخت چندتکه و تاریخ پرداخت. + */ +export default function SessionPaymentPage() { + const { recordUuid = '', sessionUuid = '' } = useParams(); + const nav = useNavigate(); + const qc = useQueryClient(); + + const steps = ['پرداخت', 'جزییات']; + const [activeStep, setActiveStep] = useState(0); + const [discountType, setDiscountType] = useState(''); + const [discountValue, setDiscountValue] = useState(''); + const [paymentDate, setPaymentDate] = useState(todayISO()); + const [expanded, setExpanded] = useState(null); + const [amount, setAmount] = useState(''); + + const recordQ = useQuery>({ + queryKey: ['patient-detail', recordUuid], + queryFn: () => api.get(`/api/v1/patient/${recordUuid}`), + enabled: !!recordUuid, + }); + const record = recordQ.data?.data as PatientRecord | undefined; + const patientName = record?.user_name || record?.profile?.full_name || '—'; + + const sessionsQ = useQuery>({ + queryKey: ['patient-sessions', recordUuid], + queryFn: () => api.get(`/api/v1/patient/${recordUuid}/sessions`), + enabled: !!recordUuid, + }); + const session = (sessionsQ.data?.data ?? []).find((s) => s.uuid === sessionUuid); + + const walletQ = useQuery>({ + queryKey: ['patient-wallet', recordUuid], + queryFn: () => api.get(`/api/v1/patient/${recordUuid}/wallet`), + enabled: !!recordUuid, + }); + const walletBalance = (walletQ.data?.data as any)?.balance_rials ?? 0; + + const invalidate = () => { + qc.invalidateQueries({ queryKey: ['patient-sessions', recordUuid] }); + qc.invalidateQueries({ queryKey: ['patient-wallet', recordUuid] }); + }; + + const discountMut = useMutation({ + mutationFn: (body: object) => api.patch(`/api/v1/session/${sessionUuid}`, body), + onSuccess: () => { invalidate(); toast.success('تخفیف به‌روزرسانی شد'); }, + onError: (e: Error) => toast.error(e.message), + }); + + const payMut = useMutation({ + mutationFn: (body: object) => api.post(`/api/v1/session/${sessionUuid}/payments`, body), + onSuccess: () => { invalidate(); setAmount(''); toast.success('پرداخت ثبت شد'); }, + onError: (e: Error) => toast.error(e.message), + }); + + const applyDiscount = () => { + if (!discountType || !discountValue) return; + discountMut.mutate({ discount_type: discountType, discount_value: Number(discountValue) }); + }; + const removeDiscount = () => { + setDiscountType(''); setDiscountValue(''); + discountMut.mutate({ discount_type: null }); + }; + const submitPayment = (method: string) => { + if (!amount) return; + payMut.mutate({ method, amount_rials: Number(amount), paid_at: isoToUnix(paymentDate) }); + }; + + const finalPrice = session?.final_price_rials ?? 0; + const discountRials = session?.discount_rials ?? 0; + const debt = session?.patient_debt_rials ?? 0; + const payments = session?.payments ?? []; + const serviceNames = (session?.services ?? []).map((s) => s.service_name || s.name).filter(Boolean) as string[]; + if ((session?.visit_price_rials ?? 0) > 0) serviceNames.unshift('ویزیت'); + + const finish = () => nav(`/admin/patients/${recordUuid}?tab=services`); + + if (sessionsQ.isLoading) { + return
در حال بارگذاری...
; + } + if (!session) { + return
مراجعه یافت نشد
; + } + + const fieldLabel: React.CSSProperties = { fontSize: 14, color: '#6B7280', marginBottom: 8, display: 'block' }; + 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' }; + + return ( +
+ {/* breadcrumb — tauri AddService header */} +
+
+
nav(-1)} + className="bg-white dark:bg-[#222433]" + style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '6px 8px', borderRadius: 12, cursor: 'pointer' }} + > + + بازگشت +
+ + پرونده + + {patientName} +
+
+ + {/* card — tauri width 748 centered */} +
+
+
+ +
+ + + + {activeStep === 0 ? ( + <> + {/* هزینه سرویس */} +
+ + هزینه سرویس: + {formatRial(finalPrice)} +
+ + {/* تخفیف — port of tauri DiscountInput (نوع + مقدار + ثبت) */} + تخفیف: +
+
+
+ setDiscountType(v ? String(v) : '')} + placeholder="مبلغ تخفیف" + /> +
+ setDiscountValue(e.target.value)} + style={{ flex: 1, minWidth: 0, height: 40, padding: '0 12px', fontSize: 13, border: 'none', outline: 'none', background: 'transparent', color: 'inherit' }} + /> + +
+
+ + حذف تخفیف +
+
+ +

+ مبلغ تخفیف:{' '} + {formatRial(discountRials)} +

+ + {/* تاریخ پرداخت */} + تاریخ پرداخت + + + {/* روش‌های پرداخت */} + انتخاب روش های پرداخت: +
+
+ + موجودی کیف پول: +
+ {formatRial(walletBalance)} +
+ + {/* آکاردئون چهار روش — باز شدن هر روش: مبلغ + ثبت پرداخت */} +
+ {METHODS.map((m) => { + const open = expanded === m.key; + return ( +
+ + {open && ( +
+ setAmount(e.target.value)} + style={{ flex: 1, height: 40 }} + /> + +
+ )} +
+ ); + })} +
+ + {/* پرداخت شده‌ها — باکس خط‌چین tauri */} +
+ پرداخت شده ها: + {payments.length === 0 ? ( + پرداختی ثبت نشده است + ) : ( +
+ {payments.map((p) => ( +
+ + + {METHOD_LABELS[p.method] ?? p.method} + + مبلغ : {formatRial(p.amount_rials)} +
+ ))} +
+ )} +
+ مبلغ باقی‌مانده : + {formatRial(debt)} +
+
+ + {/* ACTIONS */} +
+
+ + +
+
+ + ) : ( + <> + {/* گام جزییات — پورت tauri Step3Final با دیتای واقعی */} +
+
+ + {serviceNames.length ? serviceNames.join(' - ') : 'ویزیت'} + + + {session.doctor_name || '—'} +
+ +
+ + هزینه سرویس: + {formatRial(finalPrice)} + + + + تخفیف: + {formatRial(discountRials)} + +
+ + پرداخت شده ها: + {payments.length === 0 ? ( + پرداختی ثبت نشده است + ) : payments.map((p) => ( +
+ + + {METHOD_LABELS[p.method] ?? p.method} + + مبلغ: {formatRial(p.amount_rials)} +
+ ))} + +
+
+ مبلغ باقی مانده: + {formatRial(debt)} +
+
+
+ +
+ + +
+ + )} +
+
+
+ ); +} diff --git a/docs/api/patient.md b/docs/api/patient.md index eee7cccd..cab3d5ae 100644 --- a/docs/api/patient.md +++ b/docs/api/patient.md @@ -464,10 +464,17 @@ Updates mutable fields on a session. ```json { "notes": "...", - "payment_method": "card" + "payment_method": "card", + "paid_at": 1770000000, + "discount_type": "percent", + "discount_value": 25 } ``` +- `payment_method: "wallet"` روی مراجعه‌ی تسویه‌نشده، کل مبلغ نهایی را از کیف پول بیمار کسر می‌کند (موجودی ناکافی → `422 ERR_WALLET_INSUFFICIENT`). +- **تخفیف تسویه:** `discount_type` = `percent` (۰..۱۰۰) یا `fixed` (ریال، حداکثر برابر مبلغ نهایی) یا `null` (حذف تخفیف). مبلغ محاسبه‌شده در `discount_rials` برمی‌گردد. تخفیف نمی‌تواند از «مبلغ نهایی منهای پرداخت‌های ثبت‌شده» بیشتر شود. تخفیفی که مانده را صفر کند مراجعه را تسویه‌شده می‌کند (`is_paid`, `paid_at`). +- `paid_at`: unix timestamp زمان تسویه. + **Response 200:** ```json @@ -482,6 +489,62 @@ Updates mutable fields on a session. | Code | HTTP | Description | |------|------|-------------| | `ERR_SESSION_NOT_FOUND` | 404 | Session not found or not owned | +| `ERR_SESSION_DISCOUNT_INVALID` | 422 | نوع/مقدار تخفیف نامعتبر یا بیش از سقف | +| `ERR_WALLET_INSUFFICIENT` | 422 | موجودی کیف پول کافی نیست (روش wallet) | + +--- + +### Add Session Payment (تسویه چندتکه) + +``` +POST /api/v1/session/{uuid}/payments +``` + +ثبت یک پرداخت جزئی روی مراجعه. مجموع پرداخت‌ها + تخفیف که به مبلغ نهایی برسد، مراجعه تسویه‌شده می‌شود (`is_paid=true`، `payment_method` = روش آخرین پرداخت، `paid_at` ست می‌شود). + +**Request body:** + +```json +{ + "method": "wallet | pos | cash | card", + "amount_rials": 200000, + "paid_at": 1770000000 +} +``` + +- `method: "wallet"` همان مبلغ را از کیف پول بیمار کسر می‌کند (تراکنش debit با `reference: "session:{uuid}"`). +- `paid_at` اختیاری است (پیش‌فرض: اکنون). + +**Response 201:** session object با فیلدهای صورتحساب: + +```json +{ + "success": true, + "data": { + "...": "...session fields...", + "discount_type": "fixed", + "discount_value": 100000, + "discount_rials": 100000, + "paid_total_rials": 300000, + "patient_debt_rials": 0, + "paid_at": 1770000000, + "payments": [ + { "uuid": "...", "method": "cash", "amount_rials": 200000, "paid_at": 1770000000, "created_by_name": "...", "created_at": 1770000000 } + ] + } +} +``` + +**Errors:** + +| Code | HTTP | Description | +|------|------|-------------| +| `ERR_SESSION_NOT_FOUND` | 404 | Session not found or not owned | +| `ERR_SESSION_PAYMENT_INVALID` | 422 | روش نامعتبر یا مبلغ ≤ ۰ | +| `ERR_SESSION_PAYMENT_EXCEEDS` | 422 | مبلغ از مانده بدهی بیشتر است | +| `ERR_WALLET_INSUFFICIENT` | 422 | موجودی کیف پول کافی نیست (روش wallet) | + +**Session list debt:** در `GET /api/v1/patient/{uuid}/sessions`، فیلد `patient_debt_rials` = سهم بیمار (از فاکتور در صورت وجود) منهای `discount_rials` و `paid_total_rials`. --- diff --git a/migrations/Version20260716094319.php b/migrations/Version20260716094319.php new file mode 100644 index 00000000..d2105dd4 --- /dev/null +++ b/migrations/Version20260716094319.php @@ -0,0 +1,37 @@ +addSql('CREATE TABLE session_payments (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, method VARCHAR(15) NOT NULL, amount_rials INT NOT NULL, paid_at INT NOT NULL, created_by_name VARCHAR(255) DEFAULT NULL, created_at INT NOT NULL, session_id INT NOT NULL, created_by_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_EE7EFB5AD17F50A6 (uuid), INDEX IDX_EE7EFB5A613FECDF (session_id), INDEX IDX_EE7EFB5AB03A8386 (created_by_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE session_payments ADD CONSTRAINT FK_EE7EFB5A613FECDF FOREIGN KEY (session_id) REFERENCES patient_sessions (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE session_payments ADD CONSTRAINT FK_EE7EFB5AB03A8386 FOREIGN KEY (created_by_id) REFERENCES users (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE patient_sessions ADD discount_type VARCHAR(10) DEFAULT NULL, ADD discount_value INT DEFAULT 0 NOT NULL, ADD discount_rials INT DEFAULT 0 NOT NULL, ADD paid_at INT DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE session_payments DROP FOREIGN KEY FK_EE7EFB5A613FECDF'); + $this->addSql('ALTER TABLE session_payments DROP FOREIGN KEY FK_EE7EFB5AB03A8386'); + $this->addSql('DROP TABLE session_payments'); + $this->addSql('ALTER TABLE patient_sessions DROP discount_type, DROP discount_value, DROP discount_rials, DROP paid_at'); + } +} diff --git a/src/Patient/Controller/PatientController.php b/src/Patient/Controller/PatientController.php index 047e1ad5..0a5d2f5c 100644 --- a/src/Patient/Controller/PatientController.php +++ b/src/Patient/Controller/PatientController.php @@ -979,10 +979,10 @@ class PatientController extends BaseController if ($data['is_paid']) { $data['patient_debt_rials'] = 0; - } elseif ($invoice !== null) { - $data['patient_debt_rials'] = $invoice->getPatientRials(); } else { - $data['patient_debt_rials'] = $session->getFinalPriceRials(); + // سهم بیمار (از فاکتور در صورت وجود) منهای تخفیف تسویه و پرداخت‌های جزئی + $share = $invoice !== null ? $invoice->getPatientRials() : $session->getFinalPriceRials(); + $data['patient_debt_rials'] = max(0, $share - $session->getDiscountRials() - $session->getPaidTotalRials()); } return $data; @@ -1046,6 +1046,14 @@ class PatientController extends BaseController if (isset($data['notes'])) { $session->setNotes($data['notes']); } + // تخفیف تسویه: discount_type = percent|fixed|null (null = حذف تخفیف) + if (array_key_exists('discount_type', $data)) { + $type = $data['discount_type'] !== null ? (string) $data['discount_type'] : null; + $this->patientService->applyDiscount($session, $type, (int) ($data['discount_value'] ?? 0)); + } + + if (isset($data['paid_at'])) { $session->setPaidAt((int) $data['paid_at']); } + if (isset($data['payment_method'])) { $method = (string) $data['payment_method']; // پرداخت از کیف پول: سهمِ بیمار را از موجودی کسر کن (فقط یک‌بار، اگر @@ -1064,6 +1072,36 @@ class PatientController extends BaseController return $this->success($session->toArray()); } + /** + * ثبت پرداخت جزئی روی مراجعه (تسویه چندتکه). + * body: { method: wallet|pos|cash|card, amount_rials: int, paid_at?: int } + * روش wallet همان مبلغ را از کیف پول بیمار کسر می‌کند. وقتی مانده صفر شود + * مراجعه تسویه‌شده (is_paid) می‌شود. + */ + #[Route('/api/v1/session/{uuid}/payments', methods: ['POST'])] + public function addSessionPayment(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse + { + [$entityType, $entityId] = $this->resolveEntity($user); + $this->assertPatientGate($entityType, $entityId); + + $session = $this->sessionRepo->findByUuid($uuid); + if ($session === null || !$this->ownsRecord($session->getRecord(), $entityType, $entityId)) { + return $this->error(ErrorCodes::ERR_SESSION_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SESSION_NOT_FOUND), 404); + } + + $data = json_decode($request->getContent(), true) ?? []; + + $this->patientService->addSessionPayment( + $session, + (string) ($data['method'] ?? ''), + (int) ($data['amount_rials'] ?? 0), + isset($data['paid_at']) ? (int) $data['paid_at'] : null, + $user, + ); + + return $this->success($this->sessionWithBilling($session), 201); + } + private function resolveEntity(User $user): array { if ($user->hasRole('ROLE_DOCTOR')) { diff --git a/src/Patient/Entity/PatientSession.php b/src/Patient/Entity/PatientSession.php index 477a5f5b..7f9b295c 100644 --- a/src/Patient/Entity/PatientSession.php +++ b/src/Patient/Entity/PatientSession.php @@ -54,6 +54,22 @@ class PatientSession #[ORM\Column(name: 'payment_method', type: 'string', length: 15)] private string $paymentMethod = 'pending'; + /** نوع تخفیف تسویه: percent | fixed | null (بدون تخفیف) */ + #[ORM\Column(name: 'discount_type', type: 'string', length: 10, nullable: true)] + private ?string $discountType = null; + + /** مقدار خام تخفیف (درصد یا ریال، بسته به نوع) */ + #[ORM\Column(name: 'discount_value', type: 'integer')] + private int $discountValue = 0; + + /** مبلغ محاسبه‌شده‌ی تخفیف به ریال (سقف: مبلغ نهایی) */ + #[ORM\Column(name: 'discount_rials', type: 'integer')] + private int $discountRials = 0; + + /** زمان تسویه‌ی کامل (unix)؛ تا قبل از صفر شدن بدهی null است */ + #[ORM\Column(name: 'paid_at', type: 'integer', nullable: true)] + private ?int $paidAt = null; + #[ORM\Column(type: 'text', nullable: true)] private ?string $notes = null; @@ -66,6 +82,9 @@ class PatientSession #[ORM\OneToMany(targetEntity: SessionService::class, mappedBy: 'session', cascade: ['remove'])] private Collection $services; + #[ORM\OneToMany(targetEntity: SessionPayment::class, mappedBy: 'session', cascade: ['remove'])] + private Collection $payments; + public function __construct(PatientRecord $record, ?Appointment $appointment = null) { $this->uuid = Uuid::v4()->toRfc4122(); @@ -74,6 +93,7 @@ class PatientSession $this->createdAt = time(); $this->updatedAt = time(); $this->services = new ArrayCollection(); + $this->payments = new ArrayCollection(); } public function getId(): ?int { return $this->id; } @@ -97,6 +117,35 @@ class PatientSession public function getServicesTotalRials(): int { return $this->servicesTotalRials; } public function getFinalPriceRials(): int { return $this->finalPriceRials; } public function getPaymentMethod(): string { return $this->paymentMethod; } + public function getDiscountType(): ?string { return $this->discountType; } + public function getDiscountValue(): int { return $this->discountValue; } + public function getDiscountRials(): int { return $this->discountRials; } + public function getPaidAt(): ?int { return $this->paidAt; } + public function getPayments(): Collection { return $this->payments; } + + public function addPayment(SessionPayment $payment): self + { + if (!$this->payments->contains($payment)) { + $this->payments->add($payment); + } + return $this; + } + + /** مجموع پرداخت‌های ثبت‌شده روی این مراجعه (ریال) */ + public function getPaidTotalRials(): int + { + return array_sum(array_map( + fn(SessionPayment $p) => $p->getAmountRials(), + $this->payments->toArray(), + )); + } + + /** مانده‌ی بدهی پس از کسر تخفیف و پرداخت‌ها؛ هرگز منفی نمی‌شود */ + public function getRemainingRials(): int + { + return max(0, $this->finalPriceRials - $this->discountRials - $this->getPaidTotalRials()); + } + public function getNotes(): ?string { return $this->notes; } public function getCreatedAt(): int { return $this->createdAt; } public function getUpdatedAt(): int { return $this->updatedAt; } @@ -109,6 +158,15 @@ class PatientSession public function setServicesTotalRials(int $v): self { $this->servicesTotalRials = $v; $this->updatedAt = time(); return $this; } public function setFinalPriceRials(int $v): self { $this->finalPriceRials = $v; $this->updatedAt = time(); return $this; } public function setPaymentMethod(string $v): self { $this->paymentMethod = $v; $this->updatedAt = time(); return $this; } + public function setDiscount(?string $type, int $value, int $rials): self + { + $this->discountType = $type; + $this->discountValue = $type === null ? 0 : $value; + $this->discountRials = $type === null ? 0 : $rials; + $this->updatedAt = time(); + return $this; + } + public function setPaidAt(?int $v): self { $this->paidAt = $v; $this->updatedAt = time(); return $this; } public function setNotes(?string $v): self { $this->notes = $v; $this->updatedAt = time(); return $this; } public function toArray(): array @@ -128,6 +186,15 @@ class PatientSession 'final_price_rials' => $this->finalPriceRials, 'payment_method' => $this->paymentMethod, 'is_paid' => $this->paymentMethod !== 'pending', + 'discount_type' => $this->discountType, + 'discount_value' => $this->discountValue, + 'discount_rials' => $this->discountRials, + 'paid_at' => $this->paidAt, + 'paid_total_rials' => $this->getPaidTotalRials(), + 'payments' => array_map( + fn(SessionPayment $p) => $p->toArray(), + $this->payments->toArray() + ), 'services' => array_map( fn(SessionService $s) => $s->toArray(), $this->services->toArray() diff --git a/src/Patient/Entity/SessionPayment.php b/src/Patient/Entity/SessionPayment.php new file mode 100644 index 00000000..8e10f3bf --- /dev/null +++ b/src/Patient/Entity/SessionPayment.php @@ -0,0 +1,85 @@ +uuid = Uuid::v4()->toRfc4122(); + $this->session = $session; + $this->method = $method; + $this->amountRials = $amountRials; + $this->paidAt = $paidAt ?? time(); + $this->createdAt = time(); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getSession(): PatientSession { return $this->session; } + public function getMethod(): string { return $this->method; } + public function getAmountRials(): int { return $this->amountRials; } + public function getPaidAt(): int { return $this->paidAt; } + public function getCreatedBy(): ?User { return $this->createdBy; } + public function getCreatedByName(): ?string { return $this->createdByName; } + public function getCreatedAt(): int { return $this->createdAt; } + + public function setCreatedBy(?User $u): self { $this->createdBy = $u; return $this; } + public function setCreatedByName(?string $n): self { $this->createdByName = $n; return $this; } + + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'method' => $this->method, + 'amount_rials' => $this->amountRials, + 'paid_at' => $this->paidAt, + 'created_by_name' => $this->createdByName, + 'created_at' => $this->createdAt, + ]; + } +} diff --git a/src/Patient/Repository/SessionPaymentRepository.php b/src/Patient/Repository/SessionPaymentRepository.php new file mode 100644 index 00000000..2ad3b32a --- /dev/null +++ b/src/Patient/Repository/SessionPaymentRepository.php @@ -0,0 +1,21 @@ +getEntityManager()->persist($payment); + $this->getEntityManager()->flush(); + } +} diff --git a/src/Patient/Service/PatientService.php b/src/Patient/Service/PatientService.php index e6680032..ed3331d5 100644 --- a/src/Patient/Service/PatientService.php +++ b/src/Patient/Service/PatientService.php @@ -10,12 +10,18 @@ use App\ClinicService\Repository\ServiceItemRepository; use App\Clinic\Repository\ClinicRepository; use App\Insurance\Service\TenantInsuranceService; use App\Doctor\Repository\DoctorAddressRepository; +use App\Auth\Entity\User; use App\Patient\Entity\PatientRecord; use App\Patient\Entity\PatientSession; +use App\Patient\Entity\SessionPayment; use App\Patient\Entity\SessionService; use App\Patient\Repository\PatientRecordRepository; use App\Patient\Repository\PatientSessionRepository; +use App\Patient\Repository\SessionPaymentRepository; use App\Patient\Repository\SessionServiceRepository; +use App\Settlement\Service\WalletService; +use App\Shared\Constant\ErrorCodes; +use App\Shared\Exception\AppException; use App\Staff\Repository\ClinicStaffRepository; use App\Subscription\Service\SubscriptionService; @@ -25,6 +31,7 @@ class PatientService private readonly PatientRecordRepository $recordRepo, private readonly PatientSessionRepository $sessionRepo, private readonly SessionServiceRepository $sessionServiceRepo, + private readonly SessionPaymentRepository $sessionPaymentRepo, private readonly ServiceItemRepository $serviceItemRepo, private readonly ClinicStaffRepository $staffRepo, private readonly UserRepository $userRepo, @@ -33,6 +40,7 @@ class PatientService private readonly ClinicRepository $clinicRepo, private readonly TenantInsuranceService $tenantInsuranceService, private readonly BillingCalculator $billingCalculator, + private readonly WalletService $walletService, ) {} /** @@ -178,4 +186,105 @@ class PatientService return $session; } + + /** + * اعمال/حذف تخفیف تسویه روی مراجعه. + * type=null → حذف تخفیف. percent باید 0..100 و fixed حداکثر برابر مبلغ نهایی باشد. + * تخفیف نمی‌تواند از مانده‌ی قابل‌تخفیف (مبلغ نهایی منهای پرداخت‌های ثبت‌شده) بیشتر شود. + */ + public function applyDiscount(PatientSession $session, ?string $type, int $value): PatientSession + { + if ($type === null) { + $session->setDiscount(null, 0, 0); + $this->sessionRepo->save($session); + return $session; + } + + if (!in_array($type, ['percent', 'fixed'], true) || $value < 0) { + throw new AppException(ErrorCodes::ERR_SESSION_DISCOUNT_INVALID, null, 422, 'discount_type'); + } + + $final = $session->getFinalPriceRials(); + if ($type === 'percent') { + if ($value > 100) { + throw new AppException(ErrorCodes::ERR_SESSION_DISCOUNT_INVALID, null, 422, 'discount_value'); + } + $rials = (int) round($final * $value / 100); + } else { + if ($value > $final) { + throw new AppException(ErrorCodes::ERR_SESSION_DISCOUNT_INVALID, null, 422, 'discount_value'); + } + $rials = $value; + } + + // تخفیف نباید از آنچه هنوز پرداخت نشده بیشتر باشد (پرداخت‌ها برگشت‌ناپذیرند) + if ($rials > $final - $session->getPaidTotalRials()) { + throw new AppException(ErrorCodes::ERR_SESSION_DISCOUNT_INVALID, null, 422, 'discount_value'); + } + + $session->setDiscount($type, $value, $rials); + if ($session->getRemainingRials() === 0 && $session->getPaymentMethod() === 'pending') { + // تخفیف صددرصدی بدهی را صفر کرد — مراجعه تسویه‌شده تلقی می‌شود + $session->setPaymentMethod('cash'); + $session->setPaidAt(time()); + } + $this->sessionRepo->save($session); + + return $session; + } + + /** + * ثبت یک پرداخت جزئی روی مراجعه. روش wallet همان مبلغ را از کیف پول بیمار + * کسر می‌کند (موجودی ناکافی → ۴۲۲). وقتی مانده صفر شود، payment_method و + * paid_at مراجعه ست می‌شوند تا is_paid برای مصرف‌کننده‌های فعلی درست بماند. + */ + public function addSessionPayment( + PatientSession $session, + string $method, + int $amountRials, + ?int $paidAt = null, + ?User $actor = null, + ): SessionPayment { + if (!in_array($method, SessionPayment::METHODS, true)) { + throw new AppException(ErrorCodes::ERR_SESSION_PAYMENT_INVALID, null, 422, 'method'); + } + if ($amountRials <= 0) { + throw new AppException(ErrorCodes::ERR_SESSION_PAYMENT_INVALID, null, 422, 'amount_rials'); + } + + $remaining = $session->getRemainingRials(); + if ($amountRials > $remaining) { + throw new AppException(ErrorCodes::ERR_SESSION_PAYMENT_EXCEEDS, null, 422, 'amount_rials'); + } + + if ($method === 'wallet') { + $names = array_values(array_filter(array_map( + fn(SessionService $s) => $s->toArray()['service_name'] ?? null, + $session->getServices()->toArray(), + ))); + $label = $names !== [] ? implode('، ', $names) : 'ویزیت'; + $this->walletService->withdraw( + $session->getRecord()->getUser(), + $amountRials, + $actor, + 'پرداخت سرویس: ' . $label, + 'wallet', + 'session:' . $session->getUuid(), + ); + } + + $payment = new SessionPayment($session, $method, $amountRials, $paidAt); + $payment->setCreatedBy($actor) + ->setCreatedByName($this->walletService->resolveActorName($actor)); + $this->sessionPaymentRepo->save($payment); + $session->addPayment($payment); + + if ($session->getRemainingRials() === 0) { + $session->setPaymentMethod($method); + $session->setPaidAt($paidAt ?? time()); + } + $this->sessionRepo->save($session); + + return $payment; + } } diff --git a/src/Shared/Constant/ErrorCodes.php b/src/Shared/Constant/ErrorCodes.php index 3727f425..22f04cff 100644 --- a/src/Shared/Constant/ErrorCodes.php +++ b/src/Shared/Constant/ErrorCodes.php @@ -62,6 +62,9 @@ class ErrorCodes // Patient public const ERR_PATIENT_NOT_FOUND = 'ERR_PATIENT_NOT_FOUND'; public const ERR_SESSION_NOT_FOUND = 'ERR_SESSION_NOT_FOUND'; + public const ERR_SESSION_PAYMENT_INVALID = 'ERR_SESSION_PAYMENT_INVALID'; + public const ERR_SESSION_PAYMENT_EXCEEDS = 'ERR_SESSION_PAYMENT_EXCEEDS'; + public const ERR_SESSION_DISCOUNT_INVALID = 'ERR_SESSION_DISCOUNT_INVALID'; // Profile public const ERR_PROFILE_NATIONAL_CODE_TAKEN = 'ERR_PROFILE_001'; @@ -146,6 +149,9 @@ class ErrorCodes self::ERR_PROFILE_NATIONAL_CODE_TAKEN => 'این کد ملی قبلاً برای کاربر دیگری ثبت شده است', self::ERR_PROFILE_MOBILE_TAKEN => 'این شماره موبایل قبلاً برای کاربر دیگری ثبت شده است', self::ERR_SESSION_NOT_FOUND => 'مراجعه یافت نشد', + self::ERR_SESSION_PAYMENT_INVALID => 'مبلغ یا روش پرداخت نامعتبر است', + self::ERR_SESSION_PAYMENT_EXCEEDS => 'مبلغ پرداخت از مانده بدهی بیشتر است', + self::ERR_SESSION_DISCOUNT_INVALID => 'مقدار تخفیف نامعتبر است', self::ERR_SMS_WALLET_INSUFFICIENT => 'موجودی کیف پیامک کافی نیست', self::ERR_WALLET_INSUFFICIENT => 'موجودی کیف پول کافی نیست', self::ERR_RATING_NOT_ELIGIBLE => 'برای ثبت نظر یا امتیاز باید در یک ماه گذشته نوبت تایید‌شده نزد این پزشک داشته باشید', diff --git a/tests/Patient/SessionPaymentTest.php b/tests/Patient/SessionPaymentTest.php new file mode 100644 index 00000000..e7b94d72 --- /dev/null +++ b/tests/Patient/SessionPaymentTest.php @@ -0,0 +1,270 @@ +createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر'); + $this->em->persist($doctor); + $this->em->flush(); + + $patient = $this->createUser(['ROLE_USER']); + $record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId()); + $this->em->persist($record); + $this->em->flush(); + + return [$owner, $record, $patient]; + } + + private function sessionFor(PatientRecord $record, int $finalPriceRials): PatientSession + { + $session = new PatientSession($record); + $session->setFinalPriceRials($finalPriceRials); + $this->em->persist($session); + $this->em->flush(); + + return $session; + } + + // ── پرداخت جزئی ───────────────────────────────────────────────────────── + + public function testPartialPaymentReducesDebtButNotPaid(): void + { + [$owner, $record] = $this->recordFor(); + $session = $this->sessionFor($record, 500_000); + + $res = $this->authJson('POST', '/api/v1/session/' . $session->getUuid() . '/payments', $owner, [ + 'method' => 'cash', 'amount_rials' => 200_000, + ]); + self::assertSame(201, $this->responseCode()); + self::assertFalse($res['data']['is_paid']); + self::assertSame(200_000, $res['data']['paid_total_rials']); + self::assertSame(300_000, $res['data']['patient_debt_rials']); + self::assertCount(1, $res['data']['payments']); + self::assertSame('cash', $res['data']['payments'][0]['method']); + self::assertSame($owner->getMobileNumber(), $res['data']['payments'][0]['created_by_name']); + } + + public function testFullSettlementViaMultiplePaymentsMarksPaid(): void + { + [$owner, $record] = $this->recordFor(); + $session = $this->sessionFor($record, 500_000); + + $this->authJson('POST', '/api/v1/session/' . $session->getUuid() . '/payments', $owner, [ + 'method' => 'cash', 'amount_rials' => 200_000, + ]); + $res = $this->authJson('POST', '/api/v1/session/' . $session->getUuid() . '/payments', $owner, [ + 'method' => 'card', 'amount_rials' => 300_000, 'paid_at' => 1_800_000_000, + ]); + + self::assertSame(201, $this->responseCode()); + self::assertTrue($res['data']['is_paid']); + self::assertSame('card', $res['data']['payment_method']); + self::assertSame(1_800_000_000, $res['data']['paid_at']); + self::assertSame(0, $res['data']['patient_debt_rials']); + self::assertCount(2, $res['data']['payments']); + } + + public function testWalletPartialPaymentDebitsWallet(): void + { + [$owner, $record, $patient] = $this->recordFor(); + $this->em->persist(new WalletTransaction($patient, 1_000_000, 'credit', 1_000_000)); + $this->em->flush(); + $session = $this->sessionFor($record, 400_000); + + $res = $this->authJson('POST', '/api/v1/session/' . $session->getUuid() . '/payments', $owner, [ + 'method' => 'wallet', 'amount_rials' => 150_000, + ]); + self::assertSame(201, $this->responseCode()); + self::assertSame(250_000, $res['data']['patient_debt_rials']); + + $wallet = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet', $owner); + self::assertSame(850_000, $wallet['data']['balance_rials']); + $debit = $wallet['data']['recent_transactions'][0]; + self::assertSame('debit', $debit['type']); + self::assertSame('session:' . $session->getUuid(), $debit['reference']); + } + + public function testWalletPaymentRejectedWhenInsufficient(): void + { + [$owner, $record, $patient] = $this->recordFor(); + $this->em->persist(new WalletTransaction($patient, 100_000, 'credit', 100_000)); + $this->em->flush(); + $session = $this->sessionFor($record, 400_000); + + $res = $this->authJson('POST', '/api/v1/session/' . $session->getUuid() . '/payments', $owner, [ + 'method' => 'wallet', 'amount_rials' => 200_000, + ]); + self::assertSame(422, $this->responseCode()); + self::assertSame('ERR_WALLET_INSUFFICIENT', $res['errors'][0]['code']); + + // هیچ پرداختی ثبت نشد + $sessions = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/sessions', $owner); + self::assertSame(400_000, $sessions['data'][0]['patient_debt_rials']); + self::assertCount(0, $sessions['data'][0]['payments']); + } + + public function testPaymentExceedingDebtRejected(): void + { + [$owner, $record] = $this->recordFor(); + $session = $this->sessionFor($record, 300_000); + + $res = $this->authJson('POST', '/api/v1/session/' . $session->getUuid() . '/payments', $owner, [ + 'method' => 'cash', 'amount_rials' => 400_000, + ]); + self::assertSame(422, $this->responseCode()); + self::assertSame('ERR_SESSION_PAYMENT_EXCEEDS', $res['errors'][0]['code']); + } + + public function testInvalidMethodRejected(): void + { + [$owner, $record] = $this->recordFor(); + $session = $this->sessionFor($record, 300_000); + + $res = $this->authJson('POST', '/api/v1/session/' . $session->getUuid() . '/payments', $owner, [ + 'method' => 'bitcoin', 'amount_rials' => 100_000, + ]); + self::assertSame(422, $this->responseCode()); + self::assertSame('ERR_SESSION_PAYMENT_INVALID', $res['errors'][0]['code']); + } + + public function testZeroAmountRejected(): void + { + [$owner, $record] = $this->recordFor(); + $session = $this->sessionFor($record, 300_000); + + $res = $this->authJson('POST', '/api/v1/session/' . $session->getUuid() . '/payments', $owner, [ + 'method' => 'cash', 'amount_rials' => 0, + ]); + self::assertSame(422, $this->responseCode()); + self::assertSame('ERR_SESSION_PAYMENT_INVALID', $res['errors'][0]['code']); + } + + public function testSessionNotFoundReturns404(): void + { + [$owner] = $this->recordFor(); + + $this->authJson('POST', '/api/v1/session/00000000-0000-0000-0000-000000000000/payments', $owner, [ + 'method' => 'cash', 'amount_rials' => 100_000, + ]); + self::assertSame(404, $this->responseCode()); + } + + // ── تخفیف تسویه ───────────────────────────────────────────────────────── + + public function testPercentDiscountReducesDebt(): void + { + [$owner, $record] = $this->recordFor(); + $session = $this->sessionFor($record, 400_000); + + $res = $this->authJson('PATCH', '/api/v1/session/' . $session->getUuid(), $owner, [ + 'discount_type' => 'percent', 'discount_value' => 25, + ]); + self::assertSame(200, $this->responseCode()); + self::assertSame('percent', $res['data']['discount_type']); + self::assertSame(100_000, $res['data']['discount_rials']); + + $sessions = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/sessions', $owner); + self::assertSame(300_000, $sessions['data'][0]['patient_debt_rials']); + } + + public function testFixedDiscountThenRemove(): void + { + [$owner, $record] = $this->recordFor(); + $session = $this->sessionFor($record, 400_000); + + $res = $this->authJson('PATCH', '/api/v1/session/' . $session->getUuid(), $owner, [ + 'discount_type' => 'fixed', 'discount_value' => 150_000, + ]); + self::assertSame(150_000, $res['data']['discount_rials']); + + // حذف تخفیف + $res = $this->authJson('PATCH', '/api/v1/session/' . $session->getUuid(), $owner, [ + 'discount_type' => null, + ]); + self::assertSame(200, $this->responseCode()); + self::assertNull($res['data']['discount_type']); + self::assertSame(0, $res['data']['discount_rials']); + } + + public function testDiscountOverLimitsRejected(): void + { + [$owner, $record] = $this->recordFor(); + $session = $this->sessionFor($record, 400_000); + + $res = $this->authJson('PATCH', '/api/v1/session/' . $session->getUuid(), $owner, [ + 'discount_type' => 'percent', 'discount_value' => 150, + ]); + self::assertSame(422, $this->responseCode()); + self::assertSame('ERR_SESSION_DISCOUNT_INVALID', $res['errors'][0]['code']); + + $res = $this->authJson('PATCH', '/api/v1/session/' . $session->getUuid(), $owner, [ + 'discount_type' => 'fixed', 'discount_value' => 500_000, + ]); + self::assertSame(422, $this->responseCode()); + self::assertSame('ERR_SESSION_DISCOUNT_INVALID', $res['errors'][0]['code']); + } + + public function testFullPercentDiscountMarksPaid(): void + { + [$owner, $record] = $this->recordFor(); + $session = $this->sessionFor($record, 400_000); + + $res = $this->authJson('PATCH', '/api/v1/session/' . $session->getUuid(), $owner, [ + 'discount_type' => 'percent', 'discount_value' => 100, + ]); + self::assertSame(200, $this->responseCode()); + self::assertTrue($res['data']['is_paid']); + self::assertNotNull($res['data']['paid_at']); + } + + public function testDiscountPlusPaymentSettles(): void + { + [$owner, $record] = $this->recordFor(); + $session = $this->sessionFor($record, 400_000); + + $this->authJson('PATCH', '/api/v1/session/' . $session->getUuid(), $owner, [ + 'discount_type' => 'fixed', 'discount_value' => 100_000, + ]); + $res = $this->authJson('POST', '/api/v1/session/' . $session->getUuid() . '/payments', $owner, [ + 'method' => 'pos', 'amount_rials' => 300_000, + ]); + self::assertSame(201, $this->responseCode()); + self::assertTrue($res['data']['is_paid']); + self::assertSame('pos', $res['data']['payment_method']); + self::assertSame(0, $res['data']['patient_debt_rials']); + } + + public function testPaymentOnSettledSessionRejected(): void + { + [$owner, $record] = $this->recordFor(); + $session = $this->sessionFor($record, 200_000); + + $this->authJson('POST', '/api/v1/session/' . $session->getUuid() . '/payments', $owner, [ + 'method' => 'cash', 'amount_rials' => 200_000, + ]); + self::assertSame(201, $this->responseCode()); + + // مراجعه تسویه شده — هر پرداخت بعدی از مانده (صفر) بیشتر است + $res = $this->authJson('POST', '/api/v1/session/' . $session->getUuid() . '/payments', $owner, [ + 'method' => 'cash', 'amount_rials' => 1, + ]); + self::assertSame(422, $this->responseCode()); + self::assertSame('ERR_SESSION_PAYMENT_EXCEEDS', $res['errors'][0]['code']); + } +}