feat: port tauri create-service payment flow to admin session settlement
Port the tauri /files/create-service page (payment mode) to the admin SPA
and back it with real multi-part session settlement:
Backend:
- New SessionPayment entity (session_payments table): partial payments
per session with method (wallet/pos/cash/card), amount, paid_at, actor
- PatientSession: settlement discount (percent/fixed), discount_rials,
paid_at, payments relation; remaining debt derived from
final - discount - paid total
- POST /api/v1/session/{uuid}/payments: register a partial payment;
wallet method debits the patient wallet; zero remaining marks paid
- PATCH /api/v1/session/{uuid}: accepts discount_type/discount_value
(null removes) and paid_at, backward compatible
- New error codes: ERR_SESSION_PAYMENT_INVALID/_EXCEEDS,
ERR_SESSION_DISCOUNT_INVALID
- Migration + 14 functional tests (partial/full/wallet/exceed/discount)
Frontend (admin):
- SessionPaymentPage: two-step stepper (پرداخت ← جزییات) ported from
tauri AddService payment mode — service cost, settlement discount
input, Jalali payment date, wallet balance, 4-method payment accordion,
paid-list box, details summary
- SessionStepper + stepper/payment icons ported verbatim from tauri SVGs
- «تکمیل پرداخت» on SessionServiceCard now navigates to the payment page
(replaces the small settle modal on PatientDetailPage)
- Routes for patients/ and my-patients/ variants; vitest coverage
- docs/api/patient.md updated
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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() {
|
||||
<Route path="patients/:uuid" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><PatientDetailPage /></RoleRoute>} />
|
||||
<Route path="patients/:recordUuid/session/new" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><NewSessionPage /></RoleRoute>} />
|
||||
<Route path="my-patients/:recordUuid/session/new" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><NewSessionPage /></RoleRoute>} />
|
||||
<Route path="patients/:recordUuid/session/:sessionUuid/pay" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><SessionPaymentPage /></RoleRoute>} />
|
||||
<Route path="my-patients/:recordUuid/session/:sessionUuid/pay" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><SessionPaymentPage /></RoleRoute>} />
|
||||
<Route path="insurance-pricing" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><InsurancePricingPage /></RoleRoute>} />
|
||||
<Route path="claims" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><ClaimsPage /></RoleRoute>} />
|
||||
<Route path="my-financial" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']}><MyFinancialPage /></RoleRoute>} />
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface SessionPaymentData {
|
||||
|
||||
const PAYMENT_LABELS: Record<string, string> = {
|
||||
cash: 'نقدی', card: 'کارت', insurance: 'بیمه', online: 'آنلاین', pending: 'در انتظار',
|
||||
wallet: 'کیف پول', pos: 'کارتخوان',
|
||||
};
|
||||
|
||||
/** vertical hairline divider between meta columns (tauri MUI vertical Divider). */
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
FilesServiceAddServiceStep,
|
||||
FilesAddServicePaymentStep,
|
||||
FilesServiceDetailsStep,
|
||||
} from './icons/FilesServiceIcons';
|
||||
|
||||
/**
|
||||
* استپر صفحهی سرویس/تسویه — پورت tauri files/services/CustomizedStepper
|
||||
* (کانکتور بنفش، آیکون دایرهای هر گام، تیک سبز برای گامهای کاملشده).
|
||||
*/
|
||||
|
||||
// هر برچسب گام آیکون ثابت خودش را دارد تا در حالت payment-only هم درست بماند.
|
||||
const ICON_BY_LABEL: Record<string, number> = {
|
||||
'ایجاد سرویس': 1,
|
||||
'ویرایش': 1,
|
||||
'پرداخت': 2,
|
||||
'جزییات': 3,
|
||||
};
|
||||
|
||||
function StepIcon({ index, active, completed }: { index: number; active: boolean; completed: boolean }) {
|
||||
if (completed && !active) {
|
||||
// دایره سفید با تیک سبز (tauri ColorlibStepIconRoot completed state)
|
||||
return (
|
||||
<div style={{ width: 52, height: 52, borderRadius: '50%', background: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="#22C55E" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const shadow = active ? { boxShadow: '0 4px 10px 0 rgba(85, 89, 206, 0.25)', borderRadius: '50%' } : undefined;
|
||||
switch (index) {
|
||||
case 1: return <div style={shadow}><FilesServiceAddServiceStep active={active} /></div>;
|
||||
case 2: return <div style={shadow}><FilesAddServicePaymentStep active={active} /></div>;
|
||||
default: return <div style={shadow}><FilesServiceDetailsStep active={active} /></div>;
|
||||
}
|
||||
}
|
||||
|
||||
export default function SessionStepper({ activeStep = 0, steps = [] }: { activeStep?: number; steps?: string[] }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', width: '100%', marginBottom: 24 }}>
|
||||
{steps.map((label, i) => (
|
||||
<div key={label} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', position: 'relative' }}>
|
||||
{/* connector to previous step (tauri ColorlibConnector: top 22, h 3, marginX 40) */}
|
||||
{i > 0 && (
|
||||
<div
|
||||
data-testid={`step-connector-${i}`}
|
||||
style={{
|
||||
position: 'absolute', top: 22, right: 'calc(50% + 40px)', left: 'calc(-50% + 40px)',
|
||||
height: 3, borderRadius: 1,
|
||||
background: i <= activeStep ? 'linear-gradient(95deg, #5559ce 0%, #5559ce 100%)' : '#eaeaf0',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<StepIcon index={ICON_BY_LABEL[label] ?? i + 1} active={i === activeStep} completed={i < activeStep} />
|
||||
<span className="dark:text-[#D7D8ED]" style={{ marginTop: 8, fontSize: '0.875rem', fontWeight: 600, color: '#3b3b3b' }}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -249,3 +249,112 @@ export function StatusGlobe({ color = '#616161', size = 20, style }: IconProps &
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 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 (
|
||||
<svg width={size} height={size} viewBox="0 0 52 52" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ display: 'block' }}>
|
||||
<circle cx="26" cy="26" r="25" stroke={circleStrokeColor} strokeWidth="2" strokeDasharray="16 2" />
|
||||
<rect x="6.5" y="6.5" width="39" height="39" rx="19.5" fill={bgColor} />
|
||||
<rect x="6.5" y="6.5" width="39" height="39" rx="19.5" stroke={bgColor} />
|
||||
<path d="M23.5605 32V27" stroke={iconColor} strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M26 29.5H21" stroke={iconColor} strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M22 16V19" stroke={iconColor} strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M30 16V19" stroke={iconColor} strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M29.8098 17.4199C33.1498 17.5399 34.8398 18.7699 34.9398 23.4699L35.0698 29.6399C35.1498 33.7599 34.1998 35.8299 29.1998 35.9399L23.1998 36.0599C18.1998 36.1599 17.1598 34.1199 17.0798 30.0099L16.9398 23.8299C16.8398 19.1299 18.4898 17.8299 21.8098 17.5799L29.8098 17.4199Z" stroke={iconColor} strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<svg width={size} height={size} viewBox="0 0 52 52" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ display: 'block' }}>
|
||||
<circle cx="26" cy="26" r="25" stroke={circleStrokeColor} strokeWidth="2" strokeDasharray="16 2" />
|
||||
<rect x="6.5" y="6.5" width="39" height="39" rx="19.5" fill={bgColor} />
|
||||
<rect x="6.5" y="6.5" width="39" height="39" rx="19.5" stroke={bgColor} />
|
||||
<path d="M17.4297 29.8792L29.3797 17.9292" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M24.6016 32.2791L25.8016 31.0791" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M27.293 29.5887L29.683 27.1987" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M17.1013 24.239L23.7413 17.599C25.8613 15.479 26.9213 15.469 29.0213 17.569L33.9313 22.479C36.0313 24.579 36.0213 25.639 33.9013 27.759L27.2613 34.399C25.1413 36.519 24.0813 36.529 21.9813 34.429L17.0713 29.519C14.9713 27.419 14.9713 26.369 17.1013 24.239Z" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M15.5 35.9985H35.5" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<svg width={size} height={size} viewBox="0 0 52 52" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ display: 'block' }}>
|
||||
<circle cx="26" cy="26" r="25" stroke={circleStrokeColor} strokeWidth="2" strokeDasharray="16 2" />
|
||||
<rect x="6.5" y="6.5" width="39" height="39" rx="19.5" fill={bgColor} />
|
||||
<rect x="6.5" y="6.5" width="39" height="39" rx="19.5" stroke={bgColor} />
|
||||
<path d="M22 16V19" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M30 16V19" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M21 27H29" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M21 31H26" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M30 17.5C33.33 17.68 35 18.95 35 23.65V29.83C35 33.95 34 36.01 29 36.01H23C18 36.01 17 33.95 17 29.83V23.65C17 18.95 18.67 17.69 22 17.5H30Z" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Payment step widgets (tauri Step2Payment) ───────────────────────────── */
|
||||
|
||||
/** Orange «هزینه سرویس» badge — tauri Step2PaymentCard, verbatim. */
|
||||
export function Step2PaymentCard({ size = 40 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="40" height="40" rx="20" fill="#FFF4E5" />
|
||||
<path d="M27 23.5C28.9339 23.5 30.5 25.0661 30.5 27C30.5 27.6563 30.316 28.2758 29.9941 28.7979L29.9902 28.8047C29.387 29.8186 28.2772 30.5 27 30.5C25.8025 30.5 24.7525 29.9008 24.1289 28.9902L24.0098 28.8047L24.0059 28.7979L23.8916 28.5977C23.6407 28.1204 23.5 27.5743 23.5 27C23.5 25.0661 25.0661 23.5 27 23.5ZM29.4756 25.1689C29.0107 24.6708 28.216 24.6333 27.7109 25.1025L26.4629 26.2559L26.3232 26.1162C25.838 25.6313 25.0418 25.6313 24.5566 26.1162C24.0714 26.6015 24.0714 27.3985 24.5566 27.8838L25.5469 28.874C25.7954 29.1223 26.1145 29.2402 26.4297 29.2402C26.7311 29.2402 27.0438 29.1255 27.2793 28.9072L29.4092 26.9375L29.418 26.9297C29.9048 26.459 29.9501 25.6774 29.4756 25.1689Z" fill="#F17732" stroke="#F17732" />
|
||||
<path d="M14.1396 11.8999H25.8496C27.8635 11.8999 29.5 13.5364 29.5 15.5503V15.9995C29.5 16.2734 29.2739 16.4995 29 16.4995H11C10.7261 16.4995 10.5 16.2734 10.5 15.9995V15.5396C10.5002 13.526 12.1261 11.9001 14.1396 11.8999Z" fill="#F17732" stroke="#F17732" />
|
||||
<path d="M29 18.9902C29.2739 18.9902 29.5 19.2164 29.5 19.4902V20.8799C29.5 21.2157 29.1673 21.4608 28.8301 21.3467H28.8291C27.6616 20.9539 26.3422 20.9004 24.9873 21.334C24.1629 21.5978 23.4064 22.0584 22.792 22.6611C21.4095 24.003 20.8496 25.6792 20.9111 27.2607L20.9316 27.5762C20.9516 27.831 20.7192 28.1004 20.4004 28.1006H14.1396C12.126 28.1004 10.5 26.4737 10.5 24.46V19.5C10.5 19.2239 10.7239 19 11 18.9902H29ZM14 23.25C13.3139 23.25 12.75 23.814 12.75 24.5C12.75 25.1861 13.3139 25.75 14 25.75H16C16.6861 25.75 17.25 25.1861 17.25 24.5C17.25 23.814 16.6861 23.25 16 23.25H14Z" fill="#F17732" stroke="#F17732" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** «موجودی کیف پول» icon — tauri FilesServiceBalanceWallet, verbatim. */
|
||||
export function FilesServiceBalanceWallet({ size = 20 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.833 9.2915H5.83301" stroke="#F17732" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M1.66699 9.2917V5.4417C1.66699 3.7417 3.04199 2.3667 4.74199 2.3667H9.42532C11.1253 2.3667 12.5003 3.42503 12.5003 5.12503" stroke="#F17732" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M14.567 10.1667C14.1503 10.5667 13.9503 11.1833 14.117 11.8166C14.3253 12.5916 15.092 13.0833 15.892 13.0833H16.667V14.2917C16.667 16.1333 15.1753 17.625 13.3337 17.625H5.00033C3.15866 17.625 1.66699 16.1333 1.66699 14.2917V8.45833C1.66699 6.61667 3.15866 5.125 5.00033 5.125H13.3337C15.167 5.125 16.667 6.625 16.667 8.45833V9.66663H15.767C15.3003 9.66663 14.8753 9.84999 14.567 10.1667Z" stroke="#F17732" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M18.3338 10.5165V12.2332C18.3338 12.6999 17.9505 13.0832 17.4755 13.0832H15.8672C14.9672 13.0832 14.1422 12.4249 14.0672 11.5249C14.0172 10.9999 14.2172 10.5082 14.5672 10.1665C14.8755 9.84987 15.3005 9.6665 15.7672 9.6665H17.4755C17.9505 9.6665 18.3338 10.0499 18.3338 10.5165Z" stroke="#F17732" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Red trash icon (حذف تخفیف) — tauri TrashRed, verbatim. */
|
||||
export function TrashRed({ size = 20, color = '#FF5450' }: { size?: number; color?: string }) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 20 20" fill="none">
|
||||
<path d="M17.5 4.98356C14.725 4.70856 11.9333 4.56689 9.15 4.56689C7.5 4.56689 5.85 4.65023 4.2 4.81689L2.5 4.98356" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M7.08301 4.1415L7.26634 3.04984C7.39967 2.25817 7.49967 1.6665 8.90801 1.6665H11.0913C12.4997 1.6665 12.608 2.2915 12.733 3.05817L12.9163 4.1415" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M15.7087 7.6167L15.167 16.0084C15.0753 17.3167 15.0003 18.3334 12.6753 18.3334H7.32533C5.00033 18.3334 4.92533 17.3167 4.83366 16.0084L4.29199 7.6167" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M8.6084 13.75H11.3834" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M7.91699 10.4165H12.0837" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Modal close (X) — tauri CloseModalD, verbatim. */
|
||||
export function CloseModalD() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 17 17" fill="none">
|
||||
<path className="fill-[#7E7E7E] dark:fill-[#FAFAFA]" d="M12.5947 4.20135C12.533 4.13954 12.4597 4.09051 12.3791 4.05706C12.2984 4.0236 12.212 4.00638 12.1247 4.00638C12.0374 4.00638 11.9509 4.0236 11.8703 4.05706C11.7896 4.09051 11.7164 4.13954 11.6547 4.20135L8.39468 7.45468L5.13468 4.19468C5.07296 4.13296 4.99969 4.084 4.91904 4.0506C4.8384 4.01719 4.75197 4 4.66468 4C4.57739 4 4.49096 4.01719 4.41032 4.0506C4.32968 4.084 4.2564 4.13296 4.19468 4.19468C4.13296 4.2564 4.084 4.32968 4.0506 4.41032C4.01719 4.49096 4 4.57739 4 4.66468C4 4.75197 4.01719 4.8384 4.0506 4.91904C4.084 4.99969 4.13296 5.07296 4.19468 5.13468L7.45468 8.39468L4.19468 11.6547C4.13296 11.7164 4.084 11.7897 4.0506 11.8703C4.01719 11.951 4 12.0374 4 12.1247C4 12.212 4.01719 12.2984 4.0506 12.379C4.084 12.4597 4.13296 12.533 4.19468 12.5947C4.2564 12.6564 4.32968 12.7054 4.41032 12.7388C4.49096 12.7722 4.57739 12.7894 4.66468 12.7894C4.75197 12.7894 4.8384 12.7722 4.91904 12.7388C4.99969 12.7054 5.07296 12.6564 5.13468 12.5947L8.39468 9.33468L11.6547 12.5947C11.7164 12.6564 11.7897 12.7054 11.8703 12.7388C11.951 12.7722 12.0374 12.7894 12.1247 12.7894C12.212 12.7894 12.2984 12.7722 12.379 12.7388C12.4597 12.7054 12.533 12.6564 12.5947 12.5947C12.6564 12.533 12.7054 12.4597 12.7388 12.379C12.7722 12.2984 12.7894 12.212 12.7894 12.1247C12.7894 12.0374 12.7722 11.951 12.7388 11.8703C12.7054 11.7897 12.6564 11.7164 12.5947 11.6547L9.33468 8.39468L12.5947 5.13468C12.848 4.88135 12.848 4.45468 12.5947 4.20135Z" fill="#7E7E7E" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<typeof vi.fn>;
|
||||
patch.mockResolvedValue({ success: true, data: {} });
|
||||
renderDetail();
|
||||
it('navigates to the session payment page when «تکمیل پرداخت» is clicked', async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/admin/patients/:uuid" element={<PatientDetailPage />} />
|
||||
<Route path="/admin/patients/:recordUuid/session/:sessionUuid/pay" element={<div>صفحه تکمیل پرداخت</div>} />
|
||||
</Routes>,
|
||||
{ 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 () => {
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [settleTarget, setSettleTarget] = useState<string | null>(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 (
|
||||
<div className="fade-in">
|
||||
{/* breadcrumb + patient banner (tauri BreadcrumbHeader + FileServicesHeader) */}
|
||||
@@ -230,7 +217,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} settling={settle.isPending} onSettle={(u) => setSettleTarget(u)} onViewInvoice={(iv) => setInvoiceUuid(iv)} />
|
||||
<SessionServiceCard key={s.uuid} session={s} onSettle={(u) => nav(`/admin/patients/${uuid}/session/${u}/pay`)} onViewInvoice={(iv) => setInvoiceUuid(iv)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -254,28 +241,6 @@ export default function PatientDetailPage() {
|
||||
)}
|
||||
|
||||
<InvoiceSummaryModal invoiceUuid={invoiceUuid} onClose={() => setInvoiceUuid(null)} />
|
||||
|
||||
{/* انتخاب روش پرداختِ مراجعه (نقدی / کارت / کیف پول) */}
|
||||
<Modal open={settleTarget !== null} title="روش پرداخت مراجعه" onClose={() => setSettleTarget(null)}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>روش تسویهٔ این مراجعه را انتخاب کنید:</p>
|
||||
{[
|
||||
{ method: 'cash', label: 'نقدی' },
|
||||
{ method: 'card', label: 'کارت به کارت' },
|
||||
{ method: 'wallet', label: 'کیف پول بیمار' },
|
||||
].map((m) => (
|
||||
<button
|
||||
key={m.method}
|
||||
className="btn"
|
||||
style={{ justifyContent: 'flex-start' }}
|
||||
disabled={settle.isPending}
|
||||
onClick={() => settleTarget && settle.mutate({ sessionUuid: settleTarget, method: m.method })}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<ApiResponse<Call[]>>({
|
||||
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 }) {
|
||||
<div style={{ width: 320, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 18 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, textAlign: 'center', marginBottom: 16 }}>ثبت تماس جدید</div>
|
||||
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>تاریخ تماس</label>
|
||||
<div style={{ margin: '6px 0 12px' }}><PersianDateInput value={date} onChange={setDate} /></div>
|
||||
<div style={{ margin: '6px 0 12px' }}><PersianDateInput value={date} onChange={(v) => { dateTouched.current = true; setDate(v); }} /></div>
|
||||
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>ساعت تماس</label>
|
||||
<div className="field" style={{ margin: '6px 0 12px' }}><input type="time" value={time} onChange={(e) => setTime(e.target.value)} dir="ltr" /></div>
|
||||
<div className="field" style={{ margin: '6px 0 12px' }}><input type="time" value={time} onChange={(e) => { timeTouched.current = true; setTime(e.target.value); }} dir="ltr" /></div>
|
||||
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>موضوع تماس</label>
|
||||
<div className="field" style={{ margin: '6px 0 12px' }}><input value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="موضوع تماس" /></div>
|
||||
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>خلاصه تماس</label>
|
||||
@@ -746,7 +725,7 @@ function CallCenterTab({ uuid }: { uuid: string }) {
|
||||
{c.summary && <div style={{ fontSize: 12.5, color: 'var(--text-2)', marginTop: 4 }}>{c.summary}</div>}
|
||||
</div>
|
||||
<div style={{ textAlign: 'end', minWidth: 120 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-3)' }}>{formatDate(c.called_at)}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-3)' }}>{formatDateTime(c.called_at)}</div>
|
||||
{c.personnel && <div style={{ fontSize: 12, color: 'var(--text-2)', marginTop: 2 }}>{c.personnel}</div>}
|
||||
<button className="btn sm ghost" aria-label="حذف" style={{ color: 'var(--danger)', marginTop: 4 }} onClick={() => del.mutate(c.uuid)}><TrashIcon style={{ width: 14 }} /></button>
|
||||
</div>
|
||||
|
||||
@@ -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<typeof vi.fn>;
|
||||
const post = api.post as ReturnType<typeof vi.fn>;
|
||||
const patch = api.patch as ReturnType<typeof vi.fn>;
|
||||
|
||||
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(
|
||||
<Routes>
|
||||
<Route path="/admin/patients/:recordUuid/session/:sessionUuid/pay" element={<SessionPaymentPage />} />
|
||||
</Routes>,
|
||||
{ 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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
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<string | null>(null);
|
||||
const [amount, setAmount] = useState('');
|
||||
|
||||
const recordQ = useQuery<ApiResponse<PatientRecord>>({
|
||||
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<ApiResponse<SessionCardData[]>>({
|
||||
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<ApiResponse<{ balance_rials: number }>>({
|
||||
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 <div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>در حال بارگذاری...</div>;
|
||||
}
|
||||
if (!session) {
|
||||
return <div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>مراجعه یافت نشد</div>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="fade-in" style={{ width: '100%' }}>
|
||||
{/* breadcrumb — tauri AddService header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-start', marginBottom: 30 }}>
|
||||
<div className="dark:text-[#A1A1A1]" style={{ display: 'flex', alignItems: 'center', gap: 8, color: '#6B7280', fontSize: 12, padding: '0 16px' }}>
|
||||
<div
|
||||
onClick={() => nav(-1)}
|
||||
className="bg-white dark:bg-[#222433]"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '6px 8px', borderRadius: 12, cursor: 'pointer' }}
|
||||
>
|
||||
<ArrowLeftPH style={{ width: 18, height: 18, rotate: '180deg' }} />
|
||||
<span>بازگشت</span>
|
||||
</div>
|
||||
<ArrowLeftD />
|
||||
<span>پرونده</span>
|
||||
<ArrowLeftD />
|
||||
<span className="dark:text-[#D7D8ED]" style={{ color: '#111827' }}>{patientName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* card — tauri width 748 centered */}
|
||||
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
||||
<div className="bg-white dark:bg-[#222433]" style={{ width: 748, maxWidth: '100%', padding: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="بستن"
|
||||
onClick={() => nav(-1)}
|
||||
style={{ minWidth: 40, height: 40, marginBottom: 8, borderRadius: '50%', border: 'none', background: 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
>
|
||||
<CloseModalD />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SessionStepper activeStep={activeStep} steps={steps} />
|
||||
|
||||
{activeStep === 0 ? (
|
||||
<>
|
||||
{/* هزینه سرویس */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, margin: '12px 0 16px' }}>
|
||||
<Step2PaymentCard />
|
||||
<span style={{ fontSize: 14, color: '#F97316', fontWeight: 700 }}>هزینه سرویس:</span>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 700, color: '#525252' }}>{formatRial(finalPrice)}</span>
|
||||
</div>
|
||||
|
||||
{/* تخفیف — port of tauri DiscountInput (نوع + مقدار + ثبت) */}
|
||||
<span style={fieldLabel}>تخفیف:</span>
|
||||
<div style={{ display: 'flex', alignItems: 'stretch', gap: 8 }}>
|
||||
<div className="bg-white dark:bg-[#222433] dark:border-[#35343D]" style={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', border: '1px solid #e0e0e0', borderRadius: 8, marginBottom: 16 }}>
|
||||
<div style={{ minWidth: 150, borderLeft: '1px solid #e0e0e0', padding: '0 4px' }}>
|
||||
<SearchableSelect
|
||||
options={[{ value: 'percent', label: 'درصدی' }, { value: 'fixed', label: 'مبلغ ثابت' }]}
|
||||
value={discountType}
|
||||
onChange={(v) => setDiscountType(v ? String(v) : '')}
|
||||
placeholder="مبلغ تخفیف"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
dir="ltr"
|
||||
placeholder="مقدار تخفیف را وارد نمایید"
|
||||
value={discountValue}
|
||||
onChange={(e) => setDiscountValue(e.target.value)}
|
||||
style={{ flex: 1, minWidth: 0, height: 40, padding: '0 12px', fontSize: 13, border: 'none', outline: 'none', background: 'transparent', color: 'inherit' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={applyDiscount}
|
||||
disabled={discountMut.isPending || !discountType || !discountValue}
|
||||
style={{ height: 40, minWidth: 72, border: 'none', borderRadius: '0 4px 4px 0', background: '#E8EBFF', color: '#3B3F9F', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}
|
||||
>
|
||||
ثبت
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
onClick={removeDiscount}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0, cursor: 'pointer', marginBottom: 16, minWidth: 120, justifyContent: 'center' }}
|
||||
>
|
||||
<TrashRed color="#EF4444" size={18} />
|
||||
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 13, color: '#6B7280' }}>حذف تخفیف</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 13, marginBottom: 16 }}>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ color: '#2f2f2f' }}>مبلغ تخفیف:</span>{' '}
|
||||
<span style={{ color: '#D32F2F' }}>{formatRial(discountRials)}</span>
|
||||
</p>
|
||||
|
||||
{/* تاریخ پرداخت */}
|
||||
<span className="dark:text-[#A1A1A1]" style={{ ...fieldLabel, color: '#3b3b3b' }}>تاریخ پرداخت</span>
|
||||
<PersianDateInput value={paymentDate} onChange={setPaymentDate} />
|
||||
|
||||
{/* روشهای پرداخت */}
|
||||
<span className="dark:text-[#A1A1A1]" style={{ ...fieldLabel, color: '#3b3b3b', marginTop: 24 }}>انتخاب روش های پرداخت:</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<FilesServiceBalanceWallet />
|
||||
<span style={{ fontSize: 14, color: '#F97316', marginBottom: 8, fontWeight: 600 }}>موجودی کیف پول:</span>
|
||||
</div>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 600, color: '#525252', marginBottom: 4 }}>{formatRial(walletBalance)}</span>
|
||||
</div>
|
||||
|
||||
{/* آکاردئون چهار روش — باز شدن هر روش: مبلغ + ثبت پرداخت */}
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{METHODS.map((m) => {
|
||||
const open = expanded === m.key;
|
||||
return (
|
||||
<div key={m.key} className="dark:border-[#35343D]" style={{ borderBottom: '1px solid #e0e0e0' }}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => { setExpanded(open ? null : m.key); setAmount(''); }}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%', padding: '14px 4px', background: 'transparent', border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 600, color: '#111827' }}>{m.label}</span>
|
||||
<ChevronDownIcon style={{ width: 16, color: '#6B7280', transform: open ? 'rotate(180deg)' : undefined, transition: 'transform .15s' }} />
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{ display: 'flex', gap: 8, padding: '0 4px 14px' }}>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
dir="ltr"
|
||||
className="input"
|
||||
placeholder="مبلغ (تومان)"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
style={{ flex: 1, height: 40 }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submitPayment(m.key)}
|
||||
disabled={payMut.isPending || !amount}
|
||||
style={{ ...primaryBtn, height: 40, padding: '0 20px', borderRadius: 8 }}
|
||||
>
|
||||
ثبت پرداخت
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* پرداخت شدهها — باکس خطچین tauri */}
|
||||
<div className="dark:border-[#404040] dark:bg-[#222433]" style={{ border: '1px dashed #C7C9F4', borderRadius: 8, padding: 16, marginTop: 16, background: '#fff' }}>
|
||||
<span className="dark:text-[#6A6AD9]" style={{ fontSize: 16, fontWeight: 500, color: '#636bd4', display: 'block', marginBottom: 16 }}>پرداخت شده ها:</span>
|
||||
{payments.length === 0 ? (
|
||||
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 13, color: '#6B7280' }}>پرداختی ثبت نشده است</span>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{payments.map((p) => (
|
||||
<div key={p.uuid} className="dark:border-[#404040]" style={{ display: 'flex', alignItems: 'center', gap: 32, padding: '4px 0', borderBottom: '1px solid #e0e0e0' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="dark:bg-[#6A6AD9]" style={{ width: 8, height: 8, borderRadius: '50%', background: '#636bd4' }} />
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, color: '#111827' }}>{METHOD_LABELS[p.method] ?? p.method}</span>
|
||||
</span>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ flex: 1, textAlign: 'left', fontSize: 14, color: '#111827' }}>مبلغ : {formatRial(p.amount_rials)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8, marginTop: 16 }}>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 500, color: '#111827' }}>مبلغ باقیمانده :</span>
|
||||
<span className="dark:text-[#FF5252]" style={{ fontSize: 14, fontWeight: 600, color: '#d32f2f' }}>{formatRial(debt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ACTIONS */}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 16, width: '100%', justifyContent: 'flex-end' }}>
|
||||
<div style={{ width: '50%', display: 'flex', gap: 4 }}>
|
||||
<button type="button" style={{ ...ghostBtn, flex: 1 }} onClick={() => nav(-1)}>انصراف</button>
|
||||
<button type="button" style={{ ...primaryBtn, flex: 1 }} onClick={() => setActiveStep(1)}>ثبت و ادامه</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* گام جزییات — پورت tauri Step3Final با دیتای واقعی */}
|
||||
<div dir="rtl" className="dark:border-[#404040] dark:bg-[#222433]" style={{ padding: 16, border: '1px dashed #A7A7E0' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 32, marginBottom: 16 }}>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 500, color: '#2f2f2f' }}>
|
||||
{serviceNames.length ? serviceNames.join(' - ') : 'ویزیت'}
|
||||
</span>
|
||||
<span className="dark:bg-[#404040]" style={{ width: 1, height: 38, background: '#E0E0E0' }} />
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 500, color: '#525252' }}>{session.doctor_name || '—'}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 72, marginBottom: 16 }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 500, color: '#525252' }}>هزینه سرویس:</span>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 500, color: '#525252' }}>{formatRial(finalPrice)}</span>
|
||||
</span>
|
||||
<span className="dark:bg-[#404040]" style={{ width: 1, height: 38, background: '#E0E0E0' }} />
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 500, color: '#525252' }}>تخفیف:</span>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 500, color: '#525252' }}>{formatRial(discountRials)}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="dark:text-[#6A6AD9]" style={{ fontSize: 13, fontWeight: 600, color: '#5559CE', display: 'block', marginBottom: 12 }}>پرداخت شده ها:</span>
|
||||
{payments.length === 0 ? (
|
||||
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 13, color: '#6B7280' }}>پرداختی ثبت نشده است</span>
|
||||
) : payments.map((p) => (
|
||||
<div key={p.uuid} className="dark:border-[#404040]" style={{ display: 'flex', alignItems: 'center', gap: 56, padding: '8px 0', borderBottom: '1px solid #EEE' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="dark:bg-[#6A6AD9]" style={{ width: 6, height: 6, borderRadius: '50%', background: '#5559CE', flexShrink: 0 }} />
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 600, color: '#525252' }}>{METHOD_LABELS[p.method] ?? p.method}</span>
|
||||
</span>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 600, color: '#111827' }}>مبلغ: {formatRial(p.amount_rials)}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={{ display: 'flex', width: '100%', justifyContent: 'flex-end', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 16 }}>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 600, color: '#525252' }}>مبلغ باقی مانده:</span>
|
||||
<span className="dark:text-[#FF5252]" style={{ fontSize: 14, fontWeight: 600, color: '#d32f2f' }}>{formatRial(debt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 16, width: '100%', maxWidth: 320, margin: '16px auto 0' }}>
|
||||
<button type="button" style={{ ...ghostBtn, flex: 1 }} onClick={() => setActiveStep(0)}>انصراف</button>
|
||||
<button type="button" style={{ ...primaryBtn, flex: 1 }} onClick={finish}>صدور فاکتور</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+64
-1
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260716094319 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Session settlement: session_payments table + discount/paid_at columns on patient_sessions';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Entity;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Patient\Repository\SessionPaymentRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* یک پرداختِ جزئی روی یک مراجعه (تسویهی چندتکه). مجموع پرداختها بهعلاوهی
|
||||
* تخفیف، بدهیِ مراجعه را صفر میکند.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: SessionPaymentRepository::class)]
|
||||
#[ORM\Table(name: 'session_payments')]
|
||||
class SessionPayment
|
||||
{
|
||||
public const METHODS = ['wallet', 'pos', 'cash', 'card'];
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: PatientSession::class, inversedBy: 'payments')]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private PatientSession $session;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 15)]
|
||||
private string $method;
|
||||
|
||||
#[ORM\Column(name: 'amount_rials', type: 'integer')]
|
||||
private int $amountRials;
|
||||
|
||||
#[ORM\Column(name: 'paid_at', type: 'integer')]
|
||||
private int $paidAt;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(name: 'created_by_id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?User $createdBy = null;
|
||||
|
||||
#[ORM\Column(name: 'created_by_name', type: 'string', length: 255, nullable: true)]
|
||||
private ?string $createdByName = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
public function __construct(PatientSession $session, string $method, int $amountRials, ?int $paidAt = null)
|
||||
{
|
||||
$this->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Repository;
|
||||
|
||||
use App\Patient\Entity\SessionPayment;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class SessionPaymentRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, SessionPayment::class);
|
||||
}
|
||||
|
||||
public function save(SessionPayment $payment): void
|
||||
{
|
||||
$this->getEntityManager()->persist($payment);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 => 'برای ثبت نظر یا امتیاز باید در یک ماه گذشته نوبت تاییدشده نزد این پزشک داشته باشید',
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Patient;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Patient\Entity\PatientSession;
|
||||
use App\Settlement\Entity\WalletTransaction;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* تسویهی چندتکهی مراجعه: POST /session/{uuid}/payments پرداخت جزئی ثبت میکند
|
||||
* (روش wallet از کیف پول کسر میشود) و PATCH /session/{uuid} تخفیف تسویه
|
||||
* (percent/fixed/حذف) را اعمال میکند. صفر شدن مانده → is_paid و paid_at.
|
||||
*/
|
||||
class SessionPaymentTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: \App\Auth\Entity\User, 1: PatientRecord, 2: \App\Auth\Entity\User} */
|
||||
private function recordFor(): array
|
||||
{
|
||||
$owner = $this->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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user