diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts
index 2555c68a..cd2f4afc 100644
--- a/assets/admin/types/index.ts
+++ b/assets/admin/types/index.ts
@@ -539,7 +539,6 @@ export interface ServiceItem {
staff_members?: { uuid: string; full_name: string }[];
active: boolean;
insurance_covered?: boolean;
- insurance_price_rials?: number | null;
duration_minutes?: number | null;
bookable?: boolean;
}
@@ -670,7 +669,14 @@ export interface PatientSession {
base_insurance_discount_percent: string;
supplementary_discount_percent: string;
services_total_rials: number;
+ /** تفکیک بیمه — سرور محاسبه میکند؛ کلاینت هرگز خودش نسازد */
+ gross_total_rials?: number;
+ base_insurance_rials?: number;
+ supplementary_insurance_rials?: number;
+ patient_share_rials?: number;
final_price_rials: number;
+ remaining_rials?: number;
+ paid_total_rials?: number;
payment_method: string;
is_paid?: boolean;
services?: SessionServiceLine[];
diff --git a/docs/api/clinic-services.md b/docs/api/clinic-services.md
index 5933f378..7540fb5a 100644
--- a/docs/api/clinic-services.md
+++ b/docs/api/clinic-services.md
@@ -103,7 +103,6 @@
"price_rials": 500000,
"active": true,
"insurance_covered": false,
- "insurance_price_rials": null,
"duration_minutes": 50,
"bookable": true,
"created_at": 1718000000,
@@ -137,7 +136,6 @@ refresh مستقیم هم کار کند، بنابراین فیلترکردن س
"price_rials": 850000,
"active": true,
"insurance_covered": true,
- "insurance_price_rials": null,
"duration_minutes": 30,
"bookable": true,
"staff": { "uuid": "...", "full_name": "مریم امینی" },
@@ -213,7 +211,6 @@ refresh مستقیم هم کار کند، بنابراین فیلترکردن س
"price_rials": 500000,
"staff_uuid": "...",
"insurance_covered": true,
- "insurance_price_rials": 200000,
"duration_minutes": 50,
"bookable": true
}
@@ -227,7 +224,6 @@ refresh مستقیم هم کار کند، بنابراین فیلترکردن س
| staff_uuids | UUID[] | ❌ — پرسنل مسئول (چند نفر). ترجیح داده میشود |
| staff_uuid | UUID | ❌ — legacy تکپرسنل (اگر `staff_uuids` نباشد استفاده میشود) |
| insurance_covered | boolean | ❌ (پیشفرض false) — **deprecated برای نوشتن.** پنل ادمین دیگر این فیلد را نمیفرستد؛ مقدارش بهصورت خودکار از ردیفهای پوشش بیمه همگام میشود (به [insurance.md](insurance.md#put-apiv1billingtenant-insurancesuuidservice-coverage) نگاه کن). endpoint هنوز آن را میپذیرد تا کلاینتهای قدیمی نشکنند، ولی ذخیرهی پوشش بعداً آن را بازنویسی میکند |
-| insurance_price_rials | integer\|null | ❌ — **deprecated.** سهم تقریبی بیمار؛ از فرم سرویس حذف شد. محاسبهی دقیق سهم بیمار از `TenantServiceCoverage` انجام میشود |
| duration_minutes | integer\|null | ❌ — «زمان متوسط» انجام خدمت به دقیقه (`""`/`null` = بدون مقدار) |
| bookable | boolean | ❌ (پیشفرض false) — «نمایش در نوبتدهی». فقط سرویسهای `bookable=true` در حالت نوبتدهی سرویسی قابلانتخاباند |
| inventory_package_uuid | UUID\|null | ❌ — پکیج کالای مصرفی این خدمت ([inventory.md](inventory.md)). `null`/`""` یعنی قطع اتصال. پکیج باید متعلق به همان مطب/کلینیک باشد وگرنه `422 ERR_VALIDATION_001` با فیلد `inventory_package_uuid` |
@@ -235,7 +231,7 @@ refresh مستقیم هم کار کند، بنابراین فیلترکردن س
> `bookable` در `PATCH /api/v1/service-item/{uuid}` هم به همین شکل پذیرفته میشود.
-**Response 201:** ServiceItem object (شامل `insurance_covered` و `insurance_price_rials`)
+**Response 201:** ServiceItem object (شامل `insurance_covered`)
> **قیمت واحد:** هنگام ساخت سرویس، یک تعرفه برای **سال جاری** با همان `price_rials` بهصورت خودکار ثبت میشود. قیمت سرویس = تعرفهی سال جاری است و همهجا (صورتحساب، مراجعه، مطالبات) از همین قیمت استفاده میشود.
@@ -252,7 +248,6 @@ refresh مستقیم هم کار کند، بنابراین فیلترکردن س
"staff_uuid": null,
"active": false,
"insurance_covered": true,
- "insurance_price_rials": 250000,
"duration_minutes": 30
}
```
diff --git a/docs/api/patient.md b/docs/api/patient.md
index 60fbe121..30ef20eb 100644
--- a/docs/api/patient.md
+++ b/docs/api/patient.md
@@ -340,7 +340,12 @@ Returns paginated sessions for a patient record.
"base_insurance_discount_percent": "10.00",
"supplementary_discount_percent": "5.00",
"services_total_rials": 50000,
+ "gross_total_rials": 250000,
+ "base_insurance_rials": 20000,
+ "supplementary_insurance_rials": 0,
+ "patient_share_rials": 230000,
"final_price_rials": 230000,
+ "remaining_rials": 0,
"payment_method": "cash",
"is_paid": true,
"services": [
@@ -378,6 +383,20 @@ Returns paginated sessions for a patient record.
| `invoice_status` | string\|null | `draft`\|`finalized`\|`paid`\|`void` |
| `patient_debt_rials` | int | مانده بدهی سهم بیمار؛ `0` اگر تسویه شده، وگرنه سهم بیمارِ فاکتور یا `final_price_rials` |
+**تفکیک بیمه (پایا روی خود مراجعه):**
+
+| Field | Type | Notes |
+|-------|------|-------|
+| `gross_total_rials` | int | مبلغ کل پیش از بیمه: ویزیت + خدمات + کالاهای مصرفی |
+| `base_insurance_rials` | int | سهم بیمه پایه |
+| `supplementary_insurance_rials` | int | سهم بیمه تکمیلی (روی **باقیمانده پس از پایه** محاسبه میشود، نه روی کل) |
+| `patient_share_rials` | int | سهم بیمار پیش از تخفیف دستی؛ همیشه برابر `final_price_rials` |
+| `remaining_rials` | int | مانده: `max(0, patient_share − discount_rials − paid_total_rials)` |
+
+ثابت همیشگی: `gross_total_rials = base_insurance_rials + supplementary_insurance_rials + patient_share_rials`
+
+> این مقادیر را **سرور** با `BillingCalculator` محاسبه و روی `patient_sessions` ذخیره میکند (`PatientSession::applyShares`). کلاینت هرگز نباید سهمها یا مانده را خودش بسازد — صفحهی تکمیل پرداخت، مودال فاکتور و داشبورد Claims همگی باید همین فیلدها را بخوانند تا اختلاف محاسباتی ممکن نباشد. مراجعات پیش از این تغییر با `patient_share = final_price` و سهم بیمه صفر backfill شدهاند.
+
> **ثبت پرداخت («تکمیل پرداخت»):** از همان `PATCH /api/v1/session/{uuid}` با بدنهی `{ "payment_method": "cash" }` استفاده میشود؛ پس از آن `is_paid=true` و `patient_debt_rials=0` میشود. مشاهدهی فاکتور از `GET /api/v1/billing/invoices/{invoice_uuid}` (این endpoint اکنون برای منشیِ فعال هم در دسترس است).
**Errors:**
@@ -483,10 +502,13 @@ Creates a new visit session for a patient record.
- `inventory_package_uuid` (اختیاری): مرجع پکیج مصرفی ([inventory](inventory.md))؛ فقط پکیج متعلق به همان tenant پذیرفته میشود، وگرنه بیصدا نادیده گرفته میشود. روی قیمت اثری ندارد (فقط مرجع).
- `consumables` (اختیاری): کالاهای مصرفی از انبار ([inventory](inventory.md)). `price_rials` snapshot از `InventoryItem.price`؛ `quantity` (پیشفرض ۱، حداقل ۱). کالاها **پوشش بیمه ندارند** و مبلغ کاملشان به `final_price_rials` (سهم بیمار) اضافه میشود. آیتم ناموجود یا متعلق به tenant دیگر بیصدا رد میشود (همرفتار با `services`). پاسخ شامل `consumables[]` (با `line_total_rials`) و `consumables_total_rials` است.
- `services`: array of service items to attach; `price_rials` snapshot از ServiceItem؛ `quantity` (پیشفرض ۱) → `line_total_rials = price_rials × quantity`. هر `SessionService` در پاسخ `quantity` و `line_total_rials` دارد.
+- `base_insurance_discount_percent` / `supplementary_discount_percent`: **ورودی محاسبه نیستند.** هر مقداری که ارسال شود نادیده گرفته و از درصد قرارداد فعال (`TenantInsurance.coveragePercent`) بازنویسی میشود؛ صرفاً snapshot برای نمایش/گزارشاند.
- `final_price_rials` (سهم بیمار) به این صورت محاسبه میشود:
- - **ویزیت:** `round(visit_price × (1 - base%) × (1 - supp%))` با درصدهای انتخابشده در فرم.
+ - **ویزیت:** با قاعدهی پوشش قرارداد (`TenantInsuranceService::coverageRule`) از طریق `BillingCalculator` — همان مسیری که `InvoiceService` برای صدور فاکتور میرود. (تا پیش از این، ویزیت با فرمول درصدی جدا و inline حساب میشد و با فاکتور واگرا میشد.)
- **هر خدمت:** سهم بیمار با قاعدهی پوشش همان بیمهگر برای همان خدمت (`TenantServiceCoverage` از طریق `BillingCalculator`) محاسبه میشود؛ یعنی فقط خدمتی که بیمهی انتخابشده آن را پوشش میدهد تخفیف میگیرد (درصد/فرانشیز/سقف؛ مقدار نبودِ override از قرارداد ارث میبرد). خدمتِ بدون پوشش، کامل بر عهدهی بیمار است.
- `final_price_rials = سهم بیمار ویزیت + Σ(سهم بیمار هر خدمت) + Σ(کالاهای مصرفی)` و `services_total_rials = Σ(price × quantity)` (قیمت کامل خدمات، بدون بیمه). کالاهای مصرفی در `consumables_total_rials` جدا گزارش میشوند.
+ - **گیت پوشش:** اگر `ServiceItem.insurance_covered` غیرفعال باشد یا برای tenant قرارداد فعالی نباشد، هیچ پوششی اعمال نمیشود و کل مبلغ سهم بیمار است. این پرچم دستی ست نمیشود؛ از ردیفهای `TenantServiceCoverage` سینک میشود ([insurance.md](insurance.md)).
+ - **سقف:** `annual_ceiling_rials` با وجود نامش بهصورت **سقف هر قلم** اعمال میشود؛ انباشت سالانهای در کد وجود ندارد.
- این محاسبه دقیقاً همان منطقِ صورتحساب/مطالبات است؛ پیشنمایش پنل هم همین قاعده را سمت کلاینت آینه میکند.
**اتصال خودکار مطالبهی بیمه:** اگر session دارای `insurance_base_id` یا `insurance_supplementary_id` باشد، پس از ثبت بهصورت خودکار صورتحساب ساخته و نهایی میشود و مطالبه(های) بیمه در وضعیت `pending` ایجاد میگردد (پایه/مکمل، فقط برای سهم بیمه > ۰). این مطالبات در صفحهی [مطالبات بیمه](billing.md) قابل پیگیری و ارسالاند. خطا در این مرحله ثبت session را خراب نمیکند (لاگ میشود). برای هر صورتحساب فقط یکبار مطالبه ساخته میشود.
diff --git a/migrations/Version20260718184932.php b/migrations/Version20260718184932.php
new file mode 100644
index 00000000..cb1bca0d
--- /dev/null
+++ b/migrations/Version20260718184932.php
@@ -0,0 +1,47 @@
+addSql('ALTER TABLE patient_sessions '
+ . 'ADD gross_total_rials INT DEFAULT 0 NOT NULL, '
+ . 'ADD base_insurance_rials INT DEFAULT 0 NOT NULL, '
+ . 'ADD supplementary_insurance_rials INT DEFAULT 0 NOT NULL, '
+ . 'ADD patient_share_rials INT DEFAULT 0 NOT NULL');
+
+ // Existing sessions were computed without an insurance breakdown: the whole
+ // amount was the patient's share. Backfilling this way keeps their totals unchanged.
+ $this->addSql('UPDATE patient_sessions SET '
+ . 'patient_share_rials = final_price_rials, '
+ . 'gross_total_rials = final_price_rials, '
+ . 'base_insurance_rials = 0, '
+ . 'supplementary_insurance_rials = 0');
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('ALTER TABLE patient_sessions '
+ . 'DROP gross_total_rials, '
+ . 'DROP base_insurance_rials, '
+ . 'DROP supplementary_insurance_rials, '
+ . 'DROP patient_share_rials');
+ }
+}
diff --git a/src/Billing/Controller/BillingController.php b/src/Billing/Controller/BillingController.php
index 444dbf24..7863c8b4 100644
--- a/src/Billing/Controller/BillingController.php
+++ b/src/Billing/Controller/BillingController.php
@@ -9,11 +9,10 @@ use App\Billing\Repository\InvoiceItemRepository;
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\Insurance\Repository\InsuranceRepository;
use App\Patient\Repository\PatientRecordRepository;
use App\Patient\Repository\PatientSessionRepository;
+use App\Patient\Security\PatientRecordScopeResolver;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -35,11 +34,8 @@ class BillingController extends BaseController
private readonly ClaimRepository $claimRepo,
private readonly PatientSessionRepository $sessionRepo,
private readonly PatientRecordRepository $recordRepo,
- private readonly DoctorRepository $doctorRepo,
- private readonly ClinicRepository $clinicRepo,
private readonly InsuranceRepository $insuranceRepo,
- private readonly \App\Secretary\Repository\DoctorSecretaryRepository $secretaryRepo,
- private readonly \App\Auth\Repository\UserActiveContextRepository $contextRepo,
+ private readonly PatientRecordScopeResolver $scopeResolver,
) {}
/**
@@ -351,29 +347,13 @@ class BillingController extends BaseController
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];
- }
- if ($user->hasRole('ROLE_SECRETARY')) {
- $dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
- if ($dbUuid !== null) {
- $clinic = $this->clinicRepo->findByUuid($dbUuid);
- if ($clinic !== null && $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic) !== null) {
- return ['clinic', $clinic->getId()];
- }
- $doctor = $this->doctorRepo->findByUuid($dbUuid);
- if ($doctor !== null && $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor) !== null) {
- return ['doctor', $doctor->getId()];
- }
- }
- }
- return ['unknown', null];
+ return $this->scopeResolver->resolve($user)->toLegacyTuple();
}
}
diff --git a/src/ClinicService/Controller/ClinicServiceController.php b/src/ClinicService/Controller/ClinicServiceController.php
index 7e2d0b34..4d38b799 100644
--- a/src/ClinicService/Controller/ClinicServiceController.php
+++ b/src/ClinicService/Controller/ClinicServiceController.php
@@ -296,9 +296,6 @@ 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);
- }
if (array_key_exists('duration_minutes', $data)) {
$dm = $data['duration_minutes'];
$item->setDurationMinutes(($dm === null || $dm === '') ? null : (int) $dm);
@@ -350,9 +347,6 @@ 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);
- }
if (array_key_exists('duration_minutes', $data)) {
$dm = $data['duration_minutes'];
$item->setDurationMinutes(($dm === null || $dm === '') ? null : (int) $dm);
diff --git a/src/ClinicService/Entity/ServiceItem.php b/src/ClinicService/Entity/ServiceItem.php
index d5ab09a7..9da00997 100644
--- a/src/ClinicService/Entity/ServiceItem.php
+++ b/src/ClinicService/Entity/ServiceItem.php
@@ -52,6 +52,10 @@ class ServiceItem
#[ORM\Column(name: 'insurance_covered', type: 'boolean')]
private bool $insuranceCovered = false;
+ /**
+ * @deprecated منبع حقیقتِ پوشش، TenantServiceCoverage است و هیچ محاسبهای این مقدار
+ * را نمیخواند. ستون برای دادهی تاریخی مانده ولی نه نوشته میشود و نه منتشر.
+ */
#[ORM\Column(name: 'insurance_price_rials', type: 'integer', nullable: true)]
private ?int $insurancePriceRials = null;
@@ -148,7 +152,6 @@ class ServiceItem
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 getDurationMinutes(): ?int { return $this->durationMinutes; }
public function isBookable(): bool { return $this->bookable; }
public function getInventoryPackageId(): ?int { return $this->inventoryPackageId; }
@@ -187,7 +190,6 @@ class ServiceItem
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 setDurationMinutes(?int $v): self { $this->durationMinutes = $v; $this->updatedAt = time(); return $this; }
public function setBookable(bool $v): self { $this->bookable = $v; $this->updatedAt = time(); return $this; }
public function setInventoryPackageId(?int $v): self { $this->inventoryPackageId = $v; $this->updatedAt = time(); return $this; }
@@ -219,7 +221,6 @@ class ServiceItem
'price_rials' => $this->priceRials,
'active' => $this->active,
'insurance_covered' => $this->insuranceCovered,
- 'insurance_price_rials' => $this->insurancePriceRials,
'duration_minutes' => $this->durationMinutes,
'bookable' => $this->bookable,
'inventory_package_id' => $this->inventoryPackageId,
diff --git a/src/Insurance/Controller/InsuranceController.php b/src/Insurance/Controller/InsuranceController.php
index 4a2f410b..2a6c05e8 100644
--- a/src/Insurance/Controller/InsuranceController.php
+++ b/src/Insurance/Controller/InsuranceController.php
@@ -43,6 +43,7 @@ class InsuranceController extends BaseController
private readonly ServiceItemRepository $serviceItemRepo,
private readonly FileValidatorService $fileValidator,
private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker,
+ private readonly \App\Patient\Security\PatientRecordScopeResolver $scopeResolver,
private readonly string $projectDir,
) {}
@@ -78,17 +79,14 @@ class InsuranceController extends BaseController
return ['unknown', null, $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403)];
}
+ /**
+ * قرارداد بیمه به همان محیطی تعلق دارد که پروندهها در آن ثبت میشوند، پس همان
+ * رزولوِر مبنا است: مالک کلینیکی که خودش پزشک هم هست باید قراردادهای کلینیکش را
+ * ببیند، نه مطب شخصیاش.
+ */
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];
+ return $this->scopeResolver->resolve($user)->toLegacyTuple();
}
// ── Public list ───────────────────────────────────────────────────────────
diff --git a/src/Patient/Entity/PatientSession.php b/src/Patient/Entity/PatientSession.php
index c36684cd..8d18a01b 100644
--- a/src/Patient/Entity/PatientSession.php
+++ b/src/Patient/Entity/PatientSession.php
@@ -58,6 +58,24 @@ class PatientSession
#[ORM\Column(name: 'services_total_rials', type: 'integer')]
private int $servicesTotalRials = 0;
+ /**
+ * تفکیک بیمهی این مراجعه، محاسبهشده توسط PatientService::calculateFinalPrice.
+ * پایاست تا صفحهی پرداخت بدون صدور فاکتور هم سهمها را داشته باشد.
+ * ثابت: gross = baseInsurance + supplementaryInsurance + patientShare
+ */
+ #[ORM\Column(name: 'gross_total_rials', type: 'integer', options: ['default' => 0])]
+ private int $grossTotalRials = 0;
+
+ #[ORM\Column(name: 'base_insurance_rials', type: 'integer', options: ['default' => 0])]
+ private int $baseInsuranceRials = 0;
+
+ #[ORM\Column(name: 'supplementary_insurance_rials', type: 'integer', options: ['default' => 0])]
+ private int $supplementaryInsuranceRials = 0;
+
+ #[ORM\Column(name: 'patient_share_rials', type: 'integer', options: ['default' => 0])]
+ private int $patientShareRials = 0;
+
+ /** سهم بیمار پیش از تخفیف دستی — همیشه برابر patientShareRials */
#[ORM\Column(name: 'final_price_rials', type: 'integer')]
private int $finalPriceRials = 0;
@@ -144,6 +162,10 @@ class PatientSession
public function getBaseInsuranceDiscountPercent(): float { return (float) $this->baseInsuranceDiscountPercent; }
public function getSupplementaryDiscountPercent(): float { return (float) $this->supplementaryDiscountPercent; }
public function getServicesTotalRials(): int { return $this->servicesTotalRials; }
+ public function getGrossTotalRials(): int { return $this->grossTotalRials; }
+ public function getBaseInsuranceRials(): int { return $this->baseInsuranceRials; }
+ public function getSupplementaryInsuranceRials(): int { return $this->supplementaryInsuranceRials; }
+ public function getPatientShareRials(): int { return $this->patientShareRials; }
public function getFinalPriceRials(): int { return $this->finalPriceRials; }
public function getPaymentMethod(): string { return $this->paymentMethod; }
public function getDiscountType(): ?string { return $this->discountType; }
@@ -169,10 +191,16 @@ class PatientSession
));
}
+ /** مبلغ قابل پرداخت بیمار: سهم بیمار پس از کسر تخفیف دستی */
+ public function getPayableRials(): int
+ {
+ return max(0, $this->finalPriceRials - $this->discountRials);
+ }
+
/** ماندهی بدهی پس از کسر تخفیف و پرداختها؛ هرگز منفی نمیشود */
public function getRemainingRials(): int
{
- return max(0, $this->finalPriceRials - $this->discountRials - $this->getPaidTotalRials());
+ return max(0, $this->getPayableRials() - $this->getPaidTotalRials());
}
public function getSessionAt(): ?int { return $this->sessionAt; }
@@ -206,7 +234,22 @@ class PatientSession
public function setBaseInsuranceDiscountPercent(float $v): self { $this->baseInsuranceDiscountPercent = (string) $v; $this->updatedAt = time(); return $this; }
public function setSupplementaryDiscountPercent(float $v): self { $this->supplementaryDiscountPercent = (string) $v; $this->updatedAt = time(); return $this; }
public function setServicesTotalRials(int $v): self { $this->servicesTotalRials = $v; $this->updatedAt = time(); return $this; }
- public function setFinalPriceRials(int $v): self { $this->finalPriceRials = $v; $this->updatedAt = time(); return $this; }
+ public function setFinalPriceRials(int $v): self { $this->finalPriceRials = $v; $this->patientShareRials = $v; $this->updatedAt = time(); return $this; }
+
+ /**
+ * تفکیک بیمه را یکجا مینشاند تا سهمها و مبلغ نهایی نتوانند ناسازگار شوند.
+ * مبلغ نهایی همیشه سهم بیمار است؛ تخفیف دستی جدا و بعد از این اعمال میشود.
+ */
+ public function applyShares(int $gross, int $baseInsurance, int $supplementaryInsurance, int $patientShare): self
+ {
+ $this->grossTotalRials = $gross;
+ $this->baseInsuranceRials = $baseInsurance;
+ $this->supplementaryInsuranceRials = $supplementaryInsurance;
+ $this->patientShareRials = $patientShare;
+ $this->finalPriceRials = $patientShare;
+ $this->updatedAt = time();
+ return $this;
+ }
public function setPaymentMethod(string $v): self { $this->paymentMethod = $v; $this->updatedAt = time(); return $this; }
public function setDiscount(?string $type, int $value, int $rials, ?int $ruleId = null, ?string $ruleLabel = null): self
{
@@ -242,7 +285,12 @@ class PatientSession
'base_insurance_discount_percent' => (float) $this->baseInsuranceDiscountPercent,
'supplementary_discount_percent' => (float) $this->supplementaryDiscountPercent,
'services_total_rials' => $this->servicesTotalRials,
+ 'gross_total_rials' => $this->grossTotalRials,
+ 'base_insurance_rials' => $this->baseInsuranceRials,
+ 'supplementary_insurance_rials' => $this->supplementaryInsuranceRials,
+ 'patient_share_rials' => $this->patientShareRials,
'final_price_rials' => $this->finalPriceRials,
+ 'remaining_rials' => $this->getRemainingRials(),
'payment_method' => $this->paymentMethod,
'is_paid' => $this->getRemainingRials() === 0,
'discount_type' => $this->discountType,
diff --git a/src/Patient/Service/PatientService.php b/src/Patient/Service/PatientService.php
index 1f134965..848d0314 100644
--- a/src/Patient/Service/PatientService.php
+++ b/src/Patient/Service/PatientService.php
@@ -86,44 +86,69 @@ class PatientService
}
/**
- * محاسبهی سهم بیمار.
- * ویزیت با درصد تخفیف انتخابشده در فرم؛ هر خدمت با قاعدهی پوشش همان بیمهگر برای همان خدمت
- * (TenantServiceCoverage از طریق BillingCalculator). خدمتی که آن بیمه را پوشش نمیدهد، کامل بر عهدهی بیمار است.
+ * تفکیک سهم بیمهها و سهم بیمار برای یک مراجعه.
+ *
+ * ویزیت با قاعدهی قرارداد بیمه (coverageRule) و هر خدمت با قاعدهی پوشش همان خدمت
+ * (coverageRuleForService) محاسبه میشود — هر دو از طریق BillingCalculator، همان مسیری
+ * که InvoiceService برای صدور فاکتور استفاده میکند. تنها منبع محاسبه همین است تا مبلغ
+ * صفحهی پرداخت و فاکتور نتوانند از هم واگرا شوند.
*
* @param array
$serviceItems قیمت کل هر ردیف (با احتساب تعداد)
+ * @return array{services_total_rials: int, gross_total_rials: int, base_insurance_rials: int, supplementary_insurance_rials: int, patient_share_rials: int, final_price_rials: int}
*/
public function calculateFinalPrice(
int $visitPrice,
- float $baseDiscount,
- float $suppDiscount,
array $serviceItems,
string $entityType = 'doctor',
int $entityId = 0,
?int $baseInsuranceId = null,
?int $suppInsuranceId = null,
): array {
- $afterBase = $visitPrice * (1 - $baseDiscount / 100);
- $afterSupp = $afterBase * (1 - $suppDiscount / 100);
- $visitShare = (int) round($afterSupp);
+ $baseShare = 0;
+ $suppShare = 0;
+ $patientShare = 0;
- $servicesTotal = 0;
- $servicesPatient = 0;
+ if ($visitPrice > 0) {
+ $visit = $this->billingCalculator->calculateItem(
+ new Money($visitPrice),
+ $this->tenantInsuranceService->coverageRule($entityType, $entityId, $baseInsuranceId),
+ $this->tenantInsuranceService->coverageRule($entityType, $entityId, $suppInsuranceId),
+ );
+ $baseShare += $visit->baseInsuranceRials;
+ $suppShare += $visit->supplementaryRials;
+ $patientShare += $visit->patientRials;
+ }
+
+ $servicesTotal = 0;
foreach ($serviceItems as $svc) {
$servicesTotal += $svc['price_rials'];
- $baseRule = $this->tenantInsuranceService->coverageRuleForService($entityType, $entityId, $baseInsuranceId, $svc['item_id']);
- $suppRule = $this->tenantInsuranceService->coverageRuleForService($entityType, $entityId, $suppInsuranceId, $svc['item_id']);
- $breakdown = $this->billingCalculator->calculateItem(new Money($svc['price_rials']), $baseRule, $suppRule);
-
- $servicesPatient += $breakdown->patientRials;
+ $line = $this->billingCalculator->calculateItem(
+ new Money($svc['price_rials']),
+ $this->tenantInsuranceService->coverageRuleForService($entityType, $entityId, $baseInsuranceId, $svc['item_id']),
+ $this->tenantInsuranceService->coverageRuleForService($entityType, $entityId, $suppInsuranceId, $svc['item_id']),
+ );
+ $baseShare += $line->baseInsuranceRials;
+ $suppShare += $line->supplementaryRials;
+ $patientShare += $line->patientRials;
}
return [
- 'services_total_rials' => $servicesTotal,
- 'final_price_rials' => $visitShare + $servicesPatient,
+ 'services_total_rials' => $servicesTotal,
+ 'gross_total_rials' => $visitPrice + $servicesTotal,
+ 'base_insurance_rials' => $baseShare,
+ 'supplementary_insurance_rials' => $suppShare,
+ 'patient_share_rials' => $patientShare,
+ 'final_price_rials' => $patientShare,
];
}
+ /** درصد پوشش قرارداد فعال — snapshot روی مراجعه، نه ورودی محاسبه. */
+ private function contractPercent(string $entityType, int $entityId, ?int $insuranceId): float
+ {
+ return $this->tenantInsuranceService->coverageRule($entityType, $entityId, $insuranceId)->coveragePercent;
+ }
+
/**
* پرونده و مراجعهٔ خودکار برای یک نوبت قطعیشده.
*
@@ -190,7 +215,9 @@ class PatientService
}
$session->setServicesTotalRials($servicesTotal);
- $session->setFinalPriceRials($servicesTotal + $visitPrice);
+ // پذیرش خودکار بیمهای ندارد: تمام مبلغ سهم بیمار است.
+ $gross = $servicesTotal + $visitPrice;
+ $session->applyShares($gross, 0, 0, $gross);
$this->sessionRepo->save($session);
@@ -222,8 +249,8 @@ class PatientService
$session->setInsuranceBaseId(isset($data['insurance_base_id']) ? (int) $data['insurance_base_id'] : null);
$session->setInsuranceSupplementaryId(isset($data['insurance_supplementary_id']) ? (int) $data['insurance_supplementary_id'] : null);
$session->setVisitPriceRials((int) ($data['visit_price_rials'] ?? 0));
- $session->setBaseInsuranceDiscountPercent((float) ($data['base_insurance_discount_percent'] ?? 0));
- $session->setSupplementaryDiscountPercent((float) ($data['supplementary_discount_percent'] ?? 0));
+ $session->setBaseInsuranceDiscountPercent($this->contractPercent($entityType, $entityId, $session->getInsuranceBaseId()));
+ $session->setSupplementaryDiscountPercent($this->contractPercent($entityType, $entityId, $session->getInsuranceSupplementaryId()));
$session->setPaymentMethod($data['payment_method'] ?? 'pending');
$session->setNotes($data['notes'] ?? null);
@@ -252,8 +279,6 @@ class PatientService
$priceCalc = $this->calculateFinalPrice(
$session->getVisitPriceRials(),
- $session->getBaseInsuranceDiscountPercent(),
- $session->getSupplementaryDiscountPercent(),
$serviceItemsData,
$entityType,
$entityId,
@@ -277,7 +302,13 @@ class PatientService
$consumablesTotal += $item->getPrice() * $qty;
}
- $session->setFinalPriceRials($priceCalc['final_price_rials'] + $consumablesTotal);
+ // کالاهای مصرفی پوشش بیمه ندارند: مستقیم به سهم بیمار و به جمع کل اضافه میشوند.
+ $session->applyShares(
+ $priceCalc['gross_total_rials'] + $consumablesTotal,
+ $priceCalc['base_insurance_rials'],
+ $priceCalc['supplementary_insurance_rials'],
+ $priceCalc['patient_share_rials'] + $consumablesTotal,
+ );
$this->sessionRepo->save($session);
@@ -365,18 +396,25 @@ class PatientService
fn(SessionService $s) => ['item_id' => $s->getServiceItem()->getId(), 'price_rials' => $s->getLineTotalRials()],
$session->getServices()->toArray(),
);
+ $session->setBaseInsuranceDiscountPercent($this->contractPercent($entityType, $entityId, $session->getInsuranceBaseId()));
+ $session->setSupplementaryDiscountPercent($this->contractPercent($entityType, $entityId, $session->getInsuranceSupplementaryId()));
+
$priceCalc = $this->calculateFinalPrice(
$session->getVisitPriceRials(),
- $session->getBaseInsuranceDiscountPercent(),
- $session->getSupplementaryDiscountPercent(),
$serviceItemsData,
$entityType,
$entityId,
$session->getInsuranceBaseId(),
$session->getInsuranceSupplementaryId(),
);
+ $consumablesTotal = $session->getConsumablesTotalRials();
$session->setServicesTotalRials($priceCalc['services_total_rials']);
- $session->setFinalPriceRials($priceCalc['final_price_rials'] + $session->getConsumablesTotalRials());
+ $session->applyShares(
+ $priceCalc['gross_total_rials'] + $consumablesTotal,
+ $priceCalc['base_insurance_rials'],
+ $priceCalc['supplementary_insurance_rials'],
+ $priceCalc['patient_share_rials'] + $consumablesTotal,
+ );
$this->sessionRepo->save($session);
// پس از تغییر مبلغ، وضعیت تسویه بازمحاسبه شود (افزودن سرویس/پکیج → بدهکار).
@@ -417,8 +455,7 @@ class PatientService
}
// مجموع پرداختها (با مقدار جدید) نباید از مبلغِ پس از تخفیف بیشتر شود.
$othersTotal = $session->getPaidTotalRials() - $oldAmount;
- $payable = $session->getFinalPriceRials() - $session->getDiscountRials();
- if ($othersTotal + $newAmount > $payable) {
+ if ($othersTotal + $newAmount > $session->getPayableRials()) {
throw new AppException(ErrorCodes::ERR_SESSION_PAYMENT_EXCEEDS, null, 422, 'amount_rials');
}
diff --git a/tests/Patient/SessionInsuranceShareTest.php b/tests/Patient/SessionInsuranceShareTest.php
new file mode 100644
index 00000000..b53fa092
--- /dev/null
+++ b/tests/Patient/SessionInsuranceShareTest.php
@@ -0,0 +1,153 @@
+createUser(['ROLE_DOCTOR']);
+ $doctor = new Doctor($owner, 'دکتر تست');
+ $this->em->persist($doctor);
+ $this->em->flush();
+
+ $patient = $this->createUser(['ROLE_USER']);
+ $record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
+ $this->em->persist($record);
+ $this->em->flush();
+
+ return [$owner, $doctor, $record];
+ }
+
+ private function serviceItem(Doctor $doctor, int $priceRials, bool $insuranceCovered): ServiceItem
+ {
+ $section = new ServiceSection('doctor', $doctor->getId(), 'جراحی');
+ $this->em->persist($section);
+
+ $item = new ServiceItem($section, 'جراحی بینی', $priceRials);
+ $item->setInsuranceCovered($insuranceCovered);
+ $this->em->persist($item);
+ $this->em->flush();
+
+ return $item;
+ }
+
+ /** Active contract covering $coveragePercent of every covered service. */
+ private function contract(Doctor $doctor, float $coveragePercent, ?ServiceItem $item = null): Insurance
+ {
+ $insurance = new Insurance('تامین اجتماعی', InsuranceType::Basic);
+ $this->em->persist($insurance);
+ $this->em->flush();
+
+ $contract = new TenantInsurance('doctor', $doctor->getId(), $insurance->getId());
+ $contract->setCoveragePercent($coveragePercent)->setActive(true);
+ $this->em->persist($contract);
+ $this->em->flush();
+
+ if ($item !== null) {
+ $coverage = new TenantServiceCoverage($contract->getId(), $item->getId());
+ $coverage->setCovered(true)->setCoveragePercent($coveragePercent);
+ $this->em->persist($coverage);
+ $this->em->flush();
+ }
+
+ return $insurance;
+ }
+
+ public function testSessionStoresInsuranceAndPatientSharesForACoveredService(): void
+ {
+ [$owner, $doctor, $record] = $this->tenant();
+ $item = $this->serviceItem($doctor, 40_000_000, true);
+ $insurance = $this->contract($doctor, 70.0, $item);
+
+ $res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
+ 'visit_price_rials' => 3_000_000,
+ 'insurance_base_id' => $insurance->getId(),
+ 'services' => [['service_item_uuid' => $item->getUuid(), 'quantity' => 1]],
+ ]);
+ self::assertSame(201, $this->responseCode());
+
+ $session = $res['data'];
+ // 70% of both the visit and the service is carried by the insurer.
+ self::assertSame(43_000_000, $session['gross_total_rials']);
+ self::assertSame(30_100_000, $session['base_insurance_rials']);
+ self::assertSame(0, $session['supplementary_insurance_rials']);
+ self::assertSame(12_900_000, $session['patient_share_rials']);
+ // The payable amount is the patient share, not the gross total.
+ self::assertSame(12_900_000, $session['final_price_rials']);
+ self::assertSame(12_900_000, $session['remaining_rials']);
+ }
+
+ public function testTheBreakdownAlwaysSumsBackToTheGrossTotal(): void
+ {
+ [$owner, $doctor, $record] = $this->tenant();
+ $item = $this->serviceItem($doctor, 1_234_567, true);
+ $insurance = $this->contract($doctor, 33.33, $item);
+
+ $res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
+ 'visit_price_rials' => 987_654,
+ 'insurance_base_id' => $insurance->getId(),
+ 'services' => [['service_item_uuid' => $item->getUuid(), 'quantity' => 3]],
+ ]);
+ self::assertSame(201, $this->responseCode());
+
+ $s = $res['data'];
+ self::assertSame(
+ $s['gross_total_rials'],
+ $s['base_insurance_rials'] + $s['supplementary_insurance_rials'] + $s['patient_share_rials'],
+ );
+ }
+
+ public function testAServiceWithoutCoverageLeavesTheWholeAmountToThePatient(): void
+ {
+ [$owner, $doctor, $record] = $this->tenant();
+ $item = $this->serviceItem($doctor, 40_000_000, false);
+ $insurance = $this->contract($doctor, 70.0);
+
+ $res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
+ 'visit_price_rials' => 0,
+ 'insurance_base_id' => $insurance->getId(),
+ 'services' => [['service_item_uuid' => $item->getUuid(), 'quantity' => 1]],
+ ]);
+ self::assertSame(201, $this->responseCode());
+
+ $s = $res['data'];
+ self::assertSame(0, $s['base_insurance_rials']);
+ self::assertSame(40_000_000, $s['patient_share_rials']);
+ self::assertSame(40_000_000, $s['final_price_rials']);
+ }
+
+ public function testSessionWithoutInsuranceKeepsTheFullAmountAsThePatientShare(): void
+ {
+ [$owner, $doctor, $record] = $this->tenant();
+ $item = $this->serviceItem($doctor, 2_400_000, true);
+
+ $res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
+ 'visit_price_rials' => 500_000,
+ 'services' => [['service_item_uuid' => $item->getUuid(), 'quantity' => 1]],
+ ]);
+ self::assertSame(201, $this->responseCode());
+
+ $s = $res['data'];
+ self::assertSame(2_900_000, $s['gross_total_rials']);
+ self::assertSame(0, $s['base_insurance_rials']);
+ self::assertSame(2_900_000, $s['patient_share_rials']);
+ }
+}