feat: insurance & medical billing system (6 phases)

Multi-tenant insurance contracts, service coverage, versioned tariffs,
invoice calculation, and insurance claims with debt reporting.

- TenantInsurance: per-tenant insurance contracts (coverage/franchise/ceiling,
  versioning, soft-deactivate) + active guard
- ServiceItem.insuranceCovered + TenantServiceCoverage per-service overrides
- Tariff: versioned yearly tariffs with fallback to ServiceItem price
- Billing domain: Money/ShareBreakdown VOs, BillingCalculator (unit-tested),
  Invoice/InvoiceItem aggregate, InvoiceService.createFromSession
- Claim/ClaimItem with state machine (pending->submitted->approved/rejected->paid),
  ClaimService, insurance-debt report
- ClaimSubmitterInterface + ManualClaimSubmitter (future insurance API ready)
- Admin UI: insurance-pricing page, claims page, service tariff modal,
  service insurance toggle; routes + sidebar entries
- Architecture doc + billing/insurance/clinic-services API docs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-06-23 15:05:24 +03:30
co-authored by Claude Opus 4.8
parent 5b1dfe9b40
commit 89191eee57
54 changed files with 4233 additions and 10 deletions
+110
View File
@@ -0,0 +1,110 @@
# قیمت‌گذاری ویزیت بر اساس بیمه در پروفایل کلینیک/مطب
## پروژه
`clinicpro` (backend + admin frontend)
## زمینه
در حال حاضر قیمت ویزیت پزشک نسبت به بیمه فقط با موجودیت `DoctorInsurance` ذخیره می‌شود که **یک فیلد قیمت تکی** (`price`) دارد:
```php
// src/Insurance/Entity/DoctorInsurance.php
#[ORM\Column(type: 'integer', nullable: true)]
private ?int $price = null;
```
این کافی نیست. کاربر (کلینیک یا مطب شخصی) باید بتواند در پروفایل خود مشخص کند:
- مبلغ **ویزیت آزاد** (بدون بیمه) چقدر است
- اگر **بیمه پایه** اعمال شود، مبلغ نهایی/سهم بیمار چقدر می‌شود
- اگر علاوه بر آن **بیمه مکمل** هم اضافه شود، مبلغ نهایی چقدر می‌شود
## مشکل / هدف
افزودن یک ساختار قیمت‌گذاری ویزیت بر اساس بیمه در پروفایل entity (هم `doctor` و هم `clinic`)، با سه لایه:
1. **قیمت پایه آزاد** (free / بدون بیمه)
2. **سهم/مبلغ با بیمه پایه** — به ازای هر بیمه پایه‌ای که entity می‌پذیرد
3. **سهم/مبلغ با بیمه مکمل** — به ازای هر بیمه مکمل
این مقادیر بعداً در «ثبت مراجعه جدید» (پرامپت `new-visit-modal-ux.md`) برای پر کردن خودکار قیمت ویزیت و درصد/مبلغ تخفیف استفاده می‌شوند.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Insurance/Entity/DoctorInsurance.php` | رابطه فعلی پزشک↔بیمه با یک قیمت |
| `src/Insurance/Entity/Insurance.php` | موجودیت بیمه؛ دارای `type` (enum `InsuranceType`) |
| `src/Insurance/Enum/InsuranceType.php` | نوع بیمه (پایه/مکمل) — بخوان و مقادیر را تأیید کن |
| `src/Insurance/Controller/InsuranceController.php` | CRUD بیمه + `DoctorInsurance` (از خط ۱۸۶) |
| `src/Insurance/Repository/DoctorInsuranceRepository.php` | کوئری‌ها |
| `assets/admin/pages/MyClinicPage.tsx` و `DoctorProfilePage.tsx` | پروفایل کلینیک/مطب در پنل |
| `docs/api/insurance.md` | مستندات API بیمه |
## وضعیت فعلی
`DoctorInsurance.toArray()`:
```php
return [
'id' => $this->id,
'doctor_id' => $this->doctor->getId(),
'insurance_id' => $this->insurance->getId(),
'insurance_name' => $this->insurance->getName(),
'type' => $this->insurance->getType()->value,
'price' => $this->price,
];
```
- فقط برای `doctor` است؛ معادل `clinic` وجود ندارد.
- فقط یک `price` تکی دارد؛ تفکیک آزاد/پایه/مکمل ندارد.
## وظایف
### ۱. تحلیل و طراحی مدل قیمت‌گذاری
ابتدا `InsuranceType` و `DoctorInsurance` و `InsuranceController` (بخش DoctorInsurance) را کامل بخوان. سپس تصمیم بگیر:
- آیا «ویزیت آزاد» یک فیلد روی پروفایل entity است (یک مقدار) و هر `DoctorInsurance` فقط مبلغ سهم بیمار با آن بیمه را نگه می‌دارد؟ (پیشنهاد: بله)
- آیا باید برای `clinic` هم معادل `DoctorInsurance` ساخت (مثلاً عمومی‌سازی به entity-type/entity-id مثل `PatientRecord`)، یا یک موجودیت جدید `EntityInsurancePricing(entityType, entityId, insuranceId, patientShareRials)` ساخت؟
> توصیه: یک موجودیت polymorphic جدید `EntityInsurancePricing` با کلیدهای `entity_type` (`doctor|clinic`)، `entity_id`، `insurance_id`، `patient_share_rials` بساز و `DoctorInsurance` را به‌مرور کنار بگذار (اما داده‌ی فعلی را migrate کن). قیمت ویزیت آزاد را روی پروفایل entity (یک کلید config یا ستون) نگه‌دار.
طرح نهایی را قبل از پیاده‌سازی به‌صورت یک پاراگراف توضیح بده.
### ۲. Entity + Migration
- موجودیت/فیلدهای جدید را بساز (طبق طرح مرحله ۱).
- مقدار «ویزیت آزاد» را برای entity ذخیره کن.
- `doctrine:migrations:diff` سپس `migrate`.
- داده‌ی موجود `doctor_insurances.price` را به مدل جدید migrate کن (در همان migration یا یک migration داده‌ای).
### ۳. API
Endpointها برای خواندن/ذخیره قیمت‌گذاری بیمه‌ی entity جاری (از `#[CurrentUser]` → resolve به `doctor` یا `clinic` مثل الگوی `PatientController::resolveEntity`):
- `GET /api/v1/insurance-pricing` — لیست قیمت‌گذاری entity جاری + قیمت ویزیت آزاد
- `PUT|PATCH /api/v1/insurance-pricing` — ذخیره‌ی قیمت آزاد + آرایه‌ی سهم هر بیمه
از `BaseController` و helperهای `$this->success()` / `$this->error()` استفاده کن.
### ۴. Admin Frontend
در `MyClinicPage.tsx` و `DoctorProfilePage.tsx` یک بخش «قیمت‌گذاری ویزیت بر اساس بیمه» اضافه کن:
- ورودی «مبلغ ویزیت آزاد (ریال)»
- جدول/لیست بیمه‌های پایه و مکمل (از `GET /api/v1/insurances`) با یک ورودی مبلغ سهم بیمار به ازای هر کدام
- پیش‌نمایش: «آزاد: X — با بیمه پایه Y: Z — با بیمه مکمل W: …»
- ذخیره با TanStack Query `useMutation`
### ۵. مستندات
`docs/api/insurance.md` را با endpointهای جدید (method/path/permission، body کامل، نمونه پاسخ JSON واقعی، error codeها) به‌روز کن.
## نکات مهم
- entity جاری از `#[CurrentUser]` resolve می‌شود؛ الگو را از `PatientController::resolveEntity` بگیر (نقش `ROLE_DOCTOR` → doctor، `ROLE_CLINIC` → clinic).
- تاریخ‌ها/قیمت‌ها همه integer ریال هستند.
- این پرامپت پیش‌نیاز `new-visit-modal-ux.md` است؛ خروجی قیمت‌ها آنجا مصرف می‌شود.
- مهاجرت داده‌ی `DoctorInsurance` نباید قیمت‌های موجود را از بین ببرد.
@@ -0,0 +1,98 @@
# مشخص‌کردن شمول بیمه برای هر خدمت
## پروژه
`clinicpro` (backend + admin frontend)
## زمینه
هر خدمت کلینیک با موجودیت `ServiceItem` نگه‌داری می‌شود. در حال حاضر هیچ فیلدی ندارد که بگوید این خدمت **شامل بیمه می‌شود یا نه**.
## مشکل / هدف
افزودن قابلیت تعیین «شمول بیمه» به هر `ServiceItem`:
- یک پرچم: آیا این خدمت شامل بیمه می‌شود؟
- اگر شامل می‌شود، اطلاعات لازم برای محاسبه‌ی درست (مثلاً سهم بیمار / درصد پوشش / لیست بیمه‌های پذیرفته‌شده) ذخیره و نمایش داده شود.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/ClinicService/Entity/ServiceItem.php` | موجودیت خدمت |
| `src/ClinicService/Controller/ClinicServiceController.php` | CRUD خدمات/بخش‌ها |
| `src/ClinicService/Repository/ServiceItemRepository.php` | کوئری‌ها |
| `assets/admin/pages/ClinicServicesPage.tsx` | صفحه‌ی مدیریت خدمات در پنل |
| `src/Patient/Service/PatientService.php` | محاسبه‌ی قیمت مراجعه (مصرف‌کننده‌ی خدمت) |
| `docs/api/clinic-services.md` | مستندات API خدمات |
## وضعیت فعلی
`ServiceItem` فیلدهای موجود:
```php
private string $name;
#[ORM\Column(name: 'price_rials', type: 'integer')]
private int $priceRials = 0;
#[ORM\Column(type: 'boolean')]
private bool $active = true;
```
`toArray()`:
```php
return [
'uuid' => $this->uuid,
'section_uuid' => $this->section->getUuid(),
'staff_uuid' => $this->staff?->getUuid(),
'staff_name' => $this->staff?->getFullName(),
'name' => $this->name,
'price_rials' => $this->priceRials,
'active' => $this->active,
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt,
];
```
هیچ مفهوم بیمه‌ای ندارد.
## وظایف
### ۱. طراحی فیلد شمول بیمه
تصمیم بگیر مدل داده چه باشد:
- ساده: یک `bool $insuranceCovered = false`.
- کامل‌تر: `bool $insuranceCovered` + `?int $patientShareRials` (سهم بیمار وقتی بیمه اعمال شود) یا `?float $coveragePercent`.
> توصیه: `insuranceCovered` (bool) + `?int $insurancePriceRials` (قیمت/سهم بیمار با بیمه). اگر `insuranceCovered=false`، فیلد دوم نادیده گرفته شود.
طرح را قبل از پیاده‌سازی توضیح بده.
### ۲. Entity + Migration
- فیلد(ها) را به `ServiceItem` اضافه کن + getter/setter.
- در `toArray()` اضافه کن.
- `doctrine:migrations:diff` سپس `migrate`.
### ۳. API
`ClinicServiceController` (create/update خدمت) باید فیلدهای جدید را از body بپذیرد و ذخیره کند. validation مناسب (اگر `insurance_covered=true` و قیمت بیمه خالی، خطا یا صفر منطقی).
### ۴. Admin Frontend
در `ClinicServicesPage.tsx` فرم خدمت:
- یک toggle/checkbox «شامل بیمه می‌شود»
- وقتی روشن شد، ورودی «قیمت با بیمه (ریال)» نمایش داده شود
- در لیست خدمات، یک badge نشان دهد خدمت شامل بیمه است یا خیر
### ۵. مستندات
`docs/api/clinic-services.md` را با فیلدهای جدید در request/response به‌روز کن.
## نکات مهم
- قیمت‌ها integer ریال.
- این فیلد بعداً در محاسبه‌ی قیمت مراجعه (`PatientService::createSession`) قابل استفاده است؛ در این پرامپت فقط ذخیره/نمایش کافی است مگر اینکه ساده باشد همان‌جا هم اعمال شود.
- الگوی frontend خدمات موجود را رعایت کن (همان فرم/modal فعلی).
+4
View File
@@ -30,6 +30,8 @@ import MyClinicPage from './pages/MyClinicPage';
import SettingsPage from './pages/SettingsPage';
import DoctorProfilePage from './pages/DoctorProfilePage';
import MyPatientsPage from './pages/MyPatientsPage';
import InsurancePricingPage from './pages/InsurancePricingPage';
import ClaimsPage from './pages/ClaimsPage';
import MyFinancialPage from './pages/MyFinancialPage';
import ClinicFormPage from './pages/ClinicFormPage';
import PreRegistrationsPage from './pages/PreRegistrationsPage';
@@ -168,6 +170,8 @@ export default function App() {
{/* دکتر / منشی / کلینیک */}
<Route path="my-patients" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><MyPatientsPage /></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>} />
{/* فاز ۲ — دکتر / کلینیک */}
@@ -0,0 +1,169 @@
import { useEffect, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api } from '../lib/api';
import { formatRial } from '../lib/utils';
interface InsuranceRow {
insurance_id: number;
insurance_name: string;
type: string;
patient_share_rials: number | null;
}
interface PricingResponse {
free_visit_price_rials: number;
insurances: InsuranceRow[];
}
const TYPE_LABEL: Record<string, string> = {
basic: 'بیمه پایه',
supplementary: 'بیمه تکمیلی',
};
export default function InsurancePricingSection() {
const qc = useQueryClient();
const [freeVisit, setFreeVisit] = useState('');
const [shares, setShares] = useState<Record<number, string>>({});
const { data, isLoading } = useQuery<{ data: PricingResponse }>({
queryKey: ['insurance-pricing'],
queryFn: () => api.get('/api/v1/insurance-pricing'),
});
const pricing = (data as any)?.data as PricingResponse | undefined;
useEffect(() => {
if (!pricing) return;
setFreeVisit(String(pricing.free_visit_price_rials ?? 0));
const next: Record<number, string> = {};
pricing.insurances.forEach((i) => {
next[i.insurance_id] = i.patient_share_rials != null ? String(i.patient_share_rials) : '';
});
setShares(next);
}, [pricing]);
const saveMut = useMutation({
mutationFn: () =>
api.put('/api/v1/insurance-pricing', {
free_visit_price_rials: Number(freeVisit) || 0,
insurances: (pricing?.insurances ?? []).map((i) => ({
insurance_id: i.insurance_id,
patient_share_rials:
shares[i.insurance_id] === '' || shares[i.insurance_id] == null
? null
: Number(shares[i.insurance_id]) || 0,
})),
}),
onSuccess: () => {
toast.success('قیمت‌گذاری بیمه ذخیره شد');
qc.invalidateQueries({ queryKey: ['insurance-pricing'] });
},
onError: (e: Error) => toast.error(e.message),
});
const basics = (pricing?.insurances ?? []).filter((i) => i.type === 'basic');
const supps = (pricing?.insurances ?? []).filter((i) => i.type === 'supplementary');
const renderRow = (i: InsuranceRow) => {
const share = shares[i.insurance_id] ?? '';
const preview =
share !== '' && freeVisit !== ''
? `سهم بیمار: ${formatRial(Number(share) || 0)}`
: 'تعیین نشده';
return (
<div
key={i.insurance_id}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '10px 12px',
borderRadius: 10,
border: '1px solid var(--border)',
background: 'var(--surface)',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 13.5 }}>{i.insurance_name}</div>
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 2 }}>{preview}</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="number"
min={0}
dir="ltr"
className="input"
style={{ width: 150 }}
placeholder="سهم بیمار (ریال)"
value={share}
onChange={(e) =>
setShares((p) => ({ ...p, [i.insurance_id]: e.target.value }))
}
/>
</div>
</div>
);
};
return (
<div className="card" style={{ padding: 20 }}>
<div style={{ marginBottom: 14 }}>
<h2 style={{ fontSize: 14, fontWeight: 700, margin: 0 }}>قیمتگذاری ویزیت بر اساس بیمه</h2>
<p style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4, lineHeight: 1.7 }}>
مبلغ ویزیت آزاد و سهم بیمار به ازای هر بیمه را تعیین کنید. خالیگذاشتن یک بیمه یعنی پذیرفته نمیشود.
</p>
</div>
{isLoading ? (
<div className="muted" style={{ fontSize: 13 }}>در حال بارگذاری...</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
<div className="field" style={{ flexDirection: 'column', alignItems: 'stretch', height: 'auto', gap: 6, padding: 0, border: 'none', background: 'none' }}>
<label style={{ fontSize: 12.5, fontWeight: 600 }}>مبلغ ویزیت آزاد (ریال)</label>
<input
type="number"
min={0}
dir="ltr"
className="input"
style={{ maxWidth: 220 }}
value={freeVisit}
onChange={(e) => setFreeVisit(e.target.value)}
/>
<span style={{ fontSize: 11.5, color: 'var(--text-3)' }}>
{freeVisit !== '' ? formatRial(Number(freeVisit) || 0) : '—'}
</span>
</div>
{basics.length > 0 && (
<div>
<div style={{ fontSize: 12.5, fontWeight: 600, marginBottom: 8, color: 'var(--text-2)' }}>{TYPE_LABEL.basic}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{basics.map(renderRow)}</div>
</div>
)}
{supps.length > 0 && (
<div>
<div style={{ fontSize: 12.5, fontWeight: 600, marginBottom: 8, color: 'var(--text-2)' }}>{TYPE_LABEL.supplementary}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{supps.map(renderRow)}</div>
</div>
)}
{basics.length === 0 && supps.length === 0 && (
<div style={{ fontSize: 12.5, color: 'var(--text-3)' }}>بیمه فعالی در سیستم تعریف نشده است.</div>
)}
<div>
<button
className="btn primary sm"
disabled={saveMut.isPending}
onClick={() => saveMut.mutate()}
>
{saveMut.isPending ? 'در حال ذخیره...' : 'ذخیره قیمت‌گذاری'}
</button>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,94 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api } from '../lib/api';
import { formatRial, formatNumber } from '../lib/utils';
import Modal from './ui/Modal';
import type { ServiceItem } from '../types';
interface TariffRow {
uuid: string;
year: number;
price_rials: number;
is_active: boolean;
}
interface TariffResponse {
current_year: number;
default_price_rials: number;
data: TariffRow[];
}
export default function ServiceTariffModal({ item, onClose }: { item: ServiceItem | null; onClose: () => void }) {
const qc = useQueryClient();
const [year, setYear] = useState('');
const [price, setPrice] = useState('');
const { data, isLoading } = useQuery<{ data: TariffResponse }>({
queryKey: ['service-tariffs', item?.uuid],
queryFn: () => api.get(`/api/v1/service-items/${item!.uuid}/tariffs`),
enabled: !!item,
});
const resp = (data as any)?.data as TariffResponse | undefined;
const tariffs = resp?.data ?? [];
const currentYear = resp?.current_year;
const saveMut = useMutation({
mutationFn: () =>
api.put(`/api/v1/service-items/${item!.uuid}/tariffs/${Number(year)}`, {
price_rials: Number(price) || 0,
}),
onSuccess: () => {
toast.success('تعرفه ذخیره شد');
setYear('');
setPrice('');
qc.invalidateQueries({ queryKey: ['service-tariffs', item?.uuid] });
},
onError: (e: Error) => toast.error(e.message),
});
return (
<Modal open={!!item} onClose={onClose} title={`تعرفه‌های سالانه — ${item?.name ?? ''}`}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{resp && (
<div style={{ fontSize: 12.5, color: 'var(--text-3)', lineHeight: 1.7 }}>
قیمت پیشفرض خدمت: <b>{formatRial(resp.default_price_rials)}</b>. اگر تعرفهی سالی ثبت نشود، همین قیمت اعمال میشود.
</div>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'flex-end', padding: 12, borderRadius: 10, border: '1px solid var(--border)', background: 'var(--surface)' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<label style={{ fontSize: 11.5, fontWeight: 600 }}>سال (شمسی)</label>
<input type="number" dir="ltr" className="input" style={{ width: 100 }} placeholder={currentYear ? String(currentYear) : '۱۴۰۴'} value={year} onChange={(e) => setYear(e.target.value)} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<label style={{ fontSize: 11.5, fontWeight: 600 }}>تعرفه (ریال)</label>
<input type="number" min={0} dir="ltr" className="input" style={{ width: 150 }} value={price} onChange={(e) => setPrice(e.target.value)} />
</div>
<button className="btn primary sm" disabled={!year || saveMut.isPending} onClick={() => saveMut.mutate()}>
{saveMut.isPending ? '...' : 'ذخیره'}
</button>
</div>
{isLoading ? (
<div className="muted" style={{ fontSize: 13 }}>در حال بارگذاری...</div>
) : tariffs.length === 0 ? (
<div style={{ fontSize: 12.5, color: 'var(--text-3)' }}>هنوز تعرفهی سالانهای ثبت نشده است.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{tariffs.map((t) => (
<div key={t.uuid} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 12px', borderRadius: 8, border: '1px solid var(--border)' }}>
<span style={{ fontWeight: 600, fontSize: 13 }}>
سال {formatNumber(t.year)}
{t.year === currentYear && <span className="badge green" style={{ fontSize: 10, marginInlineStart: 6 }}><span className="bdot" />جاری</span>}
</span>
<span style={{ color: 'var(--primary)', fontWeight: 600, fontSize: 13 }}>{formatRial(t.price_rials)}</span>
</div>
))}
</div>
)}
</div>
</Modal>
);
}
@@ -0,0 +1,164 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { TrashIcon, PlusIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import { formatRial } from '../lib/utils';
interface Contract {
uuid: string;
insurance_id: number;
insurance_name: string | null;
insurance_kind: string | null;
version: number;
coverage_percent: number;
franchise_rials: number;
annual_ceiling_rials: number | null;
}
interface InsuranceOption {
insurance_id: number;
insurance_name: string;
type: string;
}
const KIND_LABEL: Record<string, string> = {
basic: 'پایه',
supplementary: 'تکمیلی',
};
export default function TenantInsuranceContracts() {
const qc = useQueryClient();
const [addOpen, setAddOpen] = useState(false);
const [insuranceId, setInsuranceId] = useState('');
const [coverage, setCoverage] = useState('');
const [franchise, setFranchise] = useState('');
const [ceiling, setCeiling] = useState('');
const contractsQuery = useQuery<{ data: Contract[] }>({
queryKey: ['tenant-insurances'],
queryFn: () => api.get('/api/v1/billing/tenant-insurances'),
});
const pricingQuery = useQuery<{ data: { insurances: InsuranceOption[] } }>({
queryKey: ['insurance-pricing'],
queryFn: () => api.get('/api/v1/insurance-pricing'),
});
const contracts = (contractsQuery.data as any)?.data?.data ?? [];
const allInsurances: InsuranceOption[] = (pricingQuery.data as any)?.data?.insurances ?? [];
const activeIds = new Set(contracts.map((c: Contract) => c.insurance_id));
const available = allInsurances.filter((i) => !activeIds.has(i.insurance_id));
const resetForm = () => {
setInsuranceId('');
setCoverage('');
setFranchise('');
setCeiling('');
setAddOpen(false);
};
const addMut = useMutation({
mutationFn: () =>
api.post('/api/v1/billing/tenant-insurances', {
insurance_id: Number(insuranceId),
coverage_percent: Number(coverage) || 0,
franchise_rials: Number(franchise) || 0,
annual_ceiling_rials: ceiling === '' ? null : Number(ceiling),
}),
onSuccess: () => {
toast.success('قرارداد بیمه فعال شد');
resetForm();
qc.invalidateQueries({ queryKey: ['tenant-insurances'] });
},
onError: (e: Error) => toast.error(e.message),
});
const delMut = useMutation({
mutationFn: (uuid: string) => api.delete(`/api/v1/billing/tenant-insurances/${uuid}`),
onSuccess: () => {
toast.success('قرارداد غیرفعال شد');
qc.invalidateQueries({ queryKey: ['tenant-insurances'] });
},
onError: (e: Error) => toast.error(e.message),
});
return (
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 14 }}>
<div>
<h2 style={{ fontSize: 14, fontWeight: 700, margin: 0 }}>قراردادهای بیمه</h2>
<p style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4, lineHeight: 1.7 }}>
بیمههایی که با آنها قرارداد دارید. درصد پوشش، فرانشیز و سقف تعهد هر بیمه را تعیین کنید.
</p>
</div>
{!addOpen && available.length > 0 && (
<button className="btn primary sm" onClick={() => setAddOpen(true)}>
<PlusIcon style={{ width: 14 }} /> افزودن بیمه
</button>
)}
</div>
{addOpen && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'flex-end', padding: 14, borderRadius: 10, border: '1px solid var(--border)', background: 'var(--surface)', marginBottom: 14 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<label style={{ fontSize: 11.5, fontWeight: 600 }}>بیمه</label>
<select className="input" style={{ minWidth: 160 }} value={insuranceId} onChange={(e) => setInsuranceId(e.target.value)}>
<option value="">انتخاب...</option>
{available.map((i) => (
<option key={i.insurance_id} value={i.insurance_id}>{i.insurance_name} ({KIND_LABEL[i.type] ?? i.type})</option>
))}
</select>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<label style={{ fontSize: 11.5, fontWeight: 600 }}>درصد پوشش</label>
<input type="number" min={0} max={100} dir="ltr" className="input" style={{ width: 100 }} value={coverage} onChange={(e) => setCoverage(e.target.value)} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<label style={{ fontSize: 11.5, fontWeight: 600 }}>فرانشیز (ریال)</label>
<input type="number" min={0} dir="ltr" className="input" style={{ width: 130 }} value={franchise} onChange={(e) => setFranchise(e.target.value)} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<label style={{ fontSize: 11.5, fontWeight: 600 }}>سقف تعهد (ریال)</label>
<input type="number" min={0} dir="ltr" className="input" style={{ width: 130 }} placeholder="بی‌نهایت" value={ceiling} onChange={(e) => setCeiling(e.target.value)} />
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button className="btn primary sm" disabled={!insuranceId || addMut.isPending} onClick={() => addMut.mutate()}>
{addMut.isPending ? '...' : 'ذخیره'}
</button>
<button className="btn ghost sm" onClick={resetForm}>لغو</button>
</div>
</div>
)}
{contractsQuery.isLoading ? (
<div className="muted" style={{ fontSize: 13 }}>در حال بارگذاری...</div>
) : contracts.length === 0 ? (
<div style={{ fontSize: 12.5, color: 'var(--text-3)', padding: '12px 0' }}>
هنوز با هیچ بیمهای قرارداد فعال ندارید.
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{contracts.map((c: Contract) => (
<div key={c.uuid} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 12px', borderRadius: 10, border: '1px solid var(--border)' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 13.5 }}>
{c.insurance_name ?? `#${c.insurance_id}`}
{c.insurance_kind && <span className="badge gray" style={{ fontSize: 10, marginInlineStart: 6 }}>{KIND_LABEL[c.insurance_kind] ?? c.insurance_kind}</span>}
</div>
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 2 }}>
پوشش {c.coverage_percent}٪
{c.franchise_rials > 0 && ` · فرانشیز ${formatRial(c.franchise_rials)}`}
{c.annual_ceiling_rials != null && ` · سقف ${formatRial(c.annual_ceiling_rials)}`}
</div>
</div>
<button className="mini-btn danger" title="غیرفعال‌سازی" disabled={delMut.isPending} onClick={() => delMut.mutate(c.uuid)}>
<TrashIcon style={{ width: 15 }} />
</button>
</div>
))}
</div>
)}
</div>
);
}
@@ -16,6 +16,7 @@ import {
IdentificationIcon,
KeyIcon,
LockClosedIcon,
ShieldCheckIcon,
StarIcon,
TagIcon,
UserCircleIcon,
@@ -192,6 +193,16 @@ function buildSections(
label: "پرونده بیماران",
feature: "patient_records",
},
{
to: "/admin/insurance-pricing",
icon: ShieldCheckIcon,
label: "قیمت‌گذاری بیمه",
},
{
to: "/admin/claims",
icon: DocumentTextIcon,
label: "مطالبات بیمه",
},
{ to: "/admin/staff", icon: UserPlusIcon, label: "پرسنل" },
{
to: "/admin/my-secretaries",
@@ -261,6 +272,16 @@ function buildSections(
label: "پرونده بیماران",
feature: "patient_records",
},
{
to: "/admin/insurance-pricing",
icon: ShieldCheckIcon,
label: "قیمت‌گذاری بیمه",
},
{
to: "/admin/claims",
icon: DocumentTextIcon,
label: "مطالبات بیمه",
},
{ to: "/admin/staff", icon: UserPlusIcon, label: "پرسنل" },
{
to: "/admin/my-secretaries",
@@ -320,6 +341,16 @@ function buildSections(
label: "پرونده بیماران",
feature: "patient_records",
},
{
to: "/admin/insurance-pricing",
icon: ShieldCheckIcon,
label: "قیمت‌گذاری بیمه",
},
{
to: "/admin/claims",
icon: DocumentTextIcon,
label: "مطالبات بیمه",
},
],
},
];
+187
View File
@@ -0,0 +1,187 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { CheckIcon, XMarkIcon, PaperAirplaneIcon, BanknotesIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import { formatRial, formatNumber } from '../lib/utils';
import PageHeader from '../components/ui/PageHeader';
import Modal from '../components/ui/Modal';
interface ClaimItem {
invoice_item_id: number;
claimed_rials: number;
approved_rials: number | null;
}
interface Claim {
uuid: string;
insurance_id: number;
insurance_kind: string;
total_claimed_rials: number;
total_approved_rials: number | null;
total_paid_rials: number | null;
status: string;
reject_reason: string | null;
items: ClaimItem[];
}
interface DebtRow {
insurance_id: number;
claimed: number;
approved: number;
paid: number;
debt: number;
}
const STATUS_META: Record<string, { label: string; cls: string }> = {
pending: { label: 'در انتظار', cls: 'gray' },
submitted: { label: 'ارسال‌شده', cls: 'blue' },
approved: { label: 'تأییدشده', cls: 'amber' },
rejected: { label: 'ردشده', cls: 'red' },
paid: { label: 'پرداخت‌شده', cls: 'green' },
};
const KIND_LABEL: Record<string, string> = { base: 'پایه', supplementary: 'تکمیلی' };
const STATUS_FILTERS = ['', 'pending', 'submitted', 'approved', 'rejected', 'paid'];
export default function ClaimsPage() {
const qc = useQueryClient();
const [statusFilter, setStatusFilter] = useState('');
const [rejectTarget, setRejectTarget] = useState<Claim | null>(null);
const [rejectReason, setRejectReason] = useState('');
const claimsQuery = useQuery<{ data: { data: Claim[] } }>({
queryKey: ['claims', statusFilter],
queryFn: () => api.get(`/api/v1/billing/claims${statusFilter ? `?status=${statusFilter}` : ''}`),
});
const debtQuery = useQuery<{ data: { data: DebtRow[] } }>({
queryKey: ['insurance-debt'],
queryFn: () => api.get('/api/v1/billing/reports/insurance-debt'),
});
const claims = (claimsQuery.data as any)?.data?.data ?? [];
const debt = (debtQuery.data as any)?.data?.data ?? [];
const transitionMut = useMutation({
mutationFn: ({ uuid, action, body }: { uuid: string; action: string; body?: object }) =>
api.post(`/api/v1/billing/claims/${uuid}/${action}`, body ?? {}),
onSuccess: () => {
toast.success('وضعیت مطالبه به‌روزرسانی شد');
setRejectTarget(null);
setRejectReason('');
qc.invalidateQueries({ queryKey: ['claims'] });
qc.invalidateQueries({ queryKey: ['insurance-debt'] });
},
onError: (e: Error) => toast.error(e.message),
});
return (
<div className="fade-in">
<PageHeader title="مطالبات بیمه" description="پیگیری مطالبات و بدهی بیمه‌ها" />
<div className="card" style={{ padding: 18, marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<BanknotesIcon style={{ width: 18, color: 'var(--text-3)' }} />
<h2 style={{ fontSize: 14, fontWeight: 700, margin: 0 }}>بدهی بیمهها</h2>
</div>
{debtQuery.isLoading ? (
<div className="muted" style={{ fontSize: 13 }}>در حال بارگذاری...</div>
) : debt.length === 0 ? (
<div style={{ fontSize: 12.5, color: 'var(--text-3)' }}>بدهیای ثبت نشده است.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{debt.map((d: DebtRow) => (
<div key={d.insurance_id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 12px', borderRadius: 8, border: '1px solid var(--border)', fontSize: 13 }}>
<span style={{ fontWeight: 600 }}>بیمه #{formatNumber(d.insurance_id)}</span>
<span style={{ color: 'var(--text-3)' }}>ادعا {formatRial(d.claimed)} · پرداخت {formatRial(d.paid)}</span>
<span style={{ fontWeight: 700, color: d.debt > 0 ? 'var(--danger)' : 'var(--success, #16a34a)' }}>بدهی {formatRial(d.debt)}</span>
</div>
))}
</div>
)}
</div>
<div className="card" style={{ padding: 18 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h2 style={{ fontSize: 14, fontWeight: 700, margin: 0 }}>مطالبات</h2>
<select className="input" style={{ maxWidth: 160 }} value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
{STATUS_FILTERS.map((s) => (
<option key={s} value={s}>{s === '' ? 'همه وضعیت‌ها' : STATUS_META[s]?.label}</option>
))}
</select>
</div>
{claimsQuery.isLoading ? (
<div className="muted" style={{ fontSize: 13 }}>در حال بارگذاری...</div>
) : claims.length === 0 ? (
<div style={{ fontSize: 12.5, color: 'var(--text-3)', padding: '12px 0' }}>مطالبهای یافت نشد.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{claims.map((c: Claim) => {
const meta = STATUS_META[c.status] ?? { label: c.status, cls: 'gray' };
return (
<div key={c.uuid} style={{ padding: '12px 14px', borderRadius: 10, border: '1px solid var(--border)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 }}>
<div>
<div style={{ fontWeight: 600, fontSize: 13.5 }}>
بیمه #{formatNumber(c.insurance_id)}
<span className="badge gray" style={{ fontSize: 10, marginInlineStart: 6 }}>{KIND_LABEL[c.insurance_kind] ?? c.insurance_kind}</span>
<span className={`badge ${meta.cls}`} style={{ fontSize: 10, marginInlineStart: 4 }}><span className="bdot" />{meta.label}</span>
</div>
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 3 }}>
ادعا {formatRial(c.total_claimed_rials)}
{c.total_approved_rials != null && ` · تأیید ${formatRial(c.total_approved_rials)}`}
{c.total_paid_rials != null && ` · پرداخت ${formatRial(c.total_paid_rials)}`}
{c.reject_reason && ` · دلیل رد: ${c.reject_reason}`}
</div>
</div>
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
{c.status === 'pending' && (
<button className="btn primary sm" disabled={transitionMut.isPending} onClick={() => transitionMut.mutate({ uuid: c.uuid, action: 'submit' })}>
<PaperAirplaneIcon style={{ width: 13 }} /> ارسال
</button>
)}
{c.status === 'submitted' && (
<>
<button className="btn primary sm" disabled={transitionMut.isPending} onClick={() => transitionMut.mutate({ uuid: c.uuid, action: 'approve' })}>
<CheckIcon style={{ width: 13 }} /> تأیید
</button>
<button className="btn danger sm" onClick={() => { setRejectTarget(c); setRejectReason(''); }}>
<XMarkIcon style={{ width: 13 }} /> رد
</button>
</>
)}
{c.status === 'approved' && (
<button className="btn primary sm" disabled={transitionMut.isPending} onClick={() => transitionMut.mutate({ uuid: c.uuid, action: 'pay' })}>
<BanknotesIcon style={{ width: 13 }} /> پرداخت
</button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
<Modal open={!!rejectTarget} onClose={() => setRejectTarget(null)} title="رد مطالبه"
footer={
<>
<button className="btn ghost sm" onClick={() => setRejectTarget(null)}>انصراف</button>
<button className="btn danger sm" disabled={!rejectReason || transitionMut.isPending}
onClick={() => rejectTarget && transitionMut.mutate({ uuid: rejectTarget.uuid, action: 'reject', body: { reason: rejectReason } })}>
رد کردن
</button>
</>
}
>
<div className="field">
<label>دلیل رد</label>
<textarea className="input" rows={3} dir="rtl" value={rejectReason} onChange={(e) => setRejectReason(e.target.value)} placeholder="دلیل رد را بنویسید..." />
</div>
</Modal>
</div>
);
}
+44 -7
View File
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { PlusIcon, PencilIcon, TrashIcon, WrenchScrewdriverIcon } from '@heroicons/react/24/outline';
import { PlusIcon, PencilIcon, TrashIcon, WrenchScrewdriverIcon, BanknotesIcon } from '@heroicons/react/24/outline';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@@ -13,14 +13,17 @@ import Modal from '../components/ui/Modal';
import PriceInput from '../components/ui/PriceInput';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import PageHeader from '../components/ui/PageHeader';
import ServiceTariffModal from '../components/ServiceTariffModal';
import SearchableSelect from '../components/ui/SearchableSelect';
import FeatureGate from '../components/ui/FeatureGate';
const sectionSchema = z.object({ name: z.string().min(1, 'نام بخش الزامی است') });
const itemSchema = z.object({
name: z.string().min(1, 'نام سرویس الزامی است'),
price_rials: z.coerce.number().min(0, 'مبلغ نمی‌تواند منفی باشد'),
staff_uuid: z.string().optional(),
name: z.string().min(1, 'نام سرویس الزامی است'),
price_rials: z.coerce.number().min(0, 'مبلغ نمی‌تواند منفی باشد'),
staff_uuid: z.string().optional(),
insurance_covered: z.boolean().optional(),
insurance_price_rials: z.coerce.number().min(0).optional(),
});
type SectionForm = z.infer<typeof sectionSchema>;
type ItemForm = z.infer<typeof itemSchema>;
@@ -36,6 +39,7 @@ function ClinicServicesPageInner() {
const [deleteSection, setDeleteSection] = useState<ServiceSection | null>(null);
const [itemModal, setItemModal] = useState<'create' | ServiceItem | null>(null);
const [deleteItem, setDeleteItem] = useState<ServiceItem | null>(null);
const [tariffItem, setTariffItem] = useState<ServiceItem | null>(null);
const { data: sectionsData, isLoading: sectionsLoading } = useQuery<ApiResponse<ServiceSection[]>>({
queryKey: ['service-sections'],
@@ -123,6 +127,8 @@ function ClinicServicesPageInner() {
name: item.name,
price_rials: item.price_rials,
staff_uuid: item.staff?.uuid ?? '',
insurance_covered: item.insurance_covered ?? false,
insurance_price_rials: item.insurance_price_rials ?? 0,
});
setItemModal(item);
};
@@ -234,7 +240,7 @@ function ClinicServicesPageInner() {
<button
className="btn primary sm"
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
onClick={() => { itemForm.reset({ price_rials: 0, staff_uuid: '' }); setItemModal('create'); }}
onClick={() => { itemForm.reset({ price_rials: 0, staff_uuid: '', insurance_covered: false, insurance_price_rials: 0 }); setItemModal('create'); }}
>
<PlusIcon style={{ width: 14 }} /> سرویس جدید
</button>
@@ -249,7 +255,7 @@ function ClinicServicesPageInner() {
<button
className="btn primary sm"
style={{ marginTop: 12 }}
onClick={() => { itemForm.reset({ price_rials: 0, staff_uuid: '' }); setItemModal('create'); }}
onClick={() => { itemForm.reset({ price_rials: 0, staff_uuid: '', insurance_covered: false, insurance_price_rials: 0 }); setItemModal('create'); }}
>
افزودن سرویس
</button>
@@ -273,7 +279,14 @@ function ClinicServicesPageInner() {
background: idx % 2 === 1 ? 'oklch(0.985 0.005 256)' : 'transparent',
}}
>
<td style={{ padding: '11px 16px', fontWeight: 500 }}>{item.name}</td>
<td style={{ padding: '11px 16px', fontWeight: 500 }}>
{item.name}
{item.insurance_covered && (
<span className="badge green" style={{ fontSize: 10, marginInlineStart: 6 }}>
<span className="bdot" />بیمه
</span>
)}
</td>
<td style={{ padding: '11px 16px', color: 'var(--primary)', fontWeight: 600 }}>
{formatRial(item.price_rials)}
</td>
@@ -299,6 +312,9 @@ function ClinicServicesPageInner() {
<button className="btn sm" onClick={() => openEditItem(item)} title="ویرایش">
<PencilIcon style={{ width: 13 }} />
</button>
<button className="btn sm" onClick={() => setTariffItem(item)} title="تعرفه‌های سالانه">
<BanknotesIcon style={{ width: 13 }} />
</button>
<button className="btn sm" onClick={() => setDeleteItem(item)} title="حذف">
<TrashIcon style={{ width: 13 }} />
</button>
@@ -378,6 +394,25 @@ function ClinicServicesPageInner() {
isClearable
/>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 13.5 }}>
<input
type="checkbox"
checked={itemForm.watch('insurance_covered') ?? false}
onChange={(e) => itemForm.setValue('insurance_covered', e.target.checked)}
/>
این خدمت شامل بیمه میشود
</label>
{itemForm.watch('insurance_covered') && (
<div className="field">
<label>قیمت با بیمه (ریال)</label>
<PriceInput
value={itemForm.watch('insurance_price_rials') ?? 0}
onChange={(v) => itemForm.setValue('insurance_price_rials', v)}
placeholder="سهم بیمار با بیمه"
min={0}
/>
</div>
)}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
<button type="submit" className="btn primary" disabled={createItem.isPending || editItem.isPending}>ذخیره</button>
@@ -386,6 +421,8 @@ function ClinicServicesPageInner() {
</form>
</Modal>
<ServiceTariffModal item={tariffItem} onClose={() => setTariffItem(null)} />
{/* Confirm حذف بخش */}
<ConfirmDialog
open={!!deleteSection}
@@ -0,0 +1,16 @@
import PageHeader from '../components/ui/PageHeader';
import InsurancePricingSection from '../components/InsurancePricingSection';
import TenantInsuranceContracts from '../components/TenantInsuranceContracts';
export default function InsurancePricingPage() {
return (
<div className="fade-in">
<PageHeader
title="بیمه و قیمت‌گذاری"
description="قراردادهای بیمه، درصد پوشش و مبلغ ویزیت"
/>
<TenantInsuranceContracts />
<InsurancePricingSection />
</div>
);
}
+2
View File
@@ -396,6 +396,8 @@ export interface ServiceItem {
price_rials: number;
staff: { uuid: string; full_name: string } | null;
active: boolean;
insurance_covered?: boolean;
insurance_price_rials?: number | null;
}
export interface SmsWalletBalance {
+144
View File
@@ -0,0 +1,144 @@
# Billing API — صورتحساب (فاز ۴ سیستم صورتحساب/بیمه)
> **Prefix:** `/api/v1/billing`
> دامنه: `App\Billing`. مرجع معماری: `docs/architecture/insurance-billing-system.md`.
> tenant از `#[CurrentUser]` resolve می‌شود (`ROLE_DOCTOR`→doctor، `ROLE_CLINIC`→clinic).
صورتحساب (`Invoice`) از یک Encounter (`PatientSession`) ساخته می‌شود. برای هر آیتم سهم بیمه‌ی پایه، بیمه‌ی مکمل و بیمار با `BillingCalculator` محاسبه می‌شود:
- تعرفه‌ی خدمت از `Tariff` سال جاری (با fallback به `ServiceItem.priceRials`).
- قانون پوشش از قرارداد بیمه‌ی tenant (`TenantInsurance`) + override خدمت (`TenantServiceCoverage`).
- ترتیب محاسبه: کل → پوشش پایه (با سقف) → باقیمانده → پوشش مکمل → فرانشیز سهم بیمار.
نمونه: کل ۶۰۰٬۰۰۰ · پایه ۷۰٪ → ۴۲۰٬۰۰۰ · مکمل روی باقیمانده → ۱۲۰٬۰۰۰ · بیمار ۶۰٬۰۰۰.
---
## POST /api/v1/billing/invoices
ساخت صورتحساب از یک مراجعه. اگر صورتحساب برای آن مراجعه قبلاً ساخته شده، همان برگردانده می‌شود (idempotent).
**Permission:** `AUTH` (مالک مراجعه)
**Body:**
```json
{ "session_uuid": "…" }
```
**Response 201:**
```json
{
"success": true,
"data": {
"data": {
"uuid": "…",
"entity_type": "doctor",
"entity_id": 7,
"patient_session_id": 33,
"base_insurance_id": 3,
"supplementary_insurance_id": 9,
"total_rials": 600000,
"base_insurance_rials": 420000,
"supplementary_rials": 120000,
"patient_rials": 60000,
"status": "draft",
"issued_at": 1718900000,
"items": [
{
"uuid": "…",
"service_item_id": 12,
"title": "ویزیت",
"tariff_rials": 600000,
"quantity": 1,
"total_rials": 600000,
"base_insurance_rials": 420000,
"supplementary_rials": 120000,
"patient_rials": 60000
}
]
}
}
}
```
**Errors:** `422 ERR_VALIDATION_001` session_uuid الزامی · `404 ERR_NOT_FOUND_001` مراجعه یافت نشد · `403 ERR_FORBIDDEN_001` پروفایل یافت نشد.
---
## GET /api/v1/billing/invoices/{uuid}
دریافت صورتحساب (فقط مالک tenant).
**Response 200:** همان ساختار بالا.
**Errors:** `404 ERR_NOT_FOUND_001`.
---
## POST /api/v1/billing/invoices/{uuid}/finalize
نهایی‌سازی صورتحساب (`draft``finalized`). صورتحساب نهایی‌شده مبنای ساخت Claim (فاز ۵) است.
**Response 200:** صورتحساب با `status: "finalized"`.
**Errors:** `404 ERR_NOT_FOUND_001`.
---
## وضعیت‌های Invoice
`draft` (پیش‌نویس، قابل بازسازی) → `finalized` (نهایی) → `paid` (پرداخت‌شده) · `void` (باطل).
## نکات
- پول: integer ریال. تاریخ: Unix timestamp.
- `BillingCalculator` خالص و واحد-تست‌شده است (`tests/Billing/BillingCalculatorTest.php`).
- این فاز جایگزین تدریجی `PatientService::calculateFinalPrice` است؛ آن متد فعلاً برای سازگاری باقی مانده.
---
# Claims — مطالبات بیمه (فاز ۵)
مطالبه (`Claim`) از یک صورتحساب **نهایی‌شده** ساخته می‌شود: یک Claim برای بیمه‌ی پایه و یک Claim برای بیمه‌ی مکمل (فقط اگر سهم بیمه > ۰). هر `ClaimItem` به یک `InvoiceItem` ارجاع می‌دهد. چرخه‌ی وضعیت با state machine.
**وضعیت‌ها:** `pending → submitted → {approved → paid | rejected}`. از `paid`/`rejected` خروجی ندارد.
## POST /api/v1/billing/claims
ساخت مطالبات از یک صورتحساب نهایی‌شده.
**Body:** `{ "invoice_uuid": "…" }`
**Response 201:** `{ success, data: [ …claims ] }` (یک یا دو مطالبه: پایه/مکمل).
**Errors:** `422` صورتحساب نهایی نشده / سهم بیمه ندارد · `404` صورتحساب یافت نشد.
## GET /api/v1/billing/claims?status=
لیست مطالبات tenant. فیلتر اختیاری `status` (`pending|submitted|approved|rejected|paid`).
## POST /api/v1/billing/claims/{uuid}/{action}
انتقال وضعیت. `action``submit|approve|reject|pay`.
| action | body اختیاری | اثر |
|--------|--------------|-----|
| submit | — | pending → submitted |
| approve | `approved_rials` | submitted → approved (پیش‌فرض = کل ادعا) |
| reject | `reason` (الزامی) | submitted → rejected |
| pay | `paid_rials` | approved → paid (پیش‌فرض = approved) |
**Errors:** `422` انتقال نامعتبر یا دلیل رد خالی · `404` مطالبه یافت نشد.
## GET /api/v1/billing/reports/insurance-debt
گزارش بدهی بیمه‌ها برای tenant (group بر اساس بیمه).
**Response 200:**
```json
{
"success": true,
"data": {
"data": [
{ "insurance_id": 3, "claimed": 840000, "approved": 800000, "paid": 500000, "debt": 340000 }
]
}
}
```
`debt = claimed - paid` (حداقل صفر).
---
## ارسال مطالبه (ClaimSubmitter)
عملِ `submit` از طریق interface `App\Billing\Contract\ClaimSubmitterInterface` انجام می‌شود. پیاده‌سازی پیش‌فرض `ManualClaimSubmitter` است (ارسال دستی/آفلاین — همیشه موفق). برای اتصال آینده به API شرکت‌های بیمه‌ی ایران کافی است یک پیاده‌سازی جدید از این interface ساخته و در `config/services.yaml` bind شود؛ `ClaimService` تغییر نمی‌کند (Dependency Inversion). اگر `submit` ناموفق باشد، انتقال وضعیت با `422` متوقف می‌شود.
+52 -3
View File
@@ -111,7 +111,9 @@
"section_uuid": "...",
"name": "رادیوگرافی مستقیم",
"price_rials": 500000,
"staff_uuid": "..."
"staff_uuid": "...",
"insurance_covered": true,
"insurance_price_rials": 200000
}
```
@@ -121,8 +123,10 @@
| name | string | ✅ |
| price_rials | integer | ❌ (پیش‌فرض 0) |
| staff_uuid | UUID | ❌ |
| insurance_covered | boolean | ❌ (پیش‌فرض false) — آیا خدمت شامل بیمه می‌شود |
| insurance_price_rials | integer\|null | ❌ — سهم/قیمت بیمار با بیمه |
**Response 201:** ServiceItem object
**Response 201:** ServiceItem object (شامل `insurance_covered` و `insurance_price_rials`)
---
@@ -135,7 +139,9 @@
"name": "رادیوگرافی دیجیتال",
"price_rials": 600000,
"staff_uuid": null,
"active": false
"active": false,
"insurance_covered": true,
"insurance_price_rials": 250000
}
```
@@ -160,3 +166,46 @@
| ERR_SUBSCRIPTION_REQUIRED | 403 | نیاز به پنل Basic+ |
| ERR_SERVICE_NOT_FOUND | 404 | سرویس یافت نشد |
| ERR_SERVICE_ITEM_IN_USE | 409 | سرویس در پرونده بیمار استفاده شده |
---
## تعرفه‌ی نسخه‌دار سالانه (Tariff) — فاز ۳ سیستم صورتحساب
هر خدمت می‌تواند برای هر سال شمسی یک تعرفه داشته باشد. اگر تعرفه‌ی سالی ثبت نشود، به `price_rials` خود خدمت fallback می‌شود (`TariffService::resolvePrice`). سال جاری شمسی سمت سرور با `IntlDateFormatter` (تقویم persian) محاسبه می‌شود.
### GET /api/v1/service-items/{uuid}/tariffs
لیست تعرفه‌های یک خدمت + قیمت پیش‌فرض + سال جاری.
**Permission:** `IS_AUTHENTICATED_FULLY` (مالک خدمت)
```json
{
"success": true,
"data": {
"current_year": 1405,
"default_price_rials": 500000,
"data": [
{ "uuid": "…", "service_item_id": 12, "year": 1405, "price_rials": 600000, "is_active": true },
{ "uuid": "…", "service_item_id": 12, "year": 1404, "price_rials": 500000, "is_active": true }
]
}
}
```
### PUT /api/v1/service-items/{uuid}/tariffs/{year}
ثبت/به‌روزرسانی تعرفه‌ی یک سال (upsert). `year` بین ۱۳۹۰ تا ۱۵۰۰.
**Body:**
```json
{ "price_rials": 600000 }
```
**Response 200:** `{ success, data: { …tariff } }`
**Errors:**
| Code | HTTP | توضیح |
|------|------|-------|
| ERR_SERVICE_NOT_FOUND | 404 | سرویس یافت نشد |
| ERR_VALIDATION_001 | 422 | سال نامعتبر |
+187
View File
@@ -265,3 +265,190 @@ Remove an insurance from a doctor's list.
```json
{ "success": true, "data": { "message": "بیمه از لیست حذف شد" } }
```
---
## EntityInsurancePricing — قیمت‌گذاری ویزیت بر اساس بیمه
قیمت‌گذاری ویزیت برای entity جاری (پزشک یا کلینیک)، با تفکیک:
- **ویزیت آزاد** (بدون بیمه) — یک مبلغ پایه (ردیفی با `insurance_id = null`)
- **سهم بیمار به ازای هر بیمه** پایه/مکمل
entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR``doctor`، نقش `ROLE_CLINIC``clinic`. ذخیره‌سازی polymorphic در جدول `entity_insurance_pricing` (`entity_type`, `entity_id`, `insurance_id` nullable, `patient_share_rials`).
---
## GET `/api/v1/insurance-pricing`
قیمت‌گذاری بیمه‌ی entity جاری + لیست همه‌ی بیمه‌های فعال (با سهم بیمار اگر تعیین شده).
**Permission:** `AUTH` (`ROLE_DOCTOR` یا `ROLE_CLINIC`)
### Response `200`
```json
{
"success": true,
"data": {
"entity_type": "doctor",
"entity_id": 7,
"free_visit_price_rials": 5000000,
"insurances": [
{
"insurance_id": 3,
"insurance_name": "تأمین اجتماعی",
"type": "basic",
"patient_share_rials": 1500000
},
{
"insurance_id": 9,
"insurance_name": "دانا",
"type": "supplementary",
"patient_share_rials": null
}
]
}
}
```
- `patient_share_rials = null` یعنی این بیمه پذیرفته نمی‌شود (قیمت‌گذاری ندارد).
### خطاها
- `403` `ERR_FORBIDDEN_001` — پروفایل (doctor/clinic) برای کاربر یافت نشد.
---
## PUT `/api/v1/insurance-pricing`
ذخیره/به‌روزرسانی قیمت ویزیت آزاد و سهم بیمار هر بیمه. عملیات upsert؛ ردیفی که `patient_share_rials = null` بفرستد حذف می‌شود.
**Permission:** `AUTH` (`ROLE_DOCTOR` یا `ROLE_CLINIC`)
### Request Body
```json
{
"free_visit_price_rials": 5000000,
"insurances": [
{ "insurance_id": 3, "patient_share_rials": 1500000 },
{ "insurance_id": 9, "patient_share_rials": null }
]
}
```
| فیلد | نوع | توضیح |
|------|-----|-------|
| `free_visit_price_rials` | int | مبلغ ویزیت آزاد (ریال). اختیاری؛ اگر نباشد تغییر نمی‌کند. |
| `insurances[].insurance_id` | int | شناسه‌ی بیمه (الزامی برای هر ردیف). |
| `insurances[].patient_share_rials` | int \| null | سهم بیمار با این بیمه. `null` → ردیف حذف می‌شود. |
### Response `200`
همان ساختار `GET /api/v1/insurance-pricing` (وضعیت پس از ذخیره).
### خطاها
- `403` `ERR_FORBIDDEN_001` — پروفایل یافت نشد.
---
## TenantInsurance — قراردادهای بیمه‌ی tenant (فاز ۱ سیستم صورتحساب)
قرارداد یک پزشک/کلینیک با یک بیمه: درصد پوشش، فرانشیز، سقف تعهد سالانه، نسخه‌بندی و وضعیت فعال. مبنای محاسبه‌ی سهم در سیستم صورتحساب (`docs/architecture/insurance-billing-system.md`). tenant از `#[CurrentUser]` (`ROLE_DOCTOR`→doctor، `ROLE_CLINIC`→clinic). جدول `tenant_insurances`.
### GET `/api/v1/billing/tenant-insurances`
لیست قراردادهای فعال tenant جاری.
**Permission:** `AUTH` (doctor/clinic)
```json
{
"success": true,
"data": {
"data": [
{
"uuid": "…",
"insurance_id": 3,
"insurance_name": "تأمین اجتماعی",
"insurance_kind": "basic",
"version": 1,
"is_active": true,
"coverage_percent": 70,
"franchise_rials": 0,
"annual_ceiling_rials": null,
"effective_from": 1718900000,
"effective_to": null
}
]
}
}
```
### POST `/api/v1/billing/tenant-insurances`
فعال‌سازی/به‌روزرسانی قرارداد. اگر قرارداد فعالی برای آن بیمه باشد ویرایش می‌شود، وگرنه نسخه‌ی جدید.
**Body:**
| فیلد | نوع | توضیح |
|------|-----|-------|
| `insurance_id` | int | الزامی |
| `coverage_percent` | float | درصد پوشش (۰–۱۰۰) |
| `franchise_rials` | int | فرانشیز ثابت سهم بیمار |
| `annual_ceiling_rials` | int \| null | سقف تعهد (null = بی‌نهایت) |
پاسخ `201`: `{ success, data: { …contract } }`.
خطاها: `404 ERR_NOT_FOUND_001` بیمه یافت نشد · `422 ERR_VALIDATION_001` insurance_id الزامی · `403 ERR_FORBIDDEN_001` پروفایل یافت نشد.
### PATCH `/api/v1/billing/tenant-insurances/{uuid}`
ویرایش `coverage_percent` / `franchise_rials` / `annual_ceiling_rials`. فقط قرارداد متعلق به tenant جاری.
### DELETE `/api/v1/billing/tenant-insurances/{uuid}`
غیرفعال‌سازی نرم (soft) — `is_active=false` و `effective_to=now`. داده حذف نمی‌شود.
```json
{ "success": true, "data": { "message": "قرارداد بیمه غیرفعال شد" } }
```
> **Guard:** `TenantInsuranceService::assertActive()` هنگام پذیرش/صورتحساب فقط بیمه‌های فعالِ همان tenant را مجاز می‌داند؛ در غیر این صورت `422 ERR_VALIDATION_001` («این بیمه برای این کلینیک/پزشک فعال نیست»).
---
## TenantServiceCoverage — پوشش خدمت تحت یک قرارداد بیمه (فاز ۲)
override پوشش یک خدمت خاص تحت قرارداد یک بیمه. فیلدهای `null` از خود قرارداد ارث می‌برند. اگر `covered=false` → آن خدمت تحت آن بیمه پوشش ندارد. جدول `tenant_service_coverage`.
### GET `/api/v1/billing/tenant-insurances/{uuid}/service-coverage`
لیست overrideهای پوشش خدمات یک قرارداد.
**Permission:** `AUTH` (مالک قرارداد)
```json
{
"success": true,
"data": {
"data": [
{
"uuid": "…",
"tenant_insurance_id": 4,
"service_item_id": 12,
"covered": true,
"coverage_percent": 80,
"franchise_rials": null,
"ceiling_rials": null
}
]
}
}
```
### PUT `/api/v1/billing/tenant-insurances/{uuid}/service-coverage`
تنظیم/به‌روزرسانی پوشش یک خدمت (upsert).
**Body:**
| فیلد | نوع | توضیح |
|------|-----|-------|
| `service_item_id` | int | الزامی |
| `covered` | bool | پیش‌فرض true |
| `coverage_percent` | float \| null | null = ارث از قرارداد |
| `franchise_rials` | int \| null | null = ارث از قرارداد |
| `ceiling_rials` | int \| null | null = ارث از قرارداد |
پاسخ `200`: `{ success, data: { message } }`.
خطاها: `404 ERR_NOT_FOUND_001` قرارداد یافت نشد · `422 ERR_VALIDATION_001` service_item_id الزامی.
> منطق resolve: `TenantInsuranceService::coverageRuleForService()` ابتدا override خدمت را بررسی می‌کند؛ اگر `covered=false` → `CoverageRule::notCovered()`؛ در غیر این صورت فیلدهای null از قرارداد پر می‌شوند. این `CoverageRule` در فاز ۴ ورودی `BillingCalculator` است.
@@ -0,0 +1,399 @@
# سیستم صورتحساب و بیمه (Insurance & Medical Billing) — سند معماری
> وضعیت: **طرح معماری (Design)** — هنوز پیاده‌سازی نشده. بر اساس کد فعلی Clinic Pro (Symfony 7.4 / PHP 8.2+ / Doctrine / MariaDB) و الگوهای موجود نوشته شده.
> دامنه‌های جدید پیشنهادی: `App\Billing\*` (Invoice/InvoiceItem/Claim/Tariff) و توسعه‌ی `App\Insurance\*` (TenantInsurance/Coverage).
---
## ۰. تطبیق با کد فعلی (مبنای واقعی، نه greenfield)
| مفهوم پرامپت | معادل واقعی در Clinic Pro | تصمیم |
|---|---|---|
| **Tenant** | موجودیت مستقل وجود ندارد. tenancy به‌صورت polymorphic `(entityType ∈ {doctor, clinic}, entityId)` پیاده شده (الگوی `PatientRecord`, `EntityInsurancePricing`, `SmsSettings`, `SubscriptionService`). | یک **Value Object `TenantRef(type, id)`** معرفی می‌شود؛ موجودیت Tenant جدید **ساخته نمی‌شود**. resolve از `#[CurrentUser]` مثل `PatientController::resolveEntity`. |
| **InsuranceCompany** | `App\Insurance\Entity\Insurance` (لیست master سراسری، مدیریت‌شده توسط admin، دارای `type: InsuranceType{basic,supplementary}`). | حفظ می‌شود به‌عنوان **master سراسری**. tenant آن را «فعال» می‌کند، تعریف نمی‌کند. |
| **TenantInsurance** | `EntityInsurancePricing` فعلی (polymorphic، فقط `patient_share_rials`) — **ناکافی**. | به **`TenantInsurance`** ارتقا می‌یابد (قرارداد tenant↔insurance با نسخه و وضعیت). `EntityInsurancePricing` migrate می‌شود. |
| **BaseInsurance / SupplementaryInsurance** | `InsuranceType` enum (`basic`/`supplementary`). | همان enum؛ موجودیت جدا لازم نیست (نوع روی `Insurance` است). |
| **Service** | `App\ClinicService\Entity\ServiceItem` (دارای `priceRials`, `active`, متعلق به `ServiceSection`). | حفظ + افزودن `insuranceCovered`. تعرفه به `Tariff` منتقل می‌شود. |
| **Tariff** | وجود ندارد (قیمت مستقیم روی `ServiceItem.priceRials`). | موجودیت جدید `Tariff` با **نسخه‌بندی سالانه** (effective year/range). |
| **Encounter** | `App\Patient\Entity\PatientSession` (مراجعه؛ دارای فیلدهای بیمه/قیمت ساده). | `PatientSession` نقش Encounter را دارد؛ **Invoice** به آن متصل می‌شود. |
| **Invoice / InvoiceItem** | وجود ندارد (محاسبه‌ی ساده در `PatientService::calculateFinalPrice`). | موجودیت‌های جدید `Invoice`/`InvoiceItem` با تفکیک سهم بیمار/پایه/مکمل. |
| **Claim** | وجود ندارد. | موجودیت جدید با state machine (الگوی `Appointment::transitionTo`). |
| **Patient** | `PatientRecord` (+ `UserProfile` برای دموگرافیک/بیمه‌ی بیمار). | حفظ. بیمه‌ی بیمار از `UserProfile.basicInsuranceId/supplementaryInsuranceId`. |
| **Payment** | `App\Payment\Entity\Payment` (درگاه پرداخت). | سهم بیمار از Invoice → می‌تواند به Payment وصل شود. |
**اصول حفظ‌شده:** پول = `int` ریال؛ تاریخ = Unix timestamp (نه DateTime)؛ کنترلرها از `BaseController` (`success/paginated/error`)؛ لیست admin با DQL array hydration؛ خطاها در `ErrorCodes`؛ multi-tenant با `(entityType, entityId)`.
---
## ۱. Bounded Contexts
```
Insurance (master + tenant contracts + coverage)
Insurance (master, global, admin) ← موجود
TenantInsurance (قرارداد tenant↔insurance) ← ارتقای EntityInsurancePricing
InsuranceCoverage (قانون پوشش هر بیمه)
TenantServiceCoverage (override پوشش یک خدمت خاص)
Billing (encounter → invoice → claim)
Tariff (تعرفه‌ی نسخه‌دار سالانه)
Invoice / InvoiceItem
Claim (چرخه‌ی مطالبات بیمه)
BillingCalculator (دامنه‌ی محاسبه — Domain Service)
Patient (موجود) ClinicService (موجود + insuranceCovered)
Subscription (موجود — gate ویژگی billing)
```
---
## ۲. Domain Model — موجودیت‌ها و فیلدها
### 2.1 Value Objects
```php
// App\Shared\ValueObject\TenantRef
final readonly class TenantRef
{
public function __construct(public string $type, public int $id) {} // type: 'doctor'|'clinic'
public static function doctor(int $id): self { return new self('doctor', $id); }
public static function clinic(int $id): self { return new self('clinic', $id); }
}
// App\Billing\ValueObject\Money (ریال، صحیح، بدون منفی)
final readonly class Money
{
public function __construct(public int $rials) { if ($rials < 0) throw new \InvalidArgumentException('negative money'); }
public function add(Money $o): self { return new self($this->rials + $o->rials); }
public function sub(Money $o): self { return new self(max(0, $this->rials - $o->rials)); }
public function percent(float $p): self { return new self((int) round($this->rials * $p / 100)); }
public function min(Money $o): self { return new self(min($this->rials, $o->rials)); }
}
// App\Billing\ValueObject\ShareBreakdown (خروجی محاسبه‌ی یک آیتم)
final readonly class ShareBreakdown
{
public function __construct(
public int $totalRials,
public int $baseInsuranceRials,
public int $supplementaryRials,
public int $patientRials,
) {}
}
// App\Insurance\ValueObject\CoverageRule
final readonly class CoverageRule
{
public function __construct(
public float $coveragePercent, // درصد پوشش این بیمه
public int $franchiseRials, // فرانشیز ثابت سهم بیمار
public ?int $ceilingRials, // سقف تعهد هر آیتم (null = بی‌نهایت)
public bool $covered = true,
) {}
}
```
### 2.2 Entities (جداول)
#### TenantInsurance (ارتقای EntityInsurancePricing)
قرارداد یک tenant با یک بیمه. نسخه‌دار، قابل غیرفعال‌سازی بدون حذف.
| ستون | نوع | توضیح |
|---|---|---|
| id | int PK | |
| uuid | string(36) unique | |
| entity_type | string(10) | `doctor`/`clinic` |
| entity_id | int | |
| insurance_id | int FK→insurances | |
| version | int | نسخه‌ی قرارداد (۱، ۲، …) |
| is_active | bool | غیرفعال بدون حذف |
| coverage_percent | decimal(5,2) | پوشش پیش‌فرض قرارداد |
| franchise_rials | int | فرانشیز پیش‌فرض |
| annual_ceiling_rials | int null | سقف تعهد سالانه‌ی بیمار/بیمه |
| effective_from | int (unix) | شروع اعتبار قرارداد |
| effective_to | int null | پایان (null = جاری) |
| created_at / updated_at | int | |
UniqueConstraint: `(entity_type, entity_id, insurance_id, version)`. Index: `(entity_type, entity_id, is_active)`.
#### TenantServiceCoverage
override پوشش یک خدمت خاص تحت یک بیمه‌ی tenant (آیا پوشش دارد + درصد/فرانشیز/سقف اختصاصی).
| ستون | نوع |
|---|---|
| id, uuid | |
| tenant_insurance_id | int FK→tenant_insurances |
| service_item_id | int FK→service_items |
| covered | bool |
| coverage_percent | decimal(5,2) null (null=ارث از قرارداد) |
| franchise_rials | int null |
| ceiling_rials | int null |
UniqueConstraint: `(tenant_insurance_id, service_item_id)`.
#### Tariff (تعرفه‌ی نسخه‌دار)
تعرفه‌ی یک خدمت برای یک سال. تاریخچه حفظ می‌شود.
| ستون | نوع | توضیح |
|---|---|---|
| id, uuid | | |
| service_item_id | int FK | |
| year | smallint | سال شمسی (۱۴۰۳ …) |
| price_rials | int | تعرفه‌ی آن سال |
| effective_from / effective_to | int (unix) | بازه‌ی اعتبار |
| is_active | bool | |
UniqueConstraint: `(service_item_id, year)`. `ServiceItem.priceRials` به‌عنوان تعرفه‌ی پیش‌فرض/جاری باقی می‌ماند (fallback).
#### Invoice
صورتحساب یک Encounter (`PatientSession`).
| ستون | نوع |
|---|---|
| id, uuid | |
| entity_type, entity_id | tenant |
| patient_session_id | int FK→patient_sessions (Encounter) |
| patient_record_id | int FK |
| base_insurance_id | int null |
| supplementary_insurance_id | int null |
| total_rials | int |
| base_insurance_rials | int |
| supplementary_rials | int |
| patient_rials | int |
| status | string(15) `draft\|finalized\|paid\|void` |
| issued_at | int (unix) |
| created_at, updated_at | int |
#### InvoiceItem
یک خط صورتحساب (خدمت/ویزیت).
| ستون | نوع |
|---|---|
| id, uuid | |
| invoice_id | int FK |
| service_item_id | int null (ویزیت می‌تواند null باشد) |
| title | string |
| service_code | string null |
| tariff_rials | int (تعرفه‌ی واحد) |
| quantity | int |
| total_rials | int (= tariff × qty) |
| base_coverage_percent | decimal(5,2) |
| supp_coverage_percent | decimal(5,2) |
| franchise_rials | int |
| ceiling_rials | int null |
| base_insurance_rials | int |
| supplementary_rials | int |
| patient_rials | int |
#### Claim (مطالبه‌ی بیمه)
گروهی از Invoiceها/InvoiceItemها که به یک بیمه ارسال می‌شود.
| ستون | نوع |
|---|---|
| id, uuid | |
| entity_type, entity_id | tenant |
| insurance_id | int FK |
| insurance_kind | string `base\|supplementary` |
| total_claimed_rials | int |
| total_approved_rials | int null |
| total_paid_rials | int null |
| status | string(15) `pending\|submitted\|approved\|rejected\|paid` |
| reject_reason | text null |
| submitted_at / settled_at | int null |
| created_at, updated_at | int |
#### ClaimItem
ارتباط Claim ↔ InvoiceItem (many-to-many با مبلغ ادعاشده per item).
| ستون | نوع |
|---|---|
| id | |
| claim_id | int FK |
| invoice_item_id | int FK |
| claimed_rials | int |
| approved_rials | int null |
---
## ۳. Entity Diagram (ERD متنی)
```
Insurance (master, global)
▲ insurance_id
TenantInsurance ──< TenantServiceCoverage >── ServiceItem ──< Tariff
(entity_type,id) │ service_item_id
PatientRecord ──< PatientSession(Encounter) ──1:1 Invoice ──< InvoiceItem
│ ▲ invoice_item_id
│ │
base/supp ins ClaimItem >── Claim ── Insurance
(entity_type,id)
UserProfile.basicInsuranceId / supplementaryInsuranceId → بیمه‌ی پیش‌فرض بیمار
```
---
## ۴. Aggregates (DDD)
| Aggregate Root | شامل | Invariantها |
|---|---|---|
| **Invoice** | InvoiceItem[] | جمع سهم‌ها = total هر آیتم؛ مجموع سطرها = total فاکتور؛ تغییر فقط در `draft`. |
| **Claim** | ClaimItem[] | فقط InvoiceItemهای finalized؛ transition وضعیت طبق state machine؛ `approved_rials ≤ claimed_rials`. |
| **TenantInsurance** | TenantServiceCoverage[] | فقط بیمه‌های فعالِ همان tenant؛ نسخه‌ی جدید قبلی را غیرفعال نمی‌کند مگر صریح. |
Encounter (`PatientSession`) خارج از Aggregate صورتحساب است؛ Invoice به آن **ارجاع** می‌دهد (نه ownership).
---
## ۵. منطق محاسبه‌ی سهم (Domain Service)
```php
// App\Billing\Service\BillingCalculator
final class BillingCalculator
{
/**
* محاسبه‌ی سهم برای یک آیتم.
* ترتیب: کل → پوشش پایه (با سقف) → باقیمانده‌ی بیمار → پوشش مکمل روی باقیمانده → فرانشیز ثابت سهم بیمار.
*/
public function calculateItem(
Money $total,
?CoverageRule $base, // قانون بیمه‌ی پایه (یا null)
?CoverageRule $supplementary, // قانون بیمه‌ی مکمل (یا null)
): ShareBreakdown {
$baseShare = new Money(0);
$remaining = $total;
if ($base !== null && $base->covered) {
$baseShare = $total->percent($base->coveragePercent);
if ($base->ceilingRials !== null) {
$baseShare = $baseShare->min(new Money($base->ceilingRials));
}
$remaining = $total->sub($baseShare);
}
$suppShare = new Money(0);
if ($supplementary !== null && $supplementary->covered) {
$suppShare = $remaining->percent($supplementary->coveragePercent);
if ($supplementary->ceilingRials !== null) {
$suppShare = $suppShare->min(new Money($supplementary->ceilingRials));
}
$remaining = $remaining->sub($suppShare);
}
// فرانشیز همیشه سهم بیمار است (به remaining اضافه می‌شود، از سهم بیمه کم نمی‌کند مگر طراحی دیگر)
$franchise = new Money(($base?->franchiseRials ?? 0) + ($supplementary?->franchiseRials ?? 0));
$patient = $remaining->add($franchise)->min($total); // سهم بیمار از کل بیشتر نشود
// اصلاح: اگر فرانشیز باعث شد جمع > total شود، سهم بیمه‌ها کم نمی‌شود؛ این Invariant باید تست شود.
return new ShareBreakdown(
totalRials: $total->rials,
baseInsuranceRials: $baseShare->rials,
supplementaryRials: $suppShare->rials,
patientRials: $patient->rials,
);
}
}
```
**نمونه (مطابق پرامپت):** کل 600,000؛ پایه 70% → 420,000؛ مکمل روی باقیمانده‌ی 180,000 با ≈66.7% → 120,000؛ سهم بیمار 60,000.
> نکته‌ی طراحی: تعامل فرانشیز و سقف باید با Test پوشش داده شود؛ منطق بالا یک baseline است و قابل تنظیم per-insurance.
---
## ۶. Repository / Service / DTO
**Repositoryها** (الگوی `ServiceEntityRepository` + `save/remove`):
`TenantInsuranceRepository` (findActiveByTenant, findContractFor), `TenantServiceCoverageRepository`, `TariffRepository` (findForServiceYear), `InvoiceRepository`, `ClaimRepository` (findByStatus, debtReport).
**Service Layer:**
- `TenantInsuranceService` — فعال‌سازی/غیرفعال/نسخه‌بندی قرارداد، گرفتن `CoverageRule` برای (tenant, insurance, serviceItem).
- `BillingCalculator` — محاسبه‌ی خالص (بالا).
- `InvoiceService` — ساخت Invoice از Encounter: برای هر خدمت → resolve تعرفه (Tariff سال) + CoverageRule بیمه‌های بیمار → `BillingCalculator` → InvoiceItem؛ finalize.
- `ClaimService` — گروه‌بندی InvoiceItemها بر اساس بیمه، ساخت Claim، transition وضعیت، گزارش بدهی.
**DTOها:** `CreateInvoiceRequest`, `InvoiceItemDTO`, `TenantInsuranceDTO`, `CoverageRuleDTO`, `ClaimDTO`, `BillingPreviewResponse` (پیش‌نمایش زنده‌ی محاسبه قبل از ثبت).
---
## ۷. REST API (تحت `/api/v1`, `BaseController`, tenant از `#[CurrentUser]`)
### TenantInsurance (قراردادهای بیمه‌ی tenant)
- `GET /api/v1/billing/tenant-insurances` — لیست قراردادهای فعال tenant
- `POST /api/v1/billing/tenant-insurances` — فعال‌سازی بیمه برای tenant (با coverage_percent/franchise/ceiling)
- `PATCH /api/v1/billing/tenant-insurances/{uuid}` — ویرایش (نسخه‌ی جدید)
- `DELETE /api/v1/billing/tenant-insurances/{uuid}` — غیرفعال‌سازی (soft)
- `PUT /api/v1/billing/tenant-insurances/{uuid}/service-coverage` — تنظیم پوشش خدمات
### Tariff
- `GET /api/v1/billing/services/{serviceUuid}/tariffs`
- `PUT /api/v1/billing/services/{serviceUuid}/tariffs/{year}` — ثبت/ویرایش تعرفه‌ی سال
### Invoice
- `POST /api/v1/billing/invoices/preview` — پیش‌نمایش محاسبه (بدون ذخیره)
- `POST /api/v1/billing/invoices` — ساخت از Encounter
- `GET /api/v1/billing/invoices/{uuid}`
- `POST /api/v1/billing/invoices/{uuid}/finalize`
### Claim
- `POST /api/v1/billing/claims` — ساخت از InvoiceItemهای finalized یک بیمه
- `POST /api/v1/billing/claims/{uuid}/submit|approve|reject|pay`
- `GET /api/v1/billing/claims?status=...`
- `GET /api/v1/billing/reports/insurance-debt` — گزارش بدهی بیمه‌ها
**Guard مهم (نیازمندی ۱۴):** هنگام پذیرش بیمار و ساخت Invoice، فقط بیمه‌هایی مجازند که `TenantInsurance.is_active = true` برای همان tenant. در غیر این صورت `ERR_VALIDATION` («این بیمه برای این کلینیک فعال نیست»).
---
## ۸. Workflow
### ثبت صورتحساب
```
Encounter(PatientSession) ثبت می‌شود
→ بیمه‌ی بیمار از UserProfile یا انتخاب منشی (محدود به TenantInsurance فعال)
→ InvoiceService.createFromEncounter:
برای هر خدمت:
tariff = TariffRepository.findForServiceYear(service, سالِ جاری) ?? service.priceRials
coverage_base = TenantInsuranceService.coverageRule(tenant, baseIns, service)
coverage_supp = TenantInsuranceService.coverageRule(tenant, suppIns, service)
breakdown = BillingCalculator.calculateItem(...)
→ InvoiceItem
→ Invoice (status=draft) → finalize (status=finalized)
```
### Claim
```
InvoiceItemهای finalized یک بیمه → ClaimService.create (status=pending)
→ submit (submitted) → [پاسخ بیمه] approve/reject → pay (paid)
گزارش بدهی = جمع claimed - paid بر اساس بیمه
```
state machine Claim (الگوی `Appointment::canTransitionTo`):
`pending → submitted → {approved → paid | rejected}`؛ از `paid`/`rejected` خروج ندارد.
---
## ۹. Events / Use Cases
**Domain Events:** `InvoiceFinalized`, `ClaimSubmitted`, `ClaimApproved`, `ClaimRejected`, `ClaimPaid`, `TenantInsuranceDeactivated`.
کاربرد: SMS/notification به بیمار، به‌روزرسانی گزارش بدهی، آینده: ارسال خودکار به API بیمه.
**Use Cases اصلی:** فعال‌سازی بیمه برای tenant؛ تنظیم پوشش خدمت؛ ثبت تعرفه‌ی سالانه؛ پیش‌نمایش محاسبه‌ی سهم؛ ثبت Invoice از Encounter؛ ساخت/پیگیری Claim؛ گزارش بدهی.
---
## ۱۰. آینده: اتصال به API بیمه (نیازمندی ۱۰)
`ClaimSubmitter` به‌صورت interface طراحی شود؛ پیاده‌سازی فعلی manual، آینده `IranInsuranceApiSubmitter` بدون تغییر در دامنه (Dependency Inversion).
---
## ۱۱. فازبندی پیاده‌سازی (پیشنهادی)
1. **فاز ۱ — قرارداد بیمه:****انجام شد**`TenantInsurance` (entity/repo/service/API/UI) + جدول `tenant_insurances` + guard `assertActive()` + `CoverageRule` VO. `EntityInsurancePricing` فعلاً برای ویزیت آزاد/سهم ساده باقی ماند (فاز ۴ ادغام). API: `/api/v1/billing/tenant-insurances` (GET/POST/PATCH/DELETE). UI: صفحه‌ی «بیمه و قیمت‌گذاری».
2. **فاز ۲ — پوشش خدمت:****انجام شد**`ServiceItem.insuranceCovered` + `insurancePriceRials` + موجودیت `TenantServiceCoverage` (override per-service per-contract) + `coverageRuleForService()` (resolve با ارث از قرارداد) + API `…/service-coverage` (GET/PUT) + UI toggle «شامل بیمه» در صفحه‌ی خدمات.
3. **فاز ۳ — تعرفه:****انجام شد** — موجودیت `Tariff` (service_item × سال شمسی) + `TariffService::resolvePrice()` با fallback به `ServiceItem.priceRials` + `currentJalaliYear()` (IntlDateFormatter، رقم لاتین) + API `…/tariffs` (GET) و `…/tariffs/{year}` (PUT) + UI «تعرفه‌های سالانه» (modal از ردیف خدمت).
4. **فاز ۴ — محاسبه + Invoice:****انجام شد** — VO `Money`/`ShareBreakdown` + `BillingCalculator` (۶ تست واحد سبز، نمونه ۶۰۰k) + `Invoice`/`InvoiceItem` (aggregate، جداول `invoices`/`invoice_items`) + `InvoiceService::createFromSession` (resolve تعرفه‌ی Tariff + CoverageRule per-service + محاسبه) + API `/api/v1/billing/invoices` (POST/GET/finalize). `PatientService::calculateFinalPrice` فعلاً برای سازگاری باقی ماند (مصرف UI موجود).
5. **فاز ۵ — Claim + گزارش:****انجام شد**`Claim`/`ClaimItem` (جداول `claims`/`claim_items`) + state machine (`pending→submitted→approved/rejected→paid`) + `ClaimService::createFromInvoice` (تفکیک پایه/مکمل از InvoiceItemها) + transitions + API `/api/v1/billing/claims` (POST/GET + `{uuid}/{action}`) + گزارش `…/reports/insurance-debt`.
6. **فاز ۶ — Frontend + API بیمه:****انجام شد** — صفحه‌ی «مطالبات بیمه» (`ClaimsPage`: لیست + فیلتر وضعیت + transitions submit/approve/reject/pay + گزارش بدهی) + route `/admin/claims` + آیتم sidebar (doctor/clinic) + interface `ClaimSubmitterInterface` با پیاده‌سازی پیش‌فرض `ManualClaimSubmitter` (autowired؛ آماده‌ی جایگزینی با API بیمه‌ی ایران بدون تغییر `ClaimService`). UI صورتحساب در فرم مراجعه به فاز بعدی موکول شد (نیاز به بازطراحی مودال مراجعه — پرامپت `new-visit-modal-ux.md`).
> توجه: `EntityInsurancePricing` فعلی (همین session ساخته شد) در فاز ۱ به `TenantInsurance` تبدیل می‌شود؛ تا آن زمان به‌عنوان نسخه‌ی ساده‌ی موقت کار می‌کند.
+32
View File
@@ -0,0 +1,32 @@
<?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 Version20260622163152 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE entity_insurance_pricing (id INT AUTO_INCREMENT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, insurance_id INT DEFAULT NULL, patient_share_rials INT NOT NULL, updated_at INT NOT NULL, INDEX idx_entity_pricing_owner (entity_type, entity_id), UNIQUE INDEX uniq_entity_insurance (entity_type, entity_id, insurance_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql("INSERT INTO entity_insurance_pricing (entity_type, entity_id, insurance_id, patient_share_rials, updated_at) SELECT 'doctor', doctor_id, insurance_id, COALESCE(price, 0), UNIX_TIMESTAMP() FROM doctor_insurances WHERE price IS NOT NULL");
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE entity_insurance_pricing');
}
}
+31
View File
@@ -0,0 +1,31 @@
<?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 Version20260622171538 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE tenant_insurances (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, insurance_id INT NOT NULL, version INT NOT NULL, is_active TINYINT NOT NULL, coverage_percent NUMERIC(5, 2) NOT NULL, franchise_rials INT NOT NULL, annual_ceiling_rials INT DEFAULT NULL, effective_from INT NOT NULL, effective_to INT DEFAULT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, UNIQUE INDEX UNIQ_9C419AB7D17F50A6 (uuid), INDEX idx_tenant_insurance_active (entity_type, entity_id, is_active), UNIQUE INDEX uniq_tenant_insurance_version (entity_type, entity_id, insurance_id, version), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE tenant_insurances');
}
}
+33
View File
@@ -0,0 +1,33 @@
<?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 Version20260622173707 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE tenant_service_coverage (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, tenant_insurance_id INT NOT NULL, service_item_id INT NOT NULL, covered TINYINT NOT NULL, coverage_percent NUMERIC(5, 2) DEFAULT NULL, franchise_rials INT DEFAULT NULL, ceiling_rials INT DEFAULT NULL, updated_at INT NOT NULL, UNIQUE INDEX UNIQ_76431C8ED17F50A6 (uuid), INDEX idx_tsc_service (service_item_id), UNIQUE INDEX uniq_tenant_service_coverage (tenant_insurance_id, service_item_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE service_items ADD insurance_covered TINYINT NOT NULL, ADD insurance_price_rials INT DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE tenant_service_coverage');
$this->addSql('ALTER TABLE service_items DROP insurance_covered, DROP insurance_price_rials');
}
}
+31
View File
@@ -0,0 +1,31 @@
<?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 Version20260622181251 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE service_tariffs (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, service_item_id INT NOT NULL, year SMALLINT NOT NULL, price_rials INT NOT NULL, is_active TINYINT NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, UNIQUE INDEX UNIQ_FAAAF536D17F50A6 (uuid), INDEX idx_tariff_service_active (service_item_id, is_active), UNIQUE INDEX uniq_service_tariff_year (service_item_id, year), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE service_tariffs');
}
}
+35
View File
@@ -0,0 +1,35 @@
<?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 Version20260622184409 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE invoice_items (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, service_item_id INT DEFAULT NULL, title VARCHAR(200) NOT NULL, tariff_rials INT NOT NULL, quantity INT NOT NULL, total_rials INT NOT NULL, base_insurance_rials INT NOT NULL, supplementary_rials INT NOT NULL, patient_rials INT NOT NULL, invoice_id INT NOT NULL, UNIQUE INDEX UNIQ_DCC4B9F8D17F50A6 (uuid), INDEX IDX_DCC4B9F82989F1FD (invoice_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE invoices (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, patient_session_id INT DEFAULT NULL, patient_record_id INT DEFAULT NULL, base_insurance_id INT DEFAULT NULL, supplementary_insurance_id INT DEFAULT NULL, total_rials INT NOT NULL, base_insurance_rials INT NOT NULL, supplementary_rials INT NOT NULL, patient_rials INT NOT NULL, status VARCHAR(15) NOT NULL, issued_at INT NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, UNIQUE INDEX UNIQ_6A2F2F95D17F50A6 (uuid), INDEX idx_invoice_tenant (entity_type, entity_id), INDEX idx_invoice_session (patient_session_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE invoice_items ADD CONSTRAINT FK_DCC4B9F82989F1FD FOREIGN KEY (invoice_id) REFERENCES invoices (id) ON DELETE CASCADE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE invoice_items DROP FOREIGN KEY FK_DCC4B9F82989F1FD');
$this->addSql('DROP TABLE invoice_items');
$this->addSql('DROP TABLE invoices');
}
}
+35
View File
@@ -0,0 +1,35 @@
<?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 Version20260622185948 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE claim_items (id INT AUTO_INCREMENT NOT NULL, invoice_item_id INT NOT NULL, claimed_rials INT NOT NULL, approved_rials INT DEFAULT NULL, claim_id INT NOT NULL, INDEX IDX_DD53020B7096A49F (claim_id), INDEX idx_claim_item_invoice_item (invoice_item_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE claims (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, insurance_id INT NOT NULL, insurance_kind VARCHAR(15) NOT NULL, total_claimed_rials INT NOT NULL, total_approved_rials INT DEFAULT NULL, total_paid_rials INT DEFAULT NULL, status VARCHAR(15) NOT NULL, reject_reason LONGTEXT DEFAULT NULL, submitted_at INT DEFAULT NULL, settled_at INT DEFAULT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, UNIQUE INDEX UNIQ_BEA313BED17F50A6 (uuid), INDEX idx_claim_tenant (entity_type, entity_id), INDEX idx_claim_insurance_status (insurance_id, status), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE claim_items ADD CONSTRAINT FK_DD53020B7096A49F FOREIGN KEY (claim_id) REFERENCES claims (id) ON DELETE CASCADE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE claim_items DROP FOREIGN KEY FK_DD53020B7096A49F');
$this->addSql('DROP TABLE claim_items');
$this->addSql('DROP TABLE claims');
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Billing\Contract;
final readonly class ClaimSubmissionResult
{
public function __construct(
public bool $success,
public ?string $referenceCode = null,
public ?string $errorMessage = null,
) {}
public static function ok(?string $referenceCode = null): self
{
return new self(true, $referenceCode, null);
}
public static function fail(string $errorMessage): self
{
return new self(false, null, $errorMessage);
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Billing\Contract;
use App\Billing\Entity\Claim;
/**
* انتزاع ارسال مطالبه به بیمه. پیاده‌سازی فعلی manual است؛
* در آینده می‌توان پیاده‌سازی متصل به API شرکت‌های بیمه‌ی ایران را بدون تغییر دامنه جایگزین کرد.
*/
interface ClaimSubmitterInterface
{
public function submit(Claim $claim): ClaimSubmissionResult;
}
@@ -0,0 +1,186 @@
<?php
namespace App\Billing\Controller;
use App\Auth\Entity\User;
use App\Billing\Entity\Claim;
use App\Billing\Repository\ClaimRepository;
use App\Billing\Repository\InvoiceRepository;
use App\Billing\Service\ClaimService;
use App\Billing\Service\InvoiceService;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Patient\Repository\PatientSessionRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use OpenApi\Attributes as OA;
#[OA\Tag(name: 'Billing')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class BillingController extends BaseController
{
public function __construct(
private readonly InvoiceService $invoiceService,
private readonly InvoiceRepository $invoiceRepo,
private readonly ClaimService $claimService,
private readonly ClaimRepository $claimRepo,
private readonly PatientSessionRepository $sessionRepo,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
) {}
#[Route('/api/v1/billing/invoices', methods: ['POST'])]
public function create(Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
$data = json_decode($request->getContent(), true) ?? [];
$sessionUuid = trim($data['session_uuid'] ?? '');
if ($sessionUuid === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'session_uuid الزامی است', 422);
}
$session = $this->sessionRepo->findByUuid($sessionUuid);
if ($session === null || !$this->ownsSession($session, $entityType, $entityId)) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'مراجعه یافت نشد', 404);
}
$invoice = $this->invoiceService->createFromSession($session, $entityType, $entityId);
return $this->success(['data' => $invoice->toArray()], 201);
}
#[Route('/api/v1/billing/invoices/{uuid}', methods: ['GET'])]
public function show(string $uuid, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$invoice = $this->invoiceRepo->findByUuid($uuid);
if ($invoice === null || $invoice->getEntityType() !== $entityType || $invoice->getEntityId() !== $entityId) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'صورتحساب یافت نشد', 404);
}
return $this->success(['data' => $invoice->toArray()]);
}
#[Route('/api/v1/billing/invoices/{uuid}/finalize', methods: ['POST'])]
public function finalize(string $uuid, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$invoice = $this->invoiceRepo->findByUuid($uuid);
if ($invoice === null || $invoice->getEntityType() !== $entityType || $invoice->getEntityId() !== $entityId) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'صورتحساب یافت نشد', 404);
}
$this->invoiceService->finalize($invoice);
return $this->success(['data' => $invoice->toArray()]);
}
// ── Claims (مطالبات بیمه) ──────────────────────────────────────────────────
#[Route('/api/v1/billing/claims', methods: ['POST'])]
public function createClaim(Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
$data = json_decode($request->getContent(), true) ?? [];
$invoiceUuid = trim($data['invoice_uuid'] ?? '');
if ($invoiceUuid === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'invoice_uuid الزامی است', 422);
}
$invoice = $this->invoiceRepo->findByUuid($invoiceUuid);
if ($invoice === null || $invoice->getEntityType() !== $entityType || $invoice->getEntityId() !== $entityId) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'صورتحساب یافت نشد', 404);
}
$claims = $this->claimService->createFromInvoice($invoice);
return $this->success(['data' => array_map(fn(Claim $c) => $c->toArray(), $claims)], 201);
}
#[Route('/api/v1/billing/claims', methods: ['GET'])]
public function listClaims(Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
$status = $request->query->get('status') ?: null;
$claims = $this->claimRepo->findByTenant($entityType, $entityId, $status);
return $this->success(['data' => array_map(fn(Claim $c) => $c->toArray(), $claims)]);
}
#[Route('/api/v1/billing/claims/{uuid}/{action}', methods: ['POST'], requirements: ['action' => 'submit|approve|reject|pay'])]
public function transitionClaim(string $uuid, string $action, Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$claim = $this->claimRepo->findByUuid($uuid);
if ($claim === null || $claim->getEntityType() !== $entityType || $claim->getEntityId() !== $entityId) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'مطالبه یافت نشد', 404);
}
$data = json_decode($request->getContent(), true) ?? [];
$target = match ($action) {
'submit' => Claim::STATUS_SUBMITTED,
'approve' => Claim::STATUS_APPROVED,
'reject' => Claim::STATUS_REJECTED,
'pay' => Claim::STATUS_PAID,
};
if ($action === 'reject' && trim($data['reason'] ?? '') === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'دلیل رد الزامی است', 422);
}
$this->claimService->transition($claim, $target, [
'approved_rials' => isset($data['approved_rials']) ? (int) $data['approved_rials'] : null,
'paid_rials' => isset($data['paid_rials']) ? (int) $data['paid_rials'] : null,
'reason' => trim($data['reason'] ?? ''),
]);
return $this->success(['data' => $claim->toArray()]);
}
#[Route('/api/v1/billing/reports/insurance-debt', methods: ['GET'])]
public function insuranceDebt(#[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
return $this->success(['data' => $this->claimRepo->debtReport($entityType, $entityId)]);
}
private function ownsSession($session, string $entityType, int $entityId): bool
{
$record = $session->getRecord();
return $record->getEntityType() === $entityType && $record->getEntityId() === $entityId;
}
private function resolveEntity(User $user): array
{
if ($user->hasRole('ROLE_DOCTOR')) {
$doctor = $this->doctorRepo->findByUser($user);
return $doctor !== null ? ['doctor', $doctor->getId()] : ['doctor', null];
}
if ($user->hasRole('ROLE_CLINIC')) {
$clinic = $this->clinicRepo->findByUser($user);
return $clinic !== null ? ['clinic', $clinic->getId()] : ['clinic', null];
}
return ['unknown', null];
}
}
+171
View File
@@ -0,0 +1,171 @@
<?php
namespace App\Billing\Entity;
use App\Billing\Repository\ClaimRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: ClaimRepository::class)]
#[ORM\Table(name: 'claims')]
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_claim_tenant')]
#[ORM\Index(columns: ['insurance_id', 'status'], name: 'idx_claim_insurance_status')]
class Claim
{
public const STATUS_PENDING = 'pending';
public const STATUS_SUBMITTED = 'submitted';
public const STATUS_APPROVED = 'approved';
public const STATUS_REJECTED = 'rejected';
public const STATUS_PAID = 'paid';
public const KIND_BASE = 'base';
public const KIND_SUPPLEMENTARY = 'supplementary';
private const TRANSITIONS = [
self::STATUS_PENDING => [self::STATUS_SUBMITTED],
self::STATUS_SUBMITTED => [self::STATUS_APPROVED, self::STATUS_REJECTED],
self::STATUS_APPROVED => [self::STATUS_PAID],
self::STATUS_REJECTED => [],
self::STATUS_PAID => [],
];
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(name: 'entity_type', type: 'string', length: 10)]
private string $entityType;
#[ORM\Column(name: 'entity_id', type: 'integer')]
private int $entityId;
#[ORM\Column(name: 'insurance_id', type: 'integer')]
private int $insuranceId;
#[ORM\Column(name: 'insurance_kind', type: 'string', length: 15)]
private string $insuranceKind;
#[ORM\Column(name: 'total_claimed_rials', type: 'integer')]
private int $totalClaimedRials = 0;
#[ORM\Column(name: 'total_approved_rials', type: 'integer', nullable: true)]
private ?int $totalApprovedRials = null;
#[ORM\Column(name: 'total_paid_rials', type: 'integer', nullable: true)]
private ?int $totalPaidRials = null;
#[ORM\Column(type: 'string', length: 15)]
private string $status = self::STATUS_PENDING;
#[ORM\Column(name: 'reject_reason', type: 'text', nullable: true)]
private ?string $rejectReason = null;
#[ORM\Column(name: 'submitted_at', type: 'integer', nullable: true)]
private ?int $submittedAt = null;
#[ORM\Column(name: 'settled_at', type: 'integer', nullable: true)]
private ?int $settledAt = null;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
#[ORM\OneToMany(targetEntity: ClaimItem::class, mappedBy: 'claim', cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $items;
public function __construct(string $entityType, int $entityId, int $insuranceId, string $insuranceKind)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->entityType = $entityType;
$this->entityId = $entityId;
$this->insuranceId = $insuranceId;
$this->insuranceKind = $insuranceKind;
$this->createdAt = time();
$this->updatedAt = time();
$this->items = new ArrayCollection();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getEntityType(): string { return $this->entityType; }
public function getEntityId(): int { return $this->entityId; }
public function getInsuranceId(): int { return $this->insuranceId; }
public function getInsuranceKind(): string { return $this->insuranceKind; }
public function getStatus(): string { return $this->status; }
public function getTotalClaimedRials(): int { return $this->totalClaimedRials; }
public function getTotalApprovedRials(): ?int { return $this->totalApprovedRials; }
public function getTotalPaidRials(): ?int { return $this->totalPaidRials; }
/** @return Collection<int, ClaimItem> */
public function getItems(): Collection { return $this->items; }
public function addItem(ClaimItem $item): self
{
$this->items->add($item);
$this->totalClaimedRials += $item->getClaimedRials();
$this->updatedAt = time();
return $this;
}
public function canTransitionTo(string $status): bool
{
return in_array($status, self::TRANSITIONS[$this->status] ?? [], true);
}
public function submit(): void
{
$this->status = self::STATUS_SUBMITTED;
$this->submittedAt = time();
$this->updatedAt = time();
}
public function approve(?int $approvedRials = null): void
{
$this->status = self::STATUS_APPROVED;
$this->totalApprovedRials = $approvedRials ?? $this->totalClaimedRials;
$this->updatedAt = time();
}
public function reject(string $reason): void
{
$this->status = self::STATUS_REJECTED;
$this->rejectReason = $reason;
$this->settledAt = time();
$this->updatedAt = time();
}
public function pay(?int $paidRials = null): void
{
$this->status = self::STATUS_PAID;
$this->totalPaidRials = $paidRials ?? $this->totalApprovedRials ?? $this->totalClaimedRials;
$this->settledAt = time();
$this->updatedAt = time();
}
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'entity_type' => $this->entityType,
'entity_id' => $this->entityId,
'insurance_id' => $this->insuranceId,
'insurance_kind' => $this->insuranceKind,
'total_claimed_rials' => $this->totalClaimedRials,
'total_approved_rials' => $this->totalApprovedRials,
'total_paid_rials' => $this->totalPaidRials,
'status' => $this->status,
'reject_reason' => $this->rejectReason,
'submitted_at' => $this->submittedAt,
'settled_at' => $this->settledAt,
'created_at' => $this->createdAt,
'items' => array_map(fn(ClaimItem $i) => $i->toArray(), $this->items->toArray()),
];
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace App\Billing\Entity;
use App\Billing\Repository\ClaimItemRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ClaimItemRepository::class)]
#[ORM\Table(name: 'claim_items')]
#[ORM\Index(columns: ['invoice_item_id'], name: 'idx_claim_item_invoice_item')]
class ClaimItem
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Claim::class, inversedBy: 'items')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private Claim $claim;
#[ORM\Column(name: 'invoice_item_id', type: 'integer')]
private int $invoiceItemId;
#[ORM\Column(name: 'claimed_rials', type: 'integer')]
private int $claimedRials;
#[ORM\Column(name: 'approved_rials', type: 'integer', nullable: true)]
private ?int $approvedRials = null;
public function __construct(Claim $claim, int $invoiceItemId, int $claimedRials)
{
$this->claim = $claim;
$this->invoiceItemId = $invoiceItemId;
$this->claimedRials = $claimedRials;
}
public function getId(): ?int { return $this->id; }
public function getInvoiceItemId(): int { return $this->invoiceItemId; }
public function getClaimedRials(): int { return $this->claimedRials; }
public function getApprovedRials(): ?int { return $this->approvedRials; }
public function toArray(): array
{
return [
'invoice_item_id' => $this->invoiceItemId,
'claimed_rials' => $this->claimedRials,
'approved_rials' => $this->approvedRials,
];
}
}
+153
View File
@@ -0,0 +1,153 @@
<?php
namespace App\Billing\Entity;
use App\Billing\Repository\InvoiceRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: InvoiceRepository::class)]
#[ORM\Table(name: 'invoices')]
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_invoice_tenant')]
#[ORM\Index(columns: ['patient_session_id'], name: 'idx_invoice_session')]
class Invoice
{
public const STATUS_DRAFT = 'draft';
public const STATUS_FINALIZED = 'finalized';
public const STATUS_PAID = 'paid';
public const STATUS_VOID = 'void';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(name: 'entity_type', type: 'string', length: 10)]
private string $entityType;
#[ORM\Column(name: 'entity_id', type: 'integer')]
private int $entityId;
#[ORM\Column(name: 'patient_session_id', type: 'integer', nullable: true)]
private ?int $patientSessionId = null;
#[ORM\Column(name: 'patient_record_id', type: 'integer', nullable: true)]
private ?int $patientRecordId = null;
#[ORM\Column(name: 'base_insurance_id', type: 'integer', nullable: true)]
private ?int $baseInsuranceId = null;
#[ORM\Column(name: 'supplementary_insurance_id', type: 'integer', nullable: true)]
private ?int $supplementaryInsuranceId = null;
#[ORM\Column(name: 'total_rials', type: 'integer')]
private int $totalRials = 0;
#[ORM\Column(name: 'base_insurance_rials', type: 'integer')]
private int $baseInsuranceRials = 0;
#[ORM\Column(name: 'supplementary_rials', type: 'integer')]
private int $supplementaryRials = 0;
#[ORM\Column(name: 'patient_rials', type: 'integer')]
private int $patientRials = 0;
#[ORM\Column(type: 'string', length: 15)]
private string $status = self::STATUS_DRAFT;
#[ORM\Column(name: 'issued_at', type: 'integer')]
private int $issuedAt;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
#[ORM\OneToMany(targetEntity: InvoiceItem::class, mappedBy: 'invoice', cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $items;
public function __construct(string $entityType, int $entityId)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->entityType = $entityType;
$this->entityId = $entityId;
$this->issuedAt = time();
$this->createdAt = time();
$this->updatedAt = time();
$this->items = new ArrayCollection();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getEntityType(): string { return $this->entityType; }
public function getEntityId(): int { return $this->entityId; }
public function getStatus(): string { return $this->status; }
public function getBaseInsuranceId(): ?int { return $this->baseInsuranceId; }
public function getSupplementaryInsuranceId(): ?int { return $this->supplementaryInsuranceId; }
public function getTotalRials(): int { return $this->totalRials; }
public function getPatientRials(): int { return $this->patientRials; }
/** @return Collection<int, InvoiceItem> */
public function getItems(): Collection { return $this->items; }
public function setPatientSessionId(?int $v): self { $this->patientSessionId = $v; return $this; }
public function setPatientRecordId(?int $v): self { $this->patientRecordId = $v; return $this; }
public function setBaseInsuranceId(?int $v): self { $this->baseInsuranceId = $v; return $this; }
public function setSupplementaryInsuranceId(?int $v): self { $this->supplementaryInsuranceId = $v; return $this; }
public function addItem(InvoiceItem $item): self
{
$this->items->add($item);
$item->attachTo($this);
return $this;
}
public function recalculateTotals(): void
{
$total = $base = $supp = $patient = 0;
foreach ($this->items as $item) {
$total += $item->getTotalRials();
$base += $item->getBaseInsuranceRials();
$supp += $item->getSupplementaryRials();
$patient += $item->getPatientRials();
}
$this->totalRials = $total;
$this->baseInsuranceRials = $base;
$this->supplementaryRials = $supp;
$this->patientRials = $patient;
$this->updatedAt = time();
}
public function finalize(): void
{
if ($this->status === self::STATUS_DRAFT) {
$this->status = self::STATUS_FINALIZED;
$this->updatedAt = time();
}
}
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'entity_type' => $this->entityType,
'entity_id' => $this->entityId,
'patient_session_id' => $this->patientSessionId,
'patient_record_id' => $this->patientRecordId,
'base_insurance_id' => $this->baseInsuranceId,
'supplementary_insurance_id' => $this->supplementaryInsuranceId,
'total_rials' => $this->totalRials,
'base_insurance_rials' => $this->baseInsuranceRials,
'supplementary_rials' => $this->supplementaryRials,
'patient_rials' => $this->patientRials,
'status' => $this->status,
'issued_at' => $this->issuedAt,
'items' => array_map(fn(InvoiceItem $i) => $i->toArray(), $this->items->toArray()),
];
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
namespace App\Billing\Entity;
use App\Billing\Repository\InvoiceItemRepository;
use App\Billing\ValueObject\ShareBreakdown;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: InvoiceItemRepository::class)]
#[ORM\Table(name: 'invoice_items')]
class InvoiceItem
{
#[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: Invoice::class, inversedBy: 'items')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private Invoice $invoice;
#[ORM\Column(name: 'service_item_id', type: 'integer', nullable: true)]
private ?int $serviceItemId = null;
#[ORM\Column(type: 'string', length: 200)]
private string $title;
#[ORM\Column(name: 'tariff_rials', type: 'integer')]
private int $tariffRials;
#[ORM\Column(type: 'integer')]
private int $quantity;
#[ORM\Column(name: 'total_rials', type: 'integer')]
private int $totalRials;
#[ORM\Column(name: 'base_insurance_rials', type: 'integer')]
private int $baseInsuranceRials = 0;
#[ORM\Column(name: 'supplementary_rials', type: 'integer')]
private int $supplementaryRials = 0;
#[ORM\Column(name: 'patient_rials', type: 'integer')]
private int $patientRials = 0;
public function __construct(
Invoice $invoice,
string $title,
int $tariffRials,
int $quantity,
ShareBreakdown $breakdown,
?int $serviceItemId = null,
) {
$this->uuid = Uuid::v4()->toRfc4122();
$this->invoice = $invoice;
$this->title = $title;
$this->tariffRials = $tariffRials;
$this->quantity = $quantity;
$this->serviceItemId = $serviceItemId;
$this->totalRials = $breakdown->totalRials;
$this->baseInsuranceRials = $breakdown->baseInsuranceRials;
$this->supplementaryRials = $breakdown->supplementaryRials;
$this->patientRials = $breakdown->patientRials;
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getServiceItemId(): ?int { return $this->serviceItemId; }
public function getTotalRials(): int { return $this->totalRials; }
public function getBaseInsuranceRials(): int { return $this->baseInsuranceRials; }
public function getSupplementaryRials(): int { return $this->supplementaryRials; }
public function getPatientRials(): int { return $this->patientRials; }
public function attachTo(Invoice $invoice): void
{
$this->invoice = $invoice;
}
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'service_item_id' => $this->serviceItemId,
'title' => $this->title,
'tariff_rials' => $this->tariffRials,
'quantity' => $this->quantity,
'total_rials' => $this->totalRials,
'base_insurance_rials' => $this->baseInsuranceRials,
'supplementary_rials' => $this->supplementaryRials,
'patient_rials' => $this->patientRials,
];
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Billing\Repository;
use App\Billing\Entity\ClaimItem;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ClaimItemRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ClaimItem::class);
}
public function existsForInvoiceItem(int $invoiceItemId, int $insuranceKindClaimInsuranceId): bool
{
return $this->createQueryBuilder('ci')
->select('COUNT(ci.id)')
->join('ci.claim', 'c')
->where('ci.invoiceItemId = :iid')
->andWhere('c.insuranceId = :ins')
->setParameter('iid', $invoiceItemId)
->setParameter('ins', $insuranceKindClaimInsuranceId)
->getQuery()
->getSingleScalarResult() > 0;
}
}
@@ -0,0 +1,77 @@
<?php
namespace App\Billing\Repository;
use App\Billing\Entity\Claim;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ClaimRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Claim::class);
}
public function findByUuid(string $uuid): ?Claim
{
return $this->findOneBy(['uuid' => $uuid]);
}
/** @return Claim[] */
public function findByTenant(string $entityType, int $entityId, ?string $status = null): array
{
$qb = $this->createQueryBuilder('c')
->where('c.entityType = :type')
->andWhere('c.entityId = :id')
->setParameter('type', $entityType)
->setParameter('id', $entityId)
->orderBy('c.id', 'DESC');
if ($status !== null) {
$qb->andWhere('c.status = :status')->setParameter('status', $status);
}
return $qb->getQuery()->getResult();
}
/**
* گزارش بدهی بیمه: جمع claimed/approved/paid بر اساس بیمه برای tenant.
* @return array<int, array{insurance_id:int, claimed:int, approved:int, paid:int, debt:int}>
*/
public function debtReport(string $entityType, int $entityId): array
{
$rows = $this->createQueryBuilder('c')
->select('c.insuranceId AS insurance_id')
->addSelect('SUM(c.totalClaimedRials) AS claimed')
->addSelect('SUM(COALESCE(c.totalApprovedRials, 0)) AS approved')
->addSelect('SUM(COALESCE(c.totalPaidRials, 0)) AS paid')
->where('c.entityType = :type')
->andWhere('c.entityId = :id')
->setParameter('type', $entityType)
->setParameter('id', $entityId)
->groupBy('c.insuranceId')
->getQuery()
->getArrayResult();
return array_map(function (array $r) {
$claimed = (int) $r['claimed'];
$paid = (int) $r['paid'];
return [
'insurance_id' => (int) $r['insurance_id'],
'claimed' => $claimed,
'approved' => (int) $r['approved'],
'paid' => $paid,
'debt' => max(0, $claimed - $paid),
];
}, $rows);
}
public function save(Claim $entity, bool $flush = true): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Billing\Repository;
use App\Billing\Entity\InvoiceItem;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class InvoiceItemRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, InvoiceItem::class);
}
public function save(InvoiceItem $entity, bool $flush = true): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Billing\Repository;
use App\Billing\Entity\Invoice;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class InvoiceRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Invoice::class);
}
public function findByUuid(string $uuid): ?Invoice
{
return $this->findOneBy(['uuid' => $uuid]);
}
public function findBySession(int $patientSessionId): ?Invoice
{
return $this->findOneBy(['patientSessionId' => $patientSessionId]);
}
public function save(Invoice $entity, bool $flush = true): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace App\Billing\Service;
use App\Billing\ValueObject\Money;
use App\Billing\ValueObject\ShareBreakdown;
use App\Insurance\ValueObject\CoverageRule;
class BillingCalculator
{
/**
* محاسبه‌ی سهم برای یک آیتم.
* ترتیب: کل → پوشش پایه (با سقف) → باقیمانده → پوشش مکمل روی باقیمانده (با سقف) → فرانشیز سهم بیمار.
*/
public function calculateItem(
Money $total,
?CoverageRule $base,
?CoverageRule $supplementary,
): ShareBreakdown {
$baseShare = Money::zero();
$remaining = $total;
if ($base !== null && $base->covered) {
$baseShare = $total->percent($base->coveragePercent);
if ($base->ceilingRials !== null) {
$baseShare = $baseShare->min(new Money($base->ceilingRials));
}
$remaining = $total->sub($baseShare);
}
$suppShare = Money::zero();
if ($supplementary !== null && $supplementary->covered) {
$suppShare = $remaining->percent($supplementary->coveragePercent);
if ($supplementary->ceilingRials !== null) {
$suppShare = $suppShare->min(new Money($supplementary->ceilingRials));
}
$remaining = $remaining->sub($suppShare);
}
// فرانشیز سهم بیمار است؛ از سهم بیمه کم نمی‌کند ولی سهم بیمار از کل بیشتر نمی‌شود.
$franchise = new Money(
($base?->franchiseRials ?? 0) + ($supplementary?->franchiseRials ?? 0)
);
$patient = $remaining->add($franchise)->min($total);
return new ShareBreakdown(
totalRials: $total->rials,
baseInsuranceRials: $baseShare->rials,
supplementaryRials: $suppShare->rials,
patientRials: $patient->rials,
);
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
namespace App\Billing\Service;
use App\Billing\Contract\ClaimSubmitterInterface;
use App\Billing\Entity\Claim;
use App\Billing\Entity\ClaimItem;
use App\Billing\Entity\Invoice;
use App\Billing\Repository\ClaimRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
class ClaimService
{
public function __construct(
private readonly ClaimRepository $claimRepo,
private readonly ClaimSubmitterInterface $submitter,
) {}
/**
* ساخت مطالبات از یک صورتحساب نهایی‌شده.
* یک Claim برای بیمه‌ی پایه و یک Claim برای بیمه‌ی مکمل (در صورت وجود سهم).
* @return Claim[]
*/
public function createFromInvoice(Invoice $invoice): array
{
if ($invoice->getStatus() !== Invoice::STATUS_FINALIZED) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'فقط از صورتحساب نهایی‌شده می‌توان مطالبه ساخت', 422);
}
$claims = [];
$baseId = $invoice->getBaseInsuranceId();
if ($baseId !== null) {
$claim = $this->buildClaim($invoice, $baseId, Claim::KIND_BASE);
if ($claim !== null) {
$claims[] = $claim;
}
}
$suppId = $invoice->getSupplementaryInsuranceId();
if ($suppId !== null) {
$claim = $this->buildClaim($invoice, $suppId, Claim::KIND_SUPPLEMENTARY);
if ($claim !== null) {
$claims[] = $claim;
}
}
if ($claims === []) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'سهم بیمه‌ای برای این صورتحساب وجود ندارد', 422);
}
foreach ($claims as $claim) {
$this->claimRepo->save($claim, false);
}
$this->claimRepo->getEntityManager()->flush();
return $claims;
}
private function buildClaim(Invoice $invoice, int $insuranceId, string $kind): ?Claim
{
$claim = new Claim($invoice->getEntityType(), $invoice->getEntityId(), $insuranceId, $kind);
$hasShare = false;
foreach ($invoice->getItems() as $item) {
$share = $kind === Claim::KIND_BASE
? $item->getBaseInsuranceRials()
: $item->getSupplementaryRials();
if ($share > 0) {
$claim->addItem(new ClaimItem($claim, $item->getId(), $share));
$hasShare = true;
}
}
return $hasShare ? $claim : null;
}
public function transition(Claim $claim, string $target, array $opts = []): void
{
if (!$claim->canTransitionTo($target)) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
sprintf('انتقال از «%s» به «%s» مجاز نیست', $claim->getStatus(), $target),
422
);
}
match ($target) {
Claim::STATUS_SUBMITTED => $this->doSubmit($claim),
Claim::STATUS_APPROVED => $claim->approve($opts['approved_rials'] ?? null),
Claim::STATUS_REJECTED => $claim->reject($opts['reason'] ?? ''),
Claim::STATUS_PAID => $claim->pay($opts['paid_rials'] ?? null),
default => throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'وضعیت نامعتبر', 422),
};
$this->claimRepo->save($claim);
}
private function doSubmit(Claim $claim): void
{
$result = $this->submitter->submit($claim);
if (!$result->success) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, $result->errorMessage ?? 'ارسال مطالبه ناموفق بود', 422);
}
$claim->submit();
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace App\Billing\Service;
use App\Billing\Entity\Invoice;
use App\Billing\Entity\InvoiceItem;
use App\Billing\Repository\InvoiceRepository;
use App\Billing\ValueObject\Money;
use App\ClinicService\Service\TariffService;
use App\Insurance\Service\TenantInsuranceService;
use App\Patient\Entity\PatientSession;
class InvoiceService
{
public function __construct(
private readonly InvoiceRepository $invoiceRepo,
private readonly TariffService $tariffService,
private readonly TenantInsuranceService $tenantInsuranceService,
private readonly BillingCalculator $calculator,
) {}
/**
* ساخت Invoice از یک Encounter (PatientSession).
* تعرفه‌ی هر خدمت از Tariff سال جاری (با fallback)، پوشش از قرارداد بیمه‌ی tenant.
* ویزیت به‌عنوان یک آیتم جداگانه با همان قانون پوشش لحاظ می‌شود.
*/
public function createFromSession(PatientSession $session, string $entityType, int $entityId): Invoice
{
$existing = $session->getId() !== null ? $this->invoiceRepo->findBySession($session->getId()) : null;
if ($existing !== null) {
return $existing;
}
$invoice = new Invoice($entityType, $entityId);
$invoice->setPatientSessionId($session->getId())
->setPatientRecordId($session->getRecord()->getId())
->setBaseInsuranceId($session->getInsuranceBaseId())
->setSupplementaryInsuranceId($session->getInsuranceSupplementaryId());
$baseId = $session->getInsuranceBaseId();
$suppId = $session->getInsuranceSupplementaryId();
// ویزیت
$visitPrice = $session->getVisitPriceRials();
if ($visitPrice > 0) {
$baseRule = $this->tenantInsuranceService->coverageRule($entityType, $entityId, $baseId);
$suppRule = $this->tenantInsuranceService->coverageRule($entityType, $entityId, $suppId);
$breakdown = $this->calculator->calculateItem(new Money($visitPrice), $baseRule, $suppRule);
$invoice->addItem(new InvoiceItem($invoice, 'ویزیت', $visitPrice, 1, $breakdown, null));
}
// خدمات
foreach ($session->getServices() as $sessionService) {
$item = $sessionService->getServiceItem();
$unitPrice = $this->tariffService->resolvePrice($item);
$total = new Money($unitPrice);
$baseRule = $this->tenantInsuranceService->coverageRuleForService($entityType, $entityId, $baseId, $item->getId());
$suppRule = $this->tenantInsuranceService->coverageRuleForService($entityType, $entityId, $suppId, $item->getId());
$breakdown = $this->calculator->calculateItem($total, $baseRule, $suppRule);
$invoice->addItem(new InvoiceItem($invoice, $item->getName(), $unitPrice, 1, $breakdown, $item->getId()));
}
$invoice->recalculateTotals();
$this->invoiceRepo->save($invoice);
return $invoice;
}
public function finalize(Invoice $invoice): void
{
$invoice->finalize();
$this->invoiceRepo->save($invoice);
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Billing\Service;
use App\Billing\Contract\ClaimSubmissionResult;
use App\Billing\Contract\ClaimSubmitterInterface;
use App\Billing\Entity\Claim;
/**
* پیاده‌سازی پیش‌فرض: ارسال دستی (آفلاین). مطالبه صرفاً به وضعیت submitted می‌رود
* و ارسال واقعی به بیمه به‌صورت دستی توسط کاربر انجام می‌شود.
* در آینده با یک پیاده‌سازی متصل به API بیمه جایگزین می‌شود (بدون تغییر در ClaimService).
*/
final class ManualClaimSubmitter implements ClaimSubmitterInterface
{
public function submit(Claim $claim): ClaimSubmissionResult
{
return ClaimSubmissionResult::ok();
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace App\Billing\ValueObject;
final readonly class Money
{
public function __construct(public int $rials)
{
if ($rials < 0) {
throw new \InvalidArgumentException('Money cannot be negative');
}
}
public static function zero(): self
{
return new self(0);
}
public function add(Money $o): self
{
return new self($this->rials + $o->rials);
}
public function sub(Money $o): self
{
return new self(max(0, $this->rials - $o->rials));
}
public function percent(float $p): self
{
return new self((int) round($this->rials * $p / 100));
}
public function min(Money $o): self
{
return new self(min($this->rials, $o->rials));
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Billing\ValueObject;
final readonly class ShareBreakdown
{
public function __construct(
public int $totalRials,
public int $baseInsuranceRials,
public int $supplementaryRials,
public int $patientRials,
) {}
public function toArray(): array
{
return [
'total_rials' => $this->totalRials,
'base_insurance_rials' => $this->baseInsuranceRials,
'supplementary_rials' => $this->supplementaryRials,
'patient_rials' => $this->patientRials,
];
}
}
@@ -7,6 +7,8 @@ use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\ClinicService\Repository\ServiceItemRepository;
use App\ClinicService\Repository\ServiceSectionRepository;
use App\ClinicService\Repository\TariffRepository;
use App\ClinicService\Service\TariffService;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Shared\Constant\ErrorCodes;
@@ -32,6 +34,8 @@ class ClinicServiceController extends BaseController
private readonly SubscriptionService $subscriptionService,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly TariffRepository $tariffRepo,
private readonly TariffService $tariffService,
) {}
// ── Service Sections ─────────────────────────────────────────────────────
@@ -157,6 +161,13 @@ class ClinicServiceController extends BaseController
}
}
if (isset($data['insurance_covered'])) {
$item->setInsuranceCovered((bool) $data['insurance_covered']);
}
if (array_key_exists('insurance_price_rials', $data)) {
$item->setInsurancePriceRials($data['insurance_price_rials'] !== null ? (int) $data['insurance_price_rials'] : null);
}
$this->itemRepo->save($item);
return $this->success($item->toArray(), 201);
@@ -181,6 +192,12 @@ class ClinicServiceController extends BaseController
$staff = $data['staff_uuid'] ? $this->staffRepo->findByUuid($data['staff_uuid']) : null;
$item->setStaff($staff);
}
if (isset($data['insurance_covered'])) {
$item->setInsuranceCovered((bool) $data['insurance_covered']);
}
if (array_key_exists('insurance_price_rials', $data)) {
$item->setInsurancePriceRials($data['insurance_price_rials'] !== null ? (int) $data['insurance_price_rials'] : null);
}
$this->itemRepo->save($item);
@@ -206,6 +223,51 @@ class ClinicServiceController extends BaseController
return $this->success(['message' => 'سرویس حذف شد']);
}
// ── Tariffs (تعرفه‌ی نسخه‌دار سالانه) ──────────────────────────────────────
#[Route('/api/v1/service-items/{uuid}/tariffs', methods: ['GET'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function listTariffs(string $uuid, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$item = $this->itemRepo->findByUuid($uuid);
if ($item === null || !$this->ownsSection($item->getSection(), $entityType, $entityId)) {
return $this->error(ErrorCodes::ERR_SERVICE_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SERVICE_NOT_FOUND), 404);
}
$tariffs = $this->tariffRepo->findByService($item->getId());
return $this->success([
'current_year' => $this->tariffService->currentJalaliYear(),
'default_price_rials' => $item->getPriceRials(),
'data' => array_map(fn($t) => $t->toArray(), $tariffs),
]);
}
#[Route('/api/v1/service-items/{uuid}/tariffs/{year}', methods: ['PUT'], requirements: ['year' => '\d+'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function setTariff(string $uuid, int $year, Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$item = $this->itemRepo->findByUuid($uuid);
if ($item === null || !$this->ownsSection($item->getSection(), $entityType, $entityId)) {
return $this->error(ErrorCodes::ERR_SERVICE_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SERVICE_NOT_FOUND), 404);
}
if ($year < 1390 || $year > 1500) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'سال نامعتبر است', 422);
}
$data = json_decode($request->getContent(), true) ?? [];
$price = (int) ($data['price_rials'] ?? 0);
$tariff = $this->tariffService->upsert($item->getId(), $year, $price);
return $this->success(['data' => $tariff->toArray()]);
}
// ── Helpers ──────────────────────────────────────────────────────────────
private function resolveEntity(User $user): array
+12
View File
@@ -36,6 +36,12 @@ class ServiceItem
#[ORM\Column(type: 'boolean')]
private bool $active = true;
#[ORM\Column(name: 'insurance_covered', type: 'boolean')]
private bool $insuranceCovered = false;
#[ORM\Column(name: 'insurance_price_rials', type: 'integer', nullable: true)]
private ?int $insurancePriceRials = null;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
@@ -59,6 +65,8 @@ class ServiceItem
public function getName(): string { return $this->name; }
public function getPriceRials(): int { return $this->priceRials; }
public function isActive(): bool { return $this->active; }
public function isInsuranceCovered(): bool { return $this->insuranceCovered; }
public function getInsurancePriceRials(): ?int { return $this->insurancePriceRials; }
public function getCreatedAt(): int { return $this->createdAt; }
public function getUpdatedAt(): int { return $this->updatedAt; }
@@ -66,6 +74,8 @@ class ServiceItem
public function setName(string $name): self { $this->name = $name; $this->updatedAt = time(); return $this; }
public function setPriceRials(int $price): self { $this->priceRials = $price; $this->updatedAt = time(); return $this; }
public function setActive(bool $active): self { $this->active = $active; $this->updatedAt = time(); return $this; }
public function setInsuranceCovered(bool $v): self { $this->insuranceCovered = $v; $this->updatedAt = time(); return $this; }
public function setInsurancePriceRials(?int $v): self { $this->insurancePriceRials = $v; $this->updatedAt = time(); return $this; }
public function toArray(): array
{
@@ -77,6 +87,8 @@ class ServiceItem
'name' => $this->name,
'price_rials' => $this->priceRials,
'active' => $this->active,
'insurance_covered' => $this->insuranceCovered,
'insurance_price_rials' => $this->insurancePriceRials,
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt,
];
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace App\ClinicService\Entity;
use App\ClinicService\Repository\TariffRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: TariffRepository::class)]
#[ORM\Table(name: 'service_tariffs')]
#[ORM\UniqueConstraint(name: 'uniq_service_tariff_year', columns: ['service_item_id', 'year'])]
#[ORM\Index(columns: ['service_item_id', 'is_active'], name: 'idx_tariff_service_active')]
class Tariff
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(name: 'service_item_id', type: 'integer')]
private int $serviceItemId;
#[ORM\Column(type: 'smallint')]
private int $year;
#[ORM\Column(name: 'price_rials', type: 'integer')]
private int $priceRials = 0;
#[ORM\Column(name: 'is_active', type: 'boolean')]
private bool $isActive = true;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(int $serviceItemId, int $year, int $priceRials = 0)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->serviceItemId = $serviceItemId;
$this->year = $year;
$this->priceRials = $priceRials;
$this->createdAt = time();
$this->updatedAt = time();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getServiceItemId(): int { return $this->serviceItemId; }
public function getYear(): int { return $this->year; }
public function getPriceRials(): int { return $this->priceRials; }
public function isActive(): bool { return $this->isActive; }
public function setPriceRials(int $v): self { $this->priceRials = $v; $this->updatedAt = time(); return $this; }
public function setActive(bool $v): self { $this->isActive = $v; $this->updatedAt = time(); return $this; }
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'service_item_id' => $this->serviceItemId,
'year' => $this->year,
'price_rials' => $this->priceRials,
'is_active' => $this->isActive,
];
}
}
@@ -0,0 +1,48 @@
<?php
namespace App\ClinicService\Repository;
use App\ClinicService\Entity\Tariff;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class TariffRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Tariff::class);
}
public function findForServiceYear(int $serviceItemId, int $year): ?Tariff
{
return $this->findOneBy([
'serviceItemId' => $serviceItemId,
'year' => $year,
'isActive' => true,
]);
}
/** @return Tariff[] */
public function findByService(int $serviceItemId): array
{
return $this->createQueryBuilder('t')
->where('t.serviceItemId = :sid')
->setParameter('sid', $serviceItemId)
->orderBy('t.year', 'DESC')
->getQuery()
->getResult();
}
public function findByUuid(string $uuid): ?Tariff
{
return $this->findOneBy(['uuid' => $uuid]);
}
public function save(Tariff $entity, bool $flush = true): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\ClinicService\Service;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\Tariff;
use App\ClinicService\Repository\TariffRepository;
class TariffService
{
public function __construct(
private readonly TariffRepository $tariffRepo,
) {}
/**
* تعرفه‌ی یک خدمت برای یک سال شمسی.
* اگر تعرفه‌ی آن سال ثبت نشده باشد، به priceRials خود خدمت fallback می‌شود.
*/
public function resolvePrice(ServiceItem $service, ?int $year = null): int
{
$year ??= $this->currentJalaliYear();
$tariff = $service->getId() !== null
? $this->tariffRepo->findForServiceYear($service->getId(), $year)
: null;
return $tariff?->getPriceRials() ?? $service->getPriceRials();
}
public function upsert(int $serviceItemId, int $year, int $priceRials): Tariff
{
$tariff = $this->tariffRepo->findForServiceYear($serviceItemId, $year);
if ($tariff === null) {
$tariff = new Tariff($serviceItemId, $year, $priceRials);
} else {
$tariff->setPriceRials($priceRials)->setActive(true);
}
$this->tariffRepo->save($tariff);
return $tariff;
}
public function currentJalaliYear(): int
{
$fmt = new \IntlDateFormatter(
'en_US@calendar=persian',
\IntlDateFormatter::FULL,
\IntlDateFormatter::NONE,
'Asia/Tehran',
\IntlDateFormatter::TRADITIONAL,
'yyyy'
);
return (int) $fmt->format(time());
}
}
@@ -3,12 +3,19 @@
namespace App\Insurance\Controller;
use App\Auth\Entity\User;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Insurance\Entity\DoctorInsurance;
use App\Insurance\Entity\EntityInsurancePricing;
use App\Insurance\Entity\Insurance;
use App\Insurance\Enum\InsuranceType;
use App\Insurance\Entity\TenantInsurance;
use App\Insurance\Repository\DoctorInsuranceRepository;
use App\Insurance\Repository\EntityInsurancePricingRepository;
use App\Insurance\Repository\InsuranceRepository;
use App\Insurance\Repository\TenantInsuranceRepository;
use App\Insurance\Repository\TenantServiceCoverageRepository;
use App\Insurance\Service\TenantInsuranceService;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Service\FileValidatorService;
@@ -27,10 +34,28 @@ class InsuranceController extends BaseController
private readonly InsuranceRepository $insuranceRepo,
private readonly DoctorInsuranceRepository $doctorInsuranceRepo,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly EntityInsurancePricingRepository $pricingRepo,
private readonly TenantInsuranceRepository $tenantInsuranceRepo,
private readonly TenantServiceCoverageRepository $serviceCoverageRepo,
private readonly TenantInsuranceService $tenantInsuranceService,
private readonly FileValidatorService $fileValidator,
private readonly string $projectDir,
) {}
private function resolveEntity(User $user): array
{
if ($user->hasRole('ROLE_DOCTOR')) {
$doctor = $this->doctorRepo->findByUser($user);
return $doctor !== null ? [EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId()] : [EntityInsurancePricing::TYPE_DOCTOR, null];
}
if ($user->hasRole('ROLE_CLINIC')) {
$clinic = $this->clinicRepo->findByUser($user);
return $clinic !== null ? [EntityInsurancePricing::TYPE_CLINIC, $clinic->getId()] : [EntityInsurancePricing::TYPE_CLINIC, null];
}
return ['unknown', null];
}
// ── Public list ───────────────────────────────────────────────────────────
#[Route('/api/v1/insurances', methods: ['GET'])]
@@ -183,6 +208,232 @@ class InsuranceController extends BaseController
}
}
// ── Entity insurance pricing (visit price by insurance) ───────────────────
#[Route('/api/v1/insurance-pricing', methods: ['GET'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function getInsurancePricing(#[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
$rows = $this->pricingRepo->findByEntity($entityType, $entityId);
$freeVisitPriceRials = 0;
$perInsurance = [];
foreach ($rows as $row) {
if ($row->isFreeVisit()) {
$freeVisitPriceRials = $row->getPatientShareRials();
} else {
$perInsurance[$row->getInsuranceId()] = $row->getPatientShareRials();
}
}
$insurances = array_map(function (Insurance $i) use ($perInsurance) {
return [
'insurance_id' => $i->getId(),
'insurance_name' => $i->getName(),
'type' => $i->getType()->value,
'patient_share_rials' => $perInsurance[$i->getId()] ?? null,
];
}, $this->insuranceRepo->findActive(null));
return $this->success([
'entity_type' => $entityType,
'entity_id' => $entityId,
'free_visit_price_rials' => $freeVisitPriceRials,
'insurances' => $insurances,
]);
}
#[Route('/api/v1/insurance-pricing', methods: ['PUT'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function saveInsurancePricing(Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
$data = json_decode($request->getContent(), true) ?? [];
if (array_key_exists('free_visit_price_rials', $data)) {
$this->upsertPricing($entityType, $entityId, null, (int) $data['free_visit_price_rials']);
}
foreach (($data['insurances'] ?? []) as $row) {
$insuranceId = isset($row['insurance_id']) ? (int) $row['insurance_id'] : null;
if ($insuranceId === null) {
continue;
}
if (!array_key_exists('patient_share_rials', $row) || $row['patient_share_rials'] === null) {
$existing = $this->pricingRepo->findOneForInsurance($entityType, $entityId, $insuranceId);
if ($existing !== null) {
$this->pricingRepo->remove($existing, false);
}
continue;
}
$this->upsertPricing($entityType, $entityId, $insuranceId, (int) $row['patient_share_rials']);
}
$this->pricingRepo->getEntityManager()->flush();
return $this->getInsurancePricing($user);
}
private function upsertPricing(string $entityType, int $entityId, ?int $insuranceId, int $shareRials): void
{
$row = $this->pricingRepo->findOneForInsurance($entityType, $entityId, $insuranceId);
if ($row === null) {
$row = new EntityInsurancePricing($entityType, $entityId, $insuranceId, $shareRials);
} else {
$row->setPatientShareRials($shareRials);
}
$this->pricingRepo->save($row, false);
}
// ── TenantInsurance — قراردادهای بیمه‌ی tenant ─────────────────────────────
#[Route('/api/v1/billing/tenant-insurances', methods: ['GET'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function listTenantInsurances(#[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
$contracts = $this->tenantInsuranceRepo->findActiveByTenant($entityType, $entityId);
$byId = [];
foreach ($this->insuranceRepo->findActive(null) as $ins) {
$byId[$ins->getId()] = ['name' => $ins->getName(), 'type' => $ins->getType()->value];
}
$data = array_map(function (TenantInsurance $c) use ($byId) {
$row = $c->toArray();
$row['insurance_name'] = $byId[$c->getInsuranceId()]['name'] ?? null;
$row['insurance_kind'] = $byId[$c->getInsuranceId()]['type'] ?? null;
return $row;
}, $contracts);
return $this->success(['data' => $data]);
}
#[Route('/api/v1/billing/tenant-insurances', methods: ['POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function activateTenantInsurance(Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
$data = json_decode($request->getContent(), true) ?? [];
$insuranceId = isset($data['insurance_id']) ? (int) $data['insurance_id'] : 0;
if ($insuranceId <= 0) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'insurance_id الزامی است', 422);
}
$contract = $this->tenantInsuranceService->activate(
$entityType,
$entityId,
$insuranceId,
(float) ($data['coverage_percent'] ?? 0),
(int) ($data['franchise_rials'] ?? 0),
isset($data['annual_ceiling_rials']) && $data['annual_ceiling_rials'] !== null
? (int) $data['annual_ceiling_rials'] : null,
);
return $this->success(['data' => $contract->toArray()], 201);
}
#[Route('/api/v1/billing/tenant-insurances/{uuid}', methods: ['PATCH'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function updateTenantInsurance(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$contract = $this->tenantInsuranceRepo->findByUuid($uuid);
if ($contract === null || $contract->getEntityType() !== $entityType || $contract->getEntityId() !== $entityId) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'قرارداد یافت نشد', 404);
}
$data = json_decode($request->getContent(), true) ?? [];
if (array_key_exists('coverage_percent', $data)) {
$contract->setCoveragePercent((float) $data['coverage_percent']);
}
if (array_key_exists('franchise_rials', $data)) {
$contract->setFranchiseRials((int) $data['franchise_rials']);
}
if (array_key_exists('annual_ceiling_rials', $data)) {
$contract->setAnnualCeilingRials($data['annual_ceiling_rials'] !== null ? (int) $data['annual_ceiling_rials'] : null);
}
$this->tenantInsuranceRepo->save($contract);
return $this->success(['data' => $contract->toArray()]);
}
#[Route('/api/v1/billing/tenant-insurances/{uuid}', methods: ['DELETE'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function deactivateTenantInsurance(string $uuid, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$contract = $this->tenantInsuranceRepo->findByUuid($uuid);
if ($contract === null || $contract->getEntityType() !== $entityType || $contract->getEntityId() !== $entityId) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'قرارداد یافت نشد', 404);
}
$this->tenantInsuranceService->deactivate($contract);
return $this->success(['message' => 'قرارداد بیمه غیرفعال شد']);
}
#[Route('/api/v1/billing/tenant-insurances/{uuid}/service-coverage', methods: ['GET'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function listServiceCoverage(string $uuid, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$contract = $this->tenantInsuranceRepo->findByUuid($uuid);
if ($contract === null || $contract->getEntityType() !== $entityType || $contract->getEntityId() !== $entityId) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'قرارداد یافت نشد', 404);
}
$rows = $this->serviceCoverageRepo->findByContract($contract->getId());
return $this->success(['data' => array_map(fn($r) => $r->toArray(), $rows)]);
}
#[Route('/api/v1/billing/tenant-insurances/{uuid}/service-coverage', methods: ['PUT'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function setServiceCoverage(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$contract = $this->tenantInsuranceRepo->findByUuid($uuid);
if ($contract === null || $contract->getEntityType() !== $entityType || $contract->getEntityId() !== $entityId) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'قرارداد یافت نشد', 404);
}
$data = json_decode($request->getContent(), true) ?? [];
$serviceItemId = isset($data['service_item_id']) ? (int) $data['service_item_id'] : 0;
if ($serviceItemId <= 0) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'service_item_id الزامی است', 422);
}
$this->tenantInsuranceService->setServiceCoverage(
$contract,
$serviceItemId,
(bool) ($data['covered'] ?? true),
isset($data['coverage_percent']) && $data['coverage_percent'] !== null ? (float) $data['coverage_percent'] : null,
isset($data['franchise_rials']) && $data['franchise_rials'] !== null ? (int) $data['franchise_rials'] : null,
isset($data['ceiling_rials']) && $data['ceiling_rials'] !== null ? (int) $data['ceiling_rials'] : null,
);
return $this->success(['message' => 'پوشش خدمت ذخیره شد']);
}
// ── DoctorInsurance CRUD ──────────────────────────────────────────────────
#[Route('/api/v1/insurance/', methods: ['POST'])]
@@ -0,0 +1,66 @@
<?php
namespace App\Insurance\Entity;
use App\Insurance\Repository\EntityInsurancePricingRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: EntityInsurancePricingRepository::class)]
#[ORM\Table(name: 'entity_insurance_pricing')]
#[ORM\UniqueConstraint(name: 'uniq_entity_insurance', columns: ['entity_type', 'entity_id', 'insurance_id'])]
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_entity_pricing_owner')]
class EntityInsurancePricing
{
public const TYPE_DOCTOR = 'doctor';
public const TYPE_CLINIC = 'clinic';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(name: 'entity_type', type: 'string', length: 10)]
private string $entityType;
#[ORM\Column(name: 'entity_id', type: 'integer')]
private int $entityId;
#[ORM\Column(name: 'insurance_id', type: 'integer', nullable: true)]
private ?int $insuranceId = null;
#[ORM\Column(name: 'patient_share_rials', type: 'integer')]
private int $patientShareRials = 0;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(string $entityType, int $entityId, ?int $insuranceId, int $patientShareRials = 0)
{
$this->entityType = $entityType;
$this->entityId = $entityId;
$this->insuranceId = $insuranceId;
$this->patientShareRials = $patientShareRials;
$this->updatedAt = time();
}
public function getId(): ?int { return $this->id; }
public function getEntityType(): string { return $this->entityType; }
public function getEntityId(): int { return $this->entityId; }
public function getInsuranceId(): ?int { return $this->insuranceId; }
public function getPatientShareRials(): int { return $this->patientShareRials; }
public function isFreeVisit(): bool { return $this->insuranceId === null; }
public function setPatientShareRials(int $v): self { $this->patientShareRials = $v; $this->updatedAt = time(); return $this; }
public function toArray(): array
{
return [
'id' => $this->id,
'entity_type' => $this->entityType,
'entity_id' => $this->entityId,
'insurance_id' => $this->insuranceId,
'patient_share_rials' => $this->patientShareRials,
];
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
namespace App\Insurance\Entity;
use App\Insurance\Repository\TenantInsuranceRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: TenantInsuranceRepository::class)]
#[ORM\Table(name: 'tenant_insurances')]
#[ORM\UniqueConstraint(name: 'uniq_tenant_insurance_version', columns: ['entity_type', 'entity_id', 'insurance_id', 'version'])]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'is_active'], name: 'idx_tenant_insurance_active')]
class TenantInsurance
{
public const TYPE_DOCTOR = 'doctor';
public const TYPE_CLINIC = 'clinic';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(name: 'entity_type', type: 'string', length: 10)]
private string $entityType;
#[ORM\Column(name: 'entity_id', type: 'integer')]
private int $entityId;
#[ORM\Column(name: 'insurance_id', type: 'integer')]
private int $insuranceId;
#[ORM\Column(type: 'integer')]
private int $version = 1;
#[ORM\Column(name: 'is_active', type: 'boolean')]
private bool $isActive = true;
#[ORM\Column(name: 'coverage_percent', type: 'decimal', precision: 5, scale: 2)]
private string $coveragePercent = '0.00';
#[ORM\Column(name: 'franchise_rials', type: 'integer')]
private int $franchiseRials = 0;
#[ORM\Column(name: 'annual_ceiling_rials', type: 'integer', nullable: true)]
private ?int $annualCeilingRials = null;
#[ORM\Column(name: 'effective_from', type: 'integer')]
private int $effectiveFrom;
#[ORM\Column(name: 'effective_to', type: 'integer', nullable: true)]
private ?int $effectiveTo = null;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(string $entityType, int $entityId, int $insuranceId, int $version = 1)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->entityType = $entityType;
$this->entityId = $entityId;
$this->insuranceId = $insuranceId;
$this->version = $version;
$this->effectiveFrom = time();
$this->createdAt = time();
$this->updatedAt = time();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getEntityType(): string { return $this->entityType; }
public function getEntityId(): int { return $this->entityId; }
public function getInsuranceId(): int { return $this->insuranceId; }
public function getVersion(): int { return $this->version; }
public function isActive(): bool { return $this->isActive; }
public function getCoveragePercent(): float { return (float) $this->coveragePercent; }
public function getFranchiseRials(): int { return $this->franchiseRials; }
public function getAnnualCeilingRials(): ?int { return $this->annualCeilingRials; }
public function getEffectiveFrom(): int { return $this->effectiveFrom; }
public function getEffectiveTo(): ?int { return $this->effectiveTo; }
public function setActive(bool $v): self { $this->isActive = $v; $this->updatedAt = time(); return $this; }
public function setCoveragePercent(float $v): self { $this->coveragePercent = (string) $v; $this->updatedAt = time(); return $this; }
public function setFranchiseRials(int $v): self { $this->franchiseRials = $v; $this->updatedAt = time(); return $this; }
public function setAnnualCeilingRials(?int $v): self { $this->annualCeilingRials = $v; $this->updatedAt = time(); return $this; }
public function setEffectiveTo(?int $v): self { $this->effectiveTo = $v; $this->updatedAt = time(); return $this; }
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'entity_type' => $this->entityType,
'entity_id' => $this->entityId,
'insurance_id' => $this->insuranceId,
'version' => $this->version,
'is_active' => $this->isActive,
'coverage_percent' => (float) $this->coveragePercent,
'franchise_rials' => $this->franchiseRials,
'annual_ceiling_rials' => $this->annualCeilingRials,
'effective_from' => $this->effectiveFrom,
'effective_to' => $this->effectiveTo,
];
}
}
@@ -0,0 +1,78 @@
<?php
namespace App\Insurance\Entity;
use App\Insurance\Repository\TenantServiceCoverageRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: TenantServiceCoverageRepository::class)]
#[ORM\Table(name: 'tenant_service_coverage')]
#[ORM\UniqueConstraint(name: 'uniq_tenant_service_coverage', columns: ['tenant_insurance_id', 'service_item_id'])]
#[ORM\Index(columns: ['service_item_id'], name: 'idx_tsc_service')]
class TenantServiceCoverage
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(name: 'tenant_insurance_id', type: 'integer')]
private int $tenantInsuranceId;
#[ORM\Column(name: 'service_item_id', type: 'integer')]
private int $serviceItemId;
#[ORM\Column(type: 'boolean')]
private bool $covered = true;
#[ORM\Column(name: 'coverage_percent', type: 'decimal', precision: 5, scale: 2, nullable: true)]
private ?string $coveragePercent = null;
#[ORM\Column(name: 'franchise_rials', type: 'integer', nullable: true)]
private ?int $franchiseRials = null;
#[ORM\Column(name: 'ceiling_rials', type: 'integer', nullable: true)]
private ?int $ceilingRials = null;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(int $tenantInsuranceId, int $serviceItemId)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->tenantInsuranceId = $tenantInsuranceId;
$this->serviceItemId = $serviceItemId;
$this->updatedAt = time();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getTenantInsuranceId(): int { return $this->tenantInsuranceId; }
public function getServiceItemId(): int { return $this->serviceItemId; }
public function isCovered(): bool { return $this->covered; }
public function getCoveragePercent(): ?float { return $this->coveragePercent !== null ? (float) $this->coveragePercent : null; }
public function getFranchiseRials(): ?int { return $this->franchiseRials; }
public function getCeilingRials(): ?int { return $this->ceilingRials; }
public function setCovered(bool $v): self { $this->covered = $v; $this->updatedAt = time(); return $this; }
public function setCoveragePercent(?float $v): self { $this->coveragePercent = $v !== null ? (string) $v : null; $this->updatedAt = time(); return $this; }
public function setFranchiseRials(?int $v): self { $this->franchiseRials = $v; $this->updatedAt = time(); return $this; }
public function setCeilingRials(?int $v): self { $this->ceilingRials = $v; $this->updatedAt = time(); return $this; }
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'tenant_insurance_id' => $this->tenantInsuranceId,
'service_item_id' => $this->serviceItemId,
'covered' => $this->covered,
'coverage_percent' => $this->getCoveragePercent(),
'franchise_rials' => $this->franchiseRials,
'ceiling_rials' => $this->ceilingRials,
];
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Insurance\Repository;
use App\Insurance\Entity\EntityInsurancePricing;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class EntityInsurancePricingRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, EntityInsurancePricing::class);
}
/** @return EntityInsurancePricing[] */
public function findByEntity(string $entityType, int $entityId): array
{
return $this->findBy(['entityType' => $entityType, 'entityId' => $entityId]);
}
public function findOneForInsurance(string $entityType, int $entityId, ?int $insuranceId): ?EntityInsurancePricing
{
return $this->findOneBy([
'entityType' => $entityType,
'entityId' => $entityId,
'insuranceId' => $insuranceId,
]);
}
public function save(EntityInsurancePricing $entity, bool $flush = true): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
public function remove(EntityInsurancePricing $entity, bool $flush = true): void
{
$this->getEntityManager()->remove($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
}
@@ -0,0 +1,74 @@
<?php
namespace App\Insurance\Repository;
use App\Insurance\Entity\TenantInsurance;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class TenantInsuranceRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TenantInsurance::class);
}
/** @return TenantInsurance[] */
public function findActiveByTenant(string $entityType, int $entityId): array
{
return $this->createQueryBuilder('t')
->where('t.entityType = :type')
->andWhere('t.entityId = :id')
->andWhere('t.isActive = true')
->setParameter('type', $entityType)
->setParameter('id', $entityId)
->orderBy('t.insuranceId', 'ASC')
->getQuery()
->getResult();
}
public function findByUuid(string $uuid): ?TenantInsurance
{
return $this->findOneBy(['uuid' => $uuid]);
}
public function findActiveContract(string $entityType, int $entityId, int $insuranceId): ?TenantInsurance
{
return $this->createQueryBuilder('t')
->where('t.entityType = :type')
->andWhere('t.entityId = :id')
->andWhere('t.insuranceId = :ins')
->andWhere('t.isActive = true')
->setParameter('type', $entityType)
->setParameter('id', $entityId)
->setParameter('ins', $insuranceId)
->orderBy('t.version', 'DESC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
public function latestVersion(string $entityType, int $entityId, int $insuranceId): int
{
$max = $this->createQueryBuilder('t')
->select('MAX(t.version)')
->where('t.entityType = :type')
->andWhere('t.entityId = :id')
->andWhere('t.insuranceId = :ins')
->setParameter('type', $entityType)
->setParameter('id', $entityId)
->setParameter('ins', $insuranceId)
->getQuery()
->getSingleScalarResult();
return (int) ($max ?? 0);
}
public function save(TenantInsurance $entity, bool $flush = true): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Insurance\Repository;
use App\Insurance\Entity\TenantServiceCoverage;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class TenantServiceCoverageRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TenantServiceCoverage::class);
}
public function findOneFor(int $tenantInsuranceId, int $serviceItemId): ?TenantServiceCoverage
{
return $this->findOneBy([
'tenantInsuranceId' => $tenantInsuranceId,
'serviceItemId' => $serviceItemId,
]);
}
/** @return TenantServiceCoverage[] */
public function findByContract(int $tenantInsuranceId): array
{
return $this->findBy(['tenantInsuranceId' => $tenantInsuranceId]);
}
public function save(TenantServiceCoverage $entity, bool $flush = true): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
public function remove(TenantServiceCoverage $entity, bool $flush = true): void
{
$this->getEntityManager()->remove($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
}
@@ -0,0 +1,147 @@
<?php
namespace App\Insurance\Service;
use App\Insurance\Entity\TenantInsurance;
use App\Insurance\Repository\InsuranceRepository;
use App\Insurance\Repository\TenantInsuranceRepository;
use App\Insurance\Repository\TenantServiceCoverageRepository;
use App\Insurance\ValueObject\CoverageRule;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
class TenantInsuranceService
{
public function __construct(
private readonly TenantInsuranceRepository $repo,
private readonly InsuranceRepository $insuranceRepo,
private readonly TenantServiceCoverageRepository $coverageRepo,
) {}
/**
* فعال‌سازی یا به‌روزرسانی قرارداد بیمه برای یک tenant.
* اگر قرارداد فعالی موجود باشد، همان ویرایش می‌شود؛ در غیر این صورت نسخه‌ی جدید ساخته می‌شود.
*/
public function activate(
string $entityType,
int $entityId,
int $insuranceId,
float $coveragePercent,
int $franchiseRials = 0,
?int $annualCeilingRials = null,
): TenantInsurance {
if ($this->insuranceRepo->find($insuranceId) === null) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'بیمه یافت نشد', 404);
}
$contract = $this->repo->findActiveContract($entityType, $entityId, $insuranceId);
if ($contract === null) {
$version = $this->repo->latestVersion($entityType, $entityId, $insuranceId) + 1;
$contract = new TenantInsurance($entityType, $entityId, $insuranceId, $version);
}
$contract->setCoveragePercent($coveragePercent)
->setFranchiseRials($franchiseRials)
->setAnnualCeilingRials($annualCeilingRials)
->setActive(true);
$this->repo->save($contract);
return $contract;
}
public function deactivate(TenantInsurance $contract): void
{
$contract->setActive(false)->setEffectiveTo(time());
$this->repo->save($contract);
}
/**
* بررسی فعال‌بودن یک بیمه برای tenant. در پذیرش/صورتحساب استفاده می‌شود.
*/
public function assertActive(string $entityType, int $entityId, int $insuranceId): TenantInsurance
{
$contract = $this->repo->findActiveContract($entityType, $entityId, $insuranceId);
if ($contract === null) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'این بیمه برای این کلینیک/پزشک فعال نیست', 422);
}
return $contract;
}
/**
* قانون پوشش یک بیمه برای tenant جاری (برای BillingCalculator).
* اگر قرارداد فعالی نباشد، notCovered برمی‌گردد.
*/
public function coverageRule(string $entityType, int $entityId, ?int $insuranceId): CoverageRule
{
if ($insuranceId === null) {
return CoverageRule::notCovered();
}
$contract = $this->repo->findActiveContract($entityType, $entityId, $insuranceId);
if ($contract === null) {
return CoverageRule::notCovered();
}
return new CoverageRule(
coveragePercent: $contract->getCoveragePercent(),
franchiseRials: $contract->getFranchiseRials(),
ceilingRials: $contract->getAnnualCeilingRials(),
covered: true,
);
}
/**
* قانون پوشش یک خدمت خاص تحت بیمه‌ی tenant.
* اگر override خدمت موجود باشد اعمال می‌شود؛ فیلدهای null از قرارداد ارث می‌برند.
*/
public function coverageRuleForService(string $entityType, int $entityId, ?int $insuranceId, int $serviceItemId): CoverageRule
{
if ($insuranceId === null) {
return CoverageRule::notCovered();
}
$contract = $this->repo->findActiveContract($entityType, $entityId, $insuranceId);
if ($contract === null) {
return CoverageRule::notCovered();
}
$override = $this->coverageRepo->findOneFor($contract->getId(), $serviceItemId);
if ($override !== null && !$override->isCovered()) {
return CoverageRule::notCovered();
}
return new CoverageRule(
coveragePercent: $override?->getCoveragePercent() ?? $contract->getCoveragePercent(),
franchiseRials: $override?->getFranchiseRials() ?? $contract->getFranchiseRials(),
ceilingRials: $override?->getCeilingRials() ?? $contract->getAnnualCeilingRials(),
covered: true,
);
}
/** @return array{covered: bool, percent: float|null, franchise: int|null, ceiling: int|null}|null */
public function getServiceCoverage(int $tenantInsuranceId, int $serviceItemId): ?array
{
$override = $this->coverageRepo->findOneFor($tenantInsuranceId, $serviceItemId);
return $override?->toArray();
}
public function setServiceCoverage(
TenantInsurance $contract,
int $serviceItemId,
bool $covered,
?float $coveragePercent,
?int $franchiseRials,
?int $ceilingRials,
): void {
$override = $this->coverageRepo->findOneFor($contract->getId(), $serviceItemId)
?? new \App\Insurance\Entity\TenantServiceCoverage($contract->getId(), $serviceItemId);
$override->setCovered($covered)
->setCoveragePercent($coveragePercent)
->setFranchiseRials($franchiseRials)
->setCeilingRials($ceilingRials);
$this->coverageRepo->save($override);
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Insurance\ValueObject;
final readonly class CoverageRule
{
public function __construct(
public float $coveragePercent,
public int $franchiseRials,
public ?int $ceilingRials,
public bool $covered = true,
) {}
public static function notCovered(): self
{
return new self(0.0, 0, null, false);
}
}
+3
View File
@@ -79,6 +79,9 @@ class PatientSession
public function getUuid(): string { return $this->uuid; }
public function getRecord(): PatientRecord { return $this->record; }
public function getAppointment(): ?Appointment { return $this->appointment; }
public function getInsuranceBaseId(): ?int { return $this->insuranceBaseId; }
public function getInsuranceSupplementaryId(): ?int { return $this->insuranceSupplementaryId; }
public function getServices(): Collection { return $this->services; }
public function getVisitPriceRials(): int { return $this->visitPriceRials; }
public function getBaseInsuranceDiscountPercent(): float { return (float) $this->baseInsuranceDiscountPercent; }
public function getSupplementaryDiscountPercent(): float { return (float) $this->supplementaryDiscountPercent; }
+77
View File
@@ -0,0 +1,77 @@
<?php
namespace App\Tests\Billing;
use App\Billing\Service\BillingCalculator;
use App\Billing\ValueObject\Money;
use App\Insurance\ValueObject\CoverageRule;
use PHPUnit\Framework\TestCase;
class BillingCalculatorTest extends TestCase
{
private BillingCalculator $calc;
protected function setUp(): void
{
$this->calc = new BillingCalculator();
}
public function testNoInsuranceAllPatient(): void
{
$b = $this->calc->calculateItem(new Money(600_000), null, null);
$this->assertSame(600_000, $b->patientRials);
$this->assertSame(0, $b->baseInsuranceRials);
$this->assertSame(0, $b->supplementaryRials);
}
public function testBaseAndSupplementary(): void
{
// کل 600,000؛ پایه 70% → 420,000؛ مکمل روی 180,000 با ~66.667% → 120,000؛ بیمار 60,000
$base = new CoverageRule(coveragePercent: 70, franchiseRials: 0, ceilingRials: null);
$supp = new CoverageRule(coveragePercent: 66.6667, franchiseRials: 0, ceilingRials: null);
$b = $this->calc->calculateItem(new Money(600_000), $base, $supp);
$this->assertSame(420_000, $b->baseInsuranceRials);
$this->assertSame(120_000, $b->supplementaryRials);
$this->assertSame(60_000, $b->patientRials);
$this->assertSame(
$b->totalRials,
$b->baseInsuranceRials + $b->supplementaryRials + $b->patientRials
);
}
public function testBaseOnly(): void
{
$base = new CoverageRule(coveragePercent: 70, franchiseRials: 0, ceilingRials: null);
$b = $this->calc->calculateItem(new Money(600_000), $base, null);
$this->assertSame(420_000, $b->baseInsuranceRials);
$this->assertSame(0, $b->supplementaryRials);
$this->assertSame(180_000, $b->patientRials);
}
public function testBaseCeilingCapsShare(): void
{
// پایه 70% = 420,000 ولی سقف 300,000 → بیمار باقی را می‌دهد
$base = new CoverageRule(coveragePercent: 70, franchiseRials: 0, ceilingRials: 300_000);
$b = $this->calc->calculateItem(new Money(600_000), $base, null);
$this->assertSame(300_000, $b->baseInsuranceRials);
$this->assertSame(300_000, $b->patientRials);
}
public function testFranchiseAddedToPatient(): void
{
// پایه 100% ولی فرانشیز 50,000 سهم بیمار
$base = new CoverageRule(coveragePercent: 100, franchiseRials: 50_000, ceilingRials: null);
$b = $this->calc->calculateItem(new Money(600_000), $base, null);
$this->assertSame(50_000, $b->patientRials);
$this->assertSame(600_000, $b->baseInsuranceRials);
}
public function testNotCoveredRule(): void
{
$b = $this->calc->calculateItem(new Money(600_000), CoverageRule::notCovered(), CoverageRule::notCovered());
$this->assertSame(600_000, $b->patientRials);
$this->assertSame(0, $b->baseInsuranceRials);
}
}