feat(migrations): update franchise to percentage in tenant_insurances and tenant_service_coverage
- Changed franchise_rials to franchise_percent in tenant_insurances and tenant_service_coverage tables. - Reset old rial values to 0/NULL as they are not convertible to percentage. feat(command): add SeedInsuranceScenarioCommand for seeding insurance data - Implemented a command to seed supplementary insurance contracts, patients, and claims for a specified doctor. - Includes functionality for purging existing scenario data and generating new entries with predefined contracts and patient scenarios.
This commit is contained in:
@@ -0,0 +1,542 @@
|
||||
# بیمهٔ تکمیلی: فرانشیز درصدی، درصد پوشش الزامی برای نوع خدمت فعال، و شناسایی بیمه در مطالبات
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (بکاند Symfony + پنل ادمین React). تغییرات فقط داخل همین ریپو است؛ کلاینت بیرونی ندارد
|
||||
(`nobat724_front` و `clinic-pro-tauri` هیچکدام `/api/v1/billing/tenant-insurances` یا
|
||||
`/api/v1/billing/claims/*` را مصرف نمیکنند — قبل از شروع با grep تأیید کن).
|
||||
|
||||
## زمینه
|
||||
|
||||
مدل بیمهٔ tenant امروز این است:
|
||||
|
||||
- کاتالوگ بیمه: `insurances` (`type` = `basic` | `supplementary`) — ۶ بیمهٔ پایه و ۱۰ بیمهٔ تکمیلی seed شدهاند.
|
||||
- قرارداد tenant: `TenantInsurance` (`tenant_insurances`) با `coverage_percent`، `franchise_rials`،
|
||||
`annual_ceiling_rials`، `kind`.
|
||||
- درصد پوشش به تفکیک نوع خدمت: `TenantInsuranceCategoryCoverage` (override قرارداد) →
|
||||
`InsuranceCoverageDefault` (پیشفرض مرکزی ادمین) → `coverage_percent` قرارداد (fallback قدیمی).
|
||||
- نوع خدمت: enum `ServiceCategory` = `outpatient` (خدمات سرپایی) / `inpatient` (خدمات بستری).
|
||||
- «کدام نوع خدمت اصلاً بیمهای است» یک سوییچ سراسریِ tenant است: `TenantServiceCategorySetting`
|
||||
(`tenant_service_category_settings`, ردیف نداشته = فعال) و از `GET /api/v1/insurance-pricing`
|
||||
در کلید `service_categories` بیرون میآید.
|
||||
- محاسبهٔ سهم: `BillingCalculator::calculateItem()` روی `CoverageRule` — فرانشیز فقط در قرارداد تکمیلی
|
||||
اثر دارد و بهصورت **مبلغ ریالی** به سهم بیمار اضافه میشود.
|
||||
- مطالبات: `claims` با `insurance_id` و `insurance_kind` (`base` | `supplementary` — دقت کن، واژگان
|
||||
مطالبه `base` است نه `basic`). داشبورد سطحاول `ClaimsPage` یک ردیف بهازای هر **بیمار** میدهد.
|
||||
|
||||
سه ایراد واقعی در همین مسیر وجود دارد:
|
||||
|
||||
1. مودال «افزودن بیمه» ورودی «درصد پوشش» را برای **هر دو** نوع خدمت رندر میکند، بیتوجه به اینکه
|
||||
tenant کدام نوع را فعال کرده، و خالیماندنِ آنها هیچ خطایی نمیدهد → قرارداد با پوشش صفر ثبت میشود.
|
||||
2. فرانشیز بهصورت «تومان» گرفته و ذخیره میشود (`franchise_rials`) در حالی که فرانشیز در بیمهٔ تکمیلی
|
||||
یک **درصد** است.
|
||||
3. در داشبورد مطالبات (سطح اول) هیچجا معلوم نیست هر ردیف مربوط به کدام بیمه است، و نه فیلتر «نوع بیمه»
|
||||
دارد و نه جستجو روی نام بیمه کار میکند. (سطح دومِ `ClaimPatientDetailPage` نام و نوع بیمه را دارد.)
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
۱. **درصد پوشش فقط برای نوع خدمتِ فعال، و الزامی.** در مودال افزودن/ویرایش بیمه، بهجای «همهٔ
|
||||
ServiceCategoryها»، فقط نوعهای خدمتی رندر شوند که برای همان tenant فعالاند
|
||||
(`service_categories[].enabled === true` از `GET /api/v1/insurance-pricing`)، و ثبت فرم بدون
|
||||
درصدِ معتبر (۱ تا ۱۰۰) برای هر نوع خدمتِ رندرشده مجاز نباشد. بکاند هم همین را اجبار کند
|
||||
(نه فقط UI).
|
||||
|
||||
۲. **فرانشیز درصد است، نه تومان.** `franchise_rials` → `franchise_percent` در هر دو سطح
|
||||
(قرارداد `TenantInsurance` و override خدمت `TenantServiceCoverage`)، در `CoverageRule`،
|
||||
در `BillingCalculator`، در API، در پنل، و در آینهٔ سمتکلاینت `assets/admin/lib/insuranceShares.ts`.
|
||||
|
||||
**معنای انتخابشده (این را در کد کامنت کن):** فرانشیز درصدی است از مبلغی که بیمهٔ تکمیلی روی آن
|
||||
کار میکند — یعنی «باقیماندهٔ بعد از بیمهٔ پایه» — و سهم بیمار است:
|
||||
|
||||
```
|
||||
remainingAfterBase = total − baseShare(سقفخورده)
|
||||
suppShare = remainingAfterBase × coveragePercent٪ (سقفخورده)
|
||||
franchiseAmount = remainingAfterBase × franchisePercent٪ ← جدید
|
||||
patient = min(total, remainingAfterBase − suppShare + franchiseAmount)
|
||||
```
|
||||
|
||||
این با واقعیت بازار میخواند («تکمیلی ۹۰٪ میدهد، فرانشیز ۱۰٪») و ساختار فعلی فرمول را حفظ میکند؛
|
||||
تنها تفاوت این است که مبلغ فرانشیز بهجای عدد ثابت، از درصد ساخته میشود. فرانشیز در بیمهٔ **پایه**
|
||||
همچنان بیاثر است.
|
||||
|
||||
۳. **مطالبات: کدام بیمه.** در `ClaimsPage` (سطح اول) ستون «بیمه» اضافه شود (یک بیمار میتواند مطالبه
|
||||
زیر چند بیمه داشته باشد → لیست بیمههای آن بیمار با نوعشان)، فیلتر «نوع بیمه» (پایه/تکمیلی) اضافه
|
||||
شود، و جستجوی متنی علاوه بر نام/موبایل/کد ملی بیمار، **نام بیمه** را هم پوشش دهد.
|
||||
|
||||
۴. **سناریوی واقعی + تست.** برای پزشک `09389388131` (کاربر `users.id = 11685`، پزشک
|
||||
`doctors.id = 11548`، `entity_type='doctor'`, `entity_id=11548`) چند بیمهٔ تکمیلی و چند بیمار
|
||||
ساخته شود که از این بیمهها استفاده میکنند، مطالبات تولید و مسیر وضعیتها تست شود.
|
||||
|
||||
## معیار پذیرش
|
||||
|
||||
**درصد پوشش / نوع خدمت فعال**
|
||||
|
||||
- ✅ موفق: tenantـی که فقط `outpatient` را فعال دارد، مودال «افزودن بیمه» تنها یک ورودی
|
||||
«درصد پوشش — خدمات سرپایی» نشان میدهد؛ با مقدار ۷۰ ثبت میشود و
|
||||
`GET /api/v1/billing/tenant-insurances` برای آن قرارداد `category_coverages.outpatient = 70` میدهد.
|
||||
- ❌ خطا: `POST /api/v1/billing/tenant-insurances` با
|
||||
`category_coverages: [{key:'outpatient', coverage_percent: null}]` در حالی که `outpatient` فعال است
|
||||
→ HTTP 422 با `{ success:false, errors:[{ code:'ERR_VALIDATION_001', field:'category_coverages', message:'درصد پوشش خدمات سرپایی الزامی است' }] }`.
|
||||
دکمهٔ «ثبت بیمه» هم در UI تا پرشدن همهٔ درصدهای فعال غیرفعال است.
|
||||
- ⚠️ مرزی: نوع خدمتی که در تنظیمات tenant **غیرفعال** است نه رندر میشود و نه در payload میرود؛
|
||||
قراردادی که از قبل برای آن نوع override داشت، آن override دستنخورده در DB میماند
|
||||
(ارسالنشدن ≠ حذف؛ حذف فقط با `coverage_percent: null` صریح انجام میشود).
|
||||
|
||||
**فرانشیز درصدی**
|
||||
|
||||
- ✅ موفق: قرارداد تکمیلی با `coverage_percent = 90` و `franchise_percent = 10` روی خدمتی
|
||||
۱٬۰۰۰٬۰۰۰ ریالی بدون بیمهٔ پایه → `supplementary = 900,000`، `patient = 100,000 + 100,000 = 200,000`،
|
||||
و `total = base + supp + patient` همچنان برقرار است (تست موجود
|
||||
`tests/Patient/SessionInsuranceShareTest::testTheBreakdownAlwaysSumsBackToTheGrossTotal` باید سبز بماند).
|
||||
- ❌ خطا: `franchise_percent: 150` → HTTP 422 «فرانشیز باید بین ۰ تا ۱۰۰ باشد»
|
||||
(`field: 'franchise_percent'`). همچنین `franchise_percent` منفی → همان خطا.
|
||||
- ⚠️ مرزی: `franchise_percent = 100` روی قرارداد تکمیلی → سهم بیمار از `total` بیشتر نمیشود
|
||||
(`min(total)` نگهدارنده است) و سهم بیمه منفی نمیشود؛ در بیمهٔ **پایه** هر مقدار فرانشیز بیاثر است.
|
||||
|
||||
**مطالبات**
|
||||
|
||||
- ✅ موفق: `GET /api/v1/billing/claims/by-patient` برای بیماری با دو مطالبه زیر دو بیمه، آرایهٔ
|
||||
`insurances: [{insurance_id, insurance_name, kind}, …]` میدهد و ستون «بیمه» در جدول هر دو را
|
||||
نشان میدهد.
|
||||
- ❌ خطا: `?kind=bogus` → HTTP 422 «نوع بیمه نامعتبر است» (نه ۵۰۰ و نه لیست خالیِ بیصدا).
|
||||
- ⚠️ مرزی: `?search=آسیا` فقط بیمارانی را برمیگرداند که مطالبهای زیر «بیمه آسیا» دارند، و
|
||||
`meta.totalRecords` با همان فیلتر همخوان است (شمارش و لیست از یک WHERE میآیند)؛
|
||||
`?kind=supplementary&insurance_id=<یک بیمهٔ پایه>` → صفر ردیف، بدون خطا.
|
||||
|
||||
**سناریو**
|
||||
|
||||
- ✅ موفق: بعد از اجرای دستور سناریو، ورود به پنل با `09389388131` → صفحهٔ «پروندههای بیمه»
|
||||
حداقل ۴ بیمار با مطالبات زیر ۳ بیمهٔ تکمیلی مختلف نشان میدهد؛ کارتهای آمار (ادعا/وصول/مانده)
|
||||
با جمع ستونهای جدول همخواناند.
|
||||
- ⚠️ مرزی: اجرای دوبارهٔ دستور، داده را دوباره نمیسازد (idempotent) یا با `--purge` تمیز و بازسازی میکند.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `clinicpro/src/Insurance/Entity/TenantInsurance.php` | ستون `franchise_rials` → `franchise_percent` |
|
||||
| `clinicpro/src/Insurance/Entity/TenantServiceCoverage.php` | همان تغییر در override سطح خدمت |
|
||||
| `clinicpro/src/Insurance/ValueObject/CoverageRule.php` | `franchiseRials` → `franchisePercent` |
|
||||
| `clinicpro/src/Insurance/Service/TenantInsuranceService.php` | `activate()`، `buildRule()`، `setServiceCoverage()`، اعتبارسنجی درصد |
|
||||
| `clinicpro/src/Insurance/Controller/InsuranceController.php` | خواندن/نوشتن `franchise_percent`، اجبار درصد برای نوع خدمت فعال (خطوط ۴۷۶–۵۹۲ و ۶۴۷–۶۹۳) |
|
||||
| `clinicpro/src/Billing/Service/BillingCalculator.php` | فرمول فرانشیز درصدی |
|
||||
| `clinicpro/src/Billing/Repository/ClaimRepository.php` | `patientAggregateSql()` + `patientAggregateFilters()` — بیمهها، فیلتر `kind`، جستجوی نام بیمه |
|
||||
| `clinicpro/src/Billing/Controller/BillingController.php` | `claimsByPatient()`، `claimFilters()` — پارامتر `kind` و نگاشت نام بیمه |
|
||||
| `clinicpro/assets/admin/components/InsuranceModal.tsx` | ورودی فرانشیز درصدی + درصد الزامی + فقط نوع خدمت فعال |
|
||||
| `clinicpro/assets/admin/components/TenantInsuranceContracts.tsx` | تغذیهٔ مودال با نوع خدمتِ فعال، خلاصه و جزئیات فرانشیز درصدی |
|
||||
| `clinicpro/assets/admin/lib/insuranceShares.ts` | آینهٔ فرمول سمت کلاینت |
|
||||
| `clinicpro/assets/admin/components/session/CreateStep.tsx` | مصرفکنندهٔ `franchise_rials` در پذیرش |
|
||||
| `clinicpro/assets/admin/components/ServiceInsuranceModal.tsx`، `pages/ServiceDetailPage.tsx` | override فرانشیز سطح خدمت |
|
||||
| `clinicpro/assets/admin/pages/ClaimsPage.tsx` | ستون بیمه + فیلتر نوع بیمه |
|
||||
| `clinicpro/assets/admin/types/index.ts` | `ClaimPatientRow` |
|
||||
| `clinicpro/migrations/` | migration تغییر نام و نوع ستونها |
|
||||
| `clinicpro/docs/api/insurance.md`، `docs/api/billing.md` | مستندسازی قرارداد جدید |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
`clinicpro/src/Billing/Service/BillingCalculator.php` (خطوط ۳۱–۴۳) — فرانشیز مبلغ ثابت:
|
||||
|
||||
```php
|
||||
$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($supplementary?->franchiseRials ?? 0);
|
||||
$patient = $remaining->add($franchise)->min($total);
|
||||
```
|
||||
|
||||
`clinicpro/assets/admin/components/InsuranceModal.tsx` (خطوط ۲۱۹–۲۵۵) — همهٔ categoryها رندر میشوند،
|
||||
هیچ اجباری روی مقدار نیست، و فرانشیز تومانی است:
|
||||
|
||||
```tsx
|
||||
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${Math.max(1, categories.length)}, 1fr)`, gap: 12 }}>
|
||||
{categories.map((c) => (
|
||||
<div key={c.key} style={field}>
|
||||
<label style={label}>درصد پوشش — {c.label}</label>
|
||||
<input … value={form.categoryPercents[c.key] ?? ''} onChange={(e) => setPercent(c.key, e.target.value)} />
|
||||
…
|
||||
{!isBasic && (
|
||||
<div style={field}>
|
||||
<label style={label}>فرانشیز (تومان)</label>
|
||||
<input … value={form.franchise} onChange={(e) => set({ franchise: digitsOnly(e.target.value) })} />
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
و در `buildInsurancePayload()`:
|
||||
|
||||
```ts
|
||||
franchise_rials: isBasic ? 0 : tomanToRial(Number(v.franchise) || 0),
|
||||
```
|
||||
|
||||
`clinicpro/assets/admin/components/TenantInsuranceContracts.tsx` — مودال از لیست سراسری تغذیه میشود
|
||||
و فرانشیز ریالی نمایش داده میشود:
|
||||
|
||||
```tsx
|
||||
const { categories } = useServiceCategories(); // همهٔ نوعها، نه نوعهای فعالِ tenant
|
||||
…
|
||||
if (c.insurance_kind === 'supplementary' && c.franchise_rials > 0) {
|
||||
parts.push(`فرانشیز ${formatRial(c.franchise_rials)}`);
|
||||
}
|
||||
```
|
||||
|
||||
`clinicpro/src/Billing/Repository/ClaimRepository.php` — نه بیمهای در SELECT سطح بیمار هست و نه
|
||||
جستجو/فیلتر نوع بیمه:
|
||||
|
||||
```php
|
||||
if (!empty($filters['search'])) {
|
||||
$conditions[] = '(u.real_name LIKE :search OR u.mobile_number LIKE :search OR u.national_code LIKE :search)';
|
||||
$params['search'] = '%' . trim((string) $filters['search']) . '%';
|
||||
}
|
||||
```
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. مهاجرت داده: `franchise_rials` → `franchise_percent`
|
||||
|
||||
`TenantInsurance`:
|
||||
|
||||
```php
|
||||
#[ORM\Column(name: 'franchise_percent', type: 'decimal', precision: 5, scale: 2)]
|
||||
private string $franchisePercent = '0.00';
|
||||
|
||||
public function getFranchisePercent(): float { return (float) $this->franchisePercent; }
|
||||
public function setFranchisePercent(float $v): self { $this->franchisePercent = (string) $v; $this->updatedAt = time(); return $this; }
|
||||
```
|
||||
|
||||
`TenantServiceCoverage`: همان تغییر با `nullable: true` و getter/setter `?float`.
|
||||
هر دو `toArray()` کلید `franchise_percent` بدهند (کلید `franchise_rials` کاملاً حذف شود — سازگاری
|
||||
عقبرو لازم نیست چون تنها مصرفکننده پنل خودِ همین ریپوست).
|
||||
|
||||
Migration دستی بنویس (نه فقط `diff`)، چون تبدیل ریال به درصد معنا ندارد:
|
||||
|
||||
```php
|
||||
$this->addSql('ALTER TABLE tenant_insurances CHANGE franchise_rials franchise_percent DECIMAL(5,2) NOT NULL DEFAULT 0');
|
||||
$this->addSql('UPDATE tenant_insurances SET franchise_percent = 0');
|
||||
$this->addSql('ALTER TABLE tenant_service_coverage CHANGE franchise_rials franchise_percent DECIMAL(5,2) DEFAULT NULL');
|
||||
$this->addSql('UPDATE tenant_service_coverage SET franchise_percent = NULL');
|
||||
```
|
||||
|
||||
در `getDescription()` صریح بنویس که مقادیر ریالیِ قبلی قابل تبدیل نیستند و صفر میشوند.
|
||||
|
||||
**نحوه تست:** `ddev exec php bin/console doctrine:migrations:migrate --no-interaction` سپس
|
||||
`ddev exec php bin/console doctrine:schema:validate` باید mapping را سبز بدهد.
|
||||
|
||||
### ۲. فرمول فرانشیز درصدی
|
||||
|
||||
`CoverageRule`:
|
||||
|
||||
```php
|
||||
final readonly class CoverageRule
|
||||
{
|
||||
public function __construct(
|
||||
public float $coveragePercent,
|
||||
/** درصدِ سهم بیمار از «باقیماندهٔ بعد از بیمهٔ پایه»؛ فقط در قرارداد تکمیلی معنا دارد. */
|
||||
public float $franchisePercent,
|
||||
public ?int $ceilingRials,
|
||||
public bool $covered = true,
|
||||
) {}
|
||||
|
||||
public static function notCovered(): self { return new self(0.0, 0.0, null, false); }
|
||||
}
|
||||
```
|
||||
|
||||
`BillingCalculator::calculateItem()` — مبلغ فرانشیز از پایهٔ «قبل از کسر سهم تکمیلی» ساخته میشود:
|
||||
|
||||
```php
|
||||
$suppBase = $remaining; // باقیماندهٔ بعد از بیمهٔ پایه
|
||||
$suppShare = Money::zero();
|
||||
if ($supplementary !== null && $supplementary->covered) {
|
||||
$suppShare = $suppBase->percent($supplementary->coveragePercent);
|
||||
if ($supplementary->ceilingRials !== null) {
|
||||
$suppShare = $suppShare->min(new Money($supplementary->ceilingRials));
|
||||
}
|
||||
$remaining = $suppBase->sub($suppShare);
|
||||
}
|
||||
|
||||
// فرانشیز درصدی است، نه مبلغ: سهمِ بیمار از همان مبلغی که تکمیلی رویش کار میکند.
|
||||
$franchise = $supplementary !== null && $supplementary->covered
|
||||
? $suppBase->percent($supplementary->franchisePercent)
|
||||
: Money::zero();
|
||||
$patient = $remaining->add($franchise)->min($total);
|
||||
```
|
||||
|
||||
`TenantInsuranceService::buildRule()` و `activate()` و `setServiceCoverage()` را به
|
||||
`franchisePercent`/`franchise_percent` ببر و اعتبارسنجی ۰..۱۰۰ را (همان الگوی
|
||||
`setCategoryCoverages()`) اضافه کن:
|
||||
|
||||
```php
|
||||
if ($franchisePercent < 0 || $franchisePercent > 100) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'فرانشیز باید بین ۰ تا ۱۰۰ باشد', 422, 'franchise_percent');
|
||||
}
|
||||
```
|
||||
|
||||
`InsuranceController` هر سه نقطهٔ `franchise_rials` (خطوط ۵۰۲، ۵۶۴–۵۶۵، ۶۸۵) را با
|
||||
`franchise_percent` و cast به `float` جایگزین کند.
|
||||
|
||||
**نحوه تست:** یک تست واحد در `tests/Billing/` برای `BillingCalculator` با سه سناریو:
|
||||
(الف) پایه ۳۰٪ + تکمیلی ۹۰٪ + فرانشیز ۱۰٪ روی ۱٬۰۰۰٬۰۰۰ ریال، (ب) فرانشیز ۱۰۰٪،
|
||||
(ج) بیمهٔ پایه با فرانشیز غیرصفر (باید بیاثر بماند). در هر سه،
|
||||
`total === base + supp + patient` را assert کن. علاوه بر آن:
|
||||
`ddev exec php bin/phpunit tests/Patient/SessionInsuranceShareTest.php` باید سبز بماند
|
||||
(مقادیر انتظاریاش را طبق فرمول جدید بهروز کن، نه با تغییر فرمول).
|
||||
|
||||
### ۳. الزامیکردن درصد پوشش برای نوع خدمتِ فعال
|
||||
|
||||
**بکاند** — در `InsuranceController::applyCategoryCoverages()` قبل از فراخوانی سرویس، بررسی کن که
|
||||
برای هر نوع خدمتِ فعالِ همان tenant یک درصد معتبر آمده باشد:
|
||||
|
||||
```php
|
||||
$enabled = $this->serviceCategories->enabledKeys($entityType, $entityId);
|
||||
$sent = [];
|
||||
foreach ($data['category_coverages'] ?? [] as $row) {
|
||||
$sent[(string) ($row['key'] ?? '')] = $row['coverage_percent'] ?? null;
|
||||
}
|
||||
foreach ($enabled as $key) {
|
||||
if (($sent[$key] ?? null) === null || $sent[$key] === '' || (float) $sent[$key] <= 0) {
|
||||
return $this->error(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
sprintf('درصد پوشش %s الزامی است', ServiceCategory::from($key)->label()),
|
||||
422,
|
||||
'category_coverages',
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
توجه: `applyCategoryCoverages()` امروز `$entityType/$entityId` ندارد — امضایش را گسترش بده
|
||||
(هر دو فراخوان `activate` و `update` این مقادیر را در دست دارند). این چک فقط وقتی اجرا شود که
|
||||
کلید `category_coverages` در payload آمده باشد؛ PATCHـهای دیگر (مثل toggle وضعیت) نباید بشکنند.
|
||||
|
||||
**فرانت** — `TenantInsuranceContracts.tsx` بهجای `useServiceCategories()` نوع خدمتِ **فعالِ tenant**
|
||||
را از همان `pricingQuery` که از قبل fetch میشود بگیرد (بدون درخواست جدید):
|
||||
|
||||
```tsx
|
||||
const enabledCategories: ServiceCategoryOption[] = useMemo(() => (
|
||||
((pricingQuery.data as any)?.data?.service_categories ?? [])
|
||||
.filter((c: { enabled: boolean }) => c.enabled)
|
||||
.map((c: { key: string; label: string }) => ({ key: c.key, label: c.label }))
|
||||
), [pricingQuery.data]);
|
||||
```
|
||||
|
||||
و همین را به `categories` مودال بدهد. `useServiceCategories` اگر مصرفکنندهٔ دیگری ندارد دستنخورده
|
||||
بماند (فرم خدمت از آن استفاده میکند — قبل از حذف grep کن).
|
||||
|
||||
`InsuranceModal.tsx`: دکمهٔ ثبت تا وقتی هر نوع خدمتِ رندرشده مقدار ۱..۱۰۰ نداشته باشد `disabled` بماند،
|
||||
و زیر ورودیِ خالی پیام «الزامی است» با `var(--danger)` نشان داده شود:
|
||||
|
||||
```tsx
|
||||
const percentsValid = categories.every((c) => {
|
||||
const n = Number(form.categoryPercents[c.key]);
|
||||
return Number.isFinite(n) && n > 0 && n <= 100;
|
||||
});
|
||||
…
|
||||
disabled={!form.insuranceId || !percentsValid || isPending}
|
||||
```
|
||||
|
||||
(وقتی `canUpdate === false` درصدها فقطخواندنی و ارسالنشدنیاند — در آن حالت این اجبار اعمال نشود.)
|
||||
|
||||
**نحوه تست:**
|
||||
- API: با توکن پزشک `09389388131`، `PUT /api/v1/insurance-pricing` با
|
||||
`service_categories: [{key:'outpatient',enabled:true},{key:'inpatient',enabled:false}]`، سپس
|
||||
`POST /api/v1/billing/tenant-insurances` یکبار بدون درصد (انتظار ۴۲۲) و یکبار با
|
||||
`[{key:'outpatient',coverage_percent:70}]` (انتظار ۲۰۱).
|
||||
- UI: `assets/admin/components/InsuranceModal.test.tsx` را با کیس «ثبت غیرفعال است تا درصد پر شود»
|
||||
و «فقط نوع خدمتِ فعال رندر میشود» گسترش بده؛ `yarn test`.
|
||||
|
||||
### ۴. فرانشیز درصدی در پنل
|
||||
|
||||
- `InsuranceModal.tsx`: برچسب «فرانشیز (درصد)»، ورودی `digitsOnly(value, 3)` با اعتبارسنجی ≤۱۰۰،
|
||||
`contractToForm()` از `c.franchise_percent` بخواند (بدون `rialToToman`)، و
|
||||
`buildInsurancePayload()` بفرستد:
|
||||
|
||||
```ts
|
||||
franchise_percent: isBasic ? 0 : Number(v.franchise) || 0,
|
||||
```
|
||||
|
||||
`Contract` و `InsuranceFormValues` هم بهروز شوند (`franchise_percent: number`).
|
||||
- `TenantInsuranceContracts.tsx`: `contractSummary()` → ``فرانشیز ${formatNumber(c.franchise_percent)}٪``
|
||||
و `ContractDetails` → مقدار `٪`دار.
|
||||
- `insuranceShares.ts`: `CoverageRule.franchise` به معنی درصد شود و `patientShareOf()` دقیقاً آینهٔ
|
||||
فرمول جدید سرور باشد (پایهٔ فرانشیز = باقیماندهٔ بعد از پایه). این فایل تنها منبع محاسبه در پنل است؛
|
||||
هیچ صفحهای فرمول موازی ننویسد.
|
||||
- `CreateStep.tsx`، `ServiceInsuranceModal.tsx`، `ServiceDetailPage.tsx`: `franchise_rials` →
|
||||
`franchise_percent`؛ در `ServiceInsuranceModal` ورودی از `PriceInput` به ورودی درصد تغییر کند و
|
||||
در `ServiceDetailPage` نمایش `فرانشیز {formatRial(...)}` به `فرانشیز {formatNumber(...)}٪` تبدیل شود.
|
||||
|
||||
**نحوه تست:** `npx tsc --noEmit --project tsconfig.json` (هیچ ارجاع باقیماندهای به `franchise_rials`
|
||||
نباید بماند — با grep هم تأیید کن) و `yarn test` (فایلهای تستِ متأثر:
|
||||
`InsuranceModal.test.tsx`, `TenantInsuranceContracts.test.tsx`, `session/CreateStep.test.tsx`,
|
||||
`appointments/ConfirmAppointmentModal.test.tsx`, `appointments/TurnsTimeline.test.tsx`,
|
||||
`pages/AppointmentEditPage.test.tsx`).
|
||||
|
||||
### ۵. بیمه در داشبورد مطالبات
|
||||
|
||||
**Repository** — در `patientAggregateSql()` و `detailsForPatient()` هر دو، join کاتالوگ اضافه شود
|
||||
(برای جستجوی نام بیمه) و در نمای تجمیعی، بیمههای هر بیمار جمع شوند:
|
||||
|
||||
```sql
|
||||
LEFT JOIN insurances ins ON ins.id = c.insurance_id
|
||||
…
|
||||
GROUP_CONCAT(DISTINCT CONCAT_WS('|', c.insurance_id, ins.name, c.insurance_kind) SEPARATOR '~') AS insurances,
|
||||
```
|
||||
|
||||
سپس در `aggregateByPatient()` به آرایهٔ ساختیافته تبدیل شود:
|
||||
|
||||
```php
|
||||
'insurances' => array_values(array_filter(array_map(static function (string $chunk): ?array {
|
||||
[$id, $name, $kind] = array_pad(explode('|', $chunk), 3, null);
|
||||
return $id === null || $id === '' ? null : [
|
||||
'insurance_id' => (int) $id,
|
||||
'insurance_name' => $name,
|
||||
'kind' => $kind, // base | supplementary
|
||||
];
|
||||
}, explode('~', (string) $r['insurances'])))),
|
||||
```
|
||||
|
||||
در `patientAggregateFilters()`:
|
||||
|
||||
```php
|
||||
if (!empty($filters['kind'])) {
|
||||
$conditions[] = 'c.insurance_kind = :kind';
|
||||
$params['kind'] = $filters['kind'];
|
||||
}
|
||||
if (!empty($filters['search'])) {
|
||||
$conditions[] = '(u.real_name LIKE :search OR u.mobile_number LIKE :search'
|
||||
. ' OR u.national_code LIKE :search OR ins.name LIKE :search)';
|
||||
$params['search'] = '%' . trim((string) $filters['search']) . '%';
|
||||
}
|
||||
```
|
||||
|
||||
⚠️ همان WHERE در `countPatientsWithClaims()` و `detailsForPatient()` هم استفاده میشود؛ join جدید باید
|
||||
در **هر سه** SQL باشد وگرنه شمارش با لیست واگرا میشود یا کوئری با «Unknown column ins.name» میشکند.
|
||||
شرط محیطِ `c.entity_type = :type AND c.entity_id = :id` را دست نزن (کامنت هشدارِ همان فایل).
|
||||
|
||||
**Controller** — `claimFilters()` کلید `kind` را بگیرد و مقدارش را اعتبارسنجی کند
|
||||
(`base` | `supplementary`، در غیر این صورت `ERR_VALIDATION_001` با ۴۲۲ و پیام «نوع بیمه نامعتبر است»).
|
||||
|
||||
**فرانت** — `ClaimsPage.tsx`:
|
||||
|
||||
```tsx
|
||||
const KIND_OPTIONS = [
|
||||
{ value: '', label: 'همه انواع' },
|
||||
{ value: 'base', label: 'پایه' },
|
||||
{ value: 'supplementary', label: 'تکمیلی' },
|
||||
];
|
||||
```
|
||||
|
||||
- فیلتر نوع بیمه با `SearchableSelect` (طبق قاعدهٔ پروژه؛ `<select>` بومی ممنوع) کنار فیلتر بیمه،
|
||||
با ذخیره در query string مثل بقیهٔ فیلترها.
|
||||
- ستون جدید بین «بیمار» و «تعداد درخواست»:
|
||||
|
||||
```tsx
|
||||
{ key: 'insurances', header: 'بیمه', render: (r) => (
|
||||
r.insurances.length === 0 ? '—' :
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{r.insurances.map((i) => (
|
||||
<span key={i.insurance_id} className="badge" title={KIND_TITLE[i.kind] ?? i.kind}>
|
||||
{i.insurance_name ?? `#${i.insurance_id}`}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) },
|
||||
```
|
||||
|
||||
- `searchPlaceholder` به «نام، موبایل، کد ملی بیمار یا نام بیمه» بهروز شود.
|
||||
- `ClaimPatientRow` در `types/index.ts` فیلد `insurances: { insurance_id: number; insurance_name: string | null; kind: string }[]` بگیرد.
|
||||
|
||||
**نحوه تست:** با توکن پزشک سناریو:
|
||||
`GET /api/v1/billing/claims/by-patient?limit=50` → هر ردیف `insurances` غیرخالی؛
|
||||
`?kind=supplementary` → فقط بیماران دارای مطالبهٔ تکمیلی؛ `?search=آسیا` → فقط بیماران آن بیمه؛
|
||||
`?kind=bogus` → ۴۲۲. و مقایسهٔ `meta.totalRecords` با تعداد ردیفها در حالت بدون صفحهبندی.
|
||||
|
||||
### ۶. سناریوی داده + تست دستی برای پزشک 09389388131
|
||||
|
||||
یک Console Command بساز: `src/Insurance/Command/SeedInsuranceScenarioCommand.php`
|
||||
(`app:seed-insurance-scenario`)، با آپشنهای `--doctor-mobile=09389388131` و `--purge`.
|
||||
|
||||
منطق (همه از مسیر سرویسهای واقعی، نه INSERT خام — تا اعداد را همان `BillingCalculator` بسازد):
|
||||
|
||||
1. پزشک را از موبایل پیدا کن (`users.mobile_number` → `doctors.user_id`). نبود پزشک = خطای واضح.
|
||||
محیط: `entity_type='doctor'`, `entity_id = doctors.id` (برای این موبایل: `11548`).
|
||||
2. هر دو نوع خدمت (`outpatient`, `inpatient`) را برای این tenant فعال کن
|
||||
(`TenantServiceCategoryService::save()`).
|
||||
3. یک بیمهٔ پایه (`تامین اجتماعی`, id 176) + سه بیمهٔ تکمیلی از کاتالوگ فعال کن — مثلاً
|
||||
`بیمه ایران` (182)، `بیمه آسیا` (183)، `بیمه دی` (184) — با
|
||||
`TenantInsuranceService::activate()` و درصدهای متفاوت، و برای هر کدام
|
||||
`setCategoryCoverages()` با درصد سرپایی و بستریِ متفاوت:
|
||||
|
||||
| بیمه | نوع | سرپایی | بستری | فرانشیز | سقف سالانه |
|
||||
|---|---|---|---|---|---|
|
||||
| تامین اجتماعی | پایه | ۳۰٪ | ۴۰٪ | — | نامحدود |
|
||||
| بیمه ایران | تکمیلی | ۹۰٪ | ۸۰٪ | ۱۰٪ | ۵۰٬۰۰۰٬۰۰۰ ریال |
|
||||
| بیمه آسیا | تکمیلی | ۷۰٪ | ۶۰٪ | ۲۰٪ | نامحدود |
|
||||
| بیمه دی | تکمیلی | ۱۰۰٪ | ۵۰٪ | ۰٪ | ۱۰٬۰۰۰٬۰۰۰ ریال |
|
||||
|
||||
4. چهار بیمار با موبایل نشاندار `091299000{1..4}` بساز (marker مخصوص همین سناریو تا `--purge`
|
||||
بتواند دقیقاً همانها را پاک کند)، برای هرکدام `PatientRecord` در همین محیط، و مراجعه/صورتحساب
|
||||
با ترکیبهای متفاوت: بیمار۱ فقط پایه، بیمار۲ پایه + بیمه ایران، بیمار۳ فقط بیمه آسیا،
|
||||
بیمار۴ پایه + بیمه دی با خدمتی که سقف را رد میکند (تست سقف).
|
||||
5. صورتحسابها را نهایی کن و با `ClaimService::createFromInvoice()` مطالبه بساز؛ سپس چند مطالبه را
|
||||
با `ClaimService::transition()` جلو ببر: یکی `submitted`، یکی `approved`، یکی `paid`،
|
||||
یکی `rejected` با دلیل — تا هر پنج وضعیتِ فیلتر داده داشته باشد.
|
||||
6. در پایان یک جدول خلاصه در خروجی کنسول چاپ کن: بیمار، بیمه، کل، سهم بیمه، سهم بیمار، وضعیت.
|
||||
|
||||
**نحوه تست:**
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console app:seed-insurance-scenario --doctor-mobile=09389388131 --purge
|
||||
ddev exec php bin/console app:seed-insurance-scenario --doctor-mobile=09389388131 # بار دوم: بدون دادهی تکراری
|
||||
```
|
||||
|
||||
سپس ورود به پنل با `09389388131` و بررسی دستی:
|
||||
- `/admin/insurance-pricing`: تب «بیمه تکمیلی» سه قرارداد، هر کدام با «سرپایی X٪ · بستری Y٪ · فرانشیز Z٪ · سقف …»؛
|
||||
باز کردن مودال ویرایش → فرانشیز درصدی، و اگر یکی از نوعهای خدمت را غیرفعال کنی آن ورودی محو شود.
|
||||
- `/admin/claims`: ستون «بیمه» پر، فیلتر «نوع بیمه» کار کند، جستجوی «آسیا» فقط بیمار۳ را بدهد،
|
||||
کارتهای آمار با جمع ستونها همخوان باشند.
|
||||
- ورود به جزئیات یک بیمار: سهمها با فرمول فرانشیز درصدی همخوان باشند
|
||||
(`مبلغ اصلی − سهم بیمه = سهم بیمار`).
|
||||
|
||||
اسکرینشات هر دو صفحه را بعد از سناریو ضمیمه کن.
|
||||
|
||||
### ۷. مستندات
|
||||
|
||||
`docs/api/insurance.md` و `docs/api/billing.md` را در همین session بهروز کن:
|
||||
- تغییر فیلد `franchise_rials` → `franchise_percent` در
|
||||
`POST/PATCH /api/v1/billing/tenant-insurances` و
|
||||
`PUT /api/v1/billing/tenant-insurances/{uuid}/service-coverage` (با محدودهٔ ۰..۱۰۰).
|
||||
- اجباریبودن `category_coverages` برای نوع خدمتِ فعال + خطای ۴۲۲ مربوطه.
|
||||
- پارامتر جدید `kind` و رفتار تازهٔ `search` در `GET /api/v1/billing/claims/by-patient` و فیلد
|
||||
خروجی `insurances`.
|
||||
- فرمول جدید محاسبهٔ سهم (همان بلوک شبهکد بالا) در بخش محاسبهٔ صورتحساب.
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **ترتیب اجرا:** اول migration + مدل + فرمول (وظایف ۱–۲)، بعد بکاندِ اعتبارسنجی (۳)، بعد فرانت (۴–۵)،
|
||||
و سناریو (۶) در آخر — سناریو باید روی کد نهایی اجرا شود تا اعدادش با فرمول جدید ساخته شوند.
|
||||
- `franchise_rials` هیچجا نباید باقی بماند: بعد از پایان کار
|
||||
`grep -rn "franchise_rials" src/ assets/ docs/ migrations/` فقط باید فایل migration جدید و
|
||||
migrationهای تاریخی `Version20260622*.php` را نشان بدهد.
|
||||
- **قاعدهٔ فرانشیز فقط تکمیلی:** در `buildRule()` و در `insuranceShares.ts::ruleOf()` این شرط
|
||||
(`kind === 'supplementary'`) دستنخورده بماند؛ تغییر واحد از ریال به درصد است، نه تغییر دامنهٔ اثر.
|
||||
- **دو واژگان جدا:** قرارداد `kind` = `basic`/`supplementary` ولی مطالبه `insurance_kind` =
|
||||
`base`/`supplementary`. در ClaimsPage از `base` استفاده کن (نه `basic`)؛ این تفاوت واقعی است
|
||||
و در `ClaimPatientDetailPage` هم همینطور نگاشت شده.
|
||||
- **SQL خام و tenant:** کوئریهای `ClaimRepository` زیر `TenantFilter` نیستند؛ افزودن join نباید
|
||||
شرط `c.entity_type/:type` را جابهجا یا مشروط کند.
|
||||
- **الگوها:** کد جدید همان الگوی موجود را نگه دارد — controller نازک، منطق در Service، کوئری در
|
||||
Repository، پاسخها با `$this->success()/paginated()/error()`، تاریخها `int` یونیکس. الگوی طراحیِ
|
||||
تازه لازم نیست؛ `CoverageRule` همچنان همان Value Object است و فقط واحد یکی از فیلدهایش عوض میشود.
|
||||
- **UI:** هیچ طراحی جدیدی نساز — از `Modal`، `SearchableSelect`، `DataTable`، `StatusBadge`، توکنهای
|
||||
`styles.css` و کلاسهای موجود (`badge`, `btn`, `input`, `field-label`) استفاده کن. رشتهها فارسی،
|
||||
اعداد با `formatNumber` و درصد با `٪`.
|
||||
- **بدون تست سبز تمام نیست:** `ddev exec php bin/phpunit` و `yarn test` و
|
||||
`npx tsc --noEmit --project tsconfig.json` هر سه باید پاس شوند، و
|
||||
`ddev exec php vendor/bin/phpstan analyse` رگرسیون جدید ندهد.
|
||||
Reference in New Issue
Block a user