- 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.
36 KiB
بیمهٔ تکمیلی: فرانشیز درصدی، درصد پوشش الزامی برای نوع خدمت فعال، و شناسایی بیمه در مطالبات
پروژه
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یک ردیف بهازای هر بیمار میدهد.
سه ایراد واقعی در همین مسیر وجود دارد:
- مودال «افزودن بیمه» ورودی «درصد پوشش» را برای هر دو نوع خدمت رندر میکند، بیتوجه به اینکه tenant کدام نوع را فعال کرده، و خالیماندنِ آنها هیچ خطایی نمیدهد → قرارداد با پوشش صفر ثبت میشود.
- فرانشیز بهصورت «تومان» گرفته و ذخیره میشود (
franchise_rials) در حالی که فرانشیز در بیمهٔ تکمیلی یک درصد است. - در داشبورد مطالبات (سطح اول) هیچجا معلوم نیست هر ردیف مربوط به کدام بیمه است، و نه فیلتر «نوع بیمه»
دارد و نه جستجو روی نام بیمه کار میکند. (سطح دومِ
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 (خطوط ۳۱–۴۳) — فرانشیز مبلغ ثابت:
$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ها رندر میشوند،
هیچ اجباری روی مقدار نیست، و فرانشیز تومانی است:
<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():
franchise_rials: isBasic ? 0 : tomanToRial(Number(v.franchise) || 0),
clinicpro/assets/admin/components/TenantInsuranceContracts.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 سطح بیمار هست و نه
جستجو/فیلتر نوع بیمه:
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:
#[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)، چون تبدیل ریال به درصد معنا ندارد:
$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:
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() — مبلغ فرانشیز از پایهٔ «قبل از کسر سهم تکمیلی» ساخته میشود:
$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()) اضافه کن:
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 یک درصد معتبر آمده باشد:
$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 میشود بگیرد (بدون درخواست جدید):
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) نشان داده شود:
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()بفرستد: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 کاتالوگ اضافه شود
(برای جستجوی نام بیمه) و در نمای تجمیعی، بیمههای هر بیمار جمع شوند:
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() به آرایهٔ ساختیافته تبدیل شود:
'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():
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:
const KIND_OPTIONS = [
{ value: '', label: 'همه انواع' },
{ value: 'base', label: 'پایه' },
{ value: 'supplementary', label: 'تکمیلی' },
];
-
فیلتر نوع بیمه با
SearchableSelect(طبق قاعدهٔ پروژه؛<select>بومی ممنوع) کنار فیلتر بیمه، با ذخیره در query string مثل بقیهٔ فیلترها. -
ستون جدید بین «بیمار» و «تعداد درخواست»:
{ 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 بسازد):
-
پزشک را از موبایل پیدا کن (
users.mobile_number→doctors.user_id). نبود پزشک = خطای واضح. محیط:entity_type='doctor',entity_id = doctors.id(برای این موبایل:11548). -
هر دو نوع خدمت (
outpatient,inpatient) را برای این tenant فعال کن (TenantServiceCategoryService::save()). -
یک بیمهٔ پایه (
تامین اجتماعی, id 176) + سه بیمهٔ تکمیلی از کاتالوگ فعال کن — مثلاًبیمه ایران(182)،بیمه آسیا(183)،بیمه دی(184) — باTenantInsuranceService::activate()و درصدهای متفاوت، و برای هر کدامsetCategoryCoverages()با درصد سرپایی و بستریِ متفاوت:بیمه نوع سرپایی بستری فرانشیز سقف سالانه تامین اجتماعی پایه ۳۰٪ ۴۰٪ — نامحدود بیمه ایران تکمیلی ۹۰٪ ۸۰٪ ۱۰٪ ۵۰٬۰۰۰٬۰۰۰ ریال بیمه آسیا تکمیلی ۷۰٪ ۶۰٪ ۲۰٪ نامحدود بیمه دی تکمیلی ۱۰۰٪ ۵۰٪ ۰٪ ۱۰٬۰۰۰٬۰۰۰ ریال -
چهار بیمار با موبایل نشاندار
091299000{1..4}بساز (marker مخصوص همین سناریو تا--purgeبتواند دقیقاً همانها را پاک کند)، برای هرکدامPatientRecordدر همین محیط، و مراجعه/صورتحساب با ترکیبهای متفاوت: بیمار۱ فقط پایه، بیمار۲ پایه + بیمه ایران، بیمار۳ فقط بیمه آسیا، بیمار۴ پایه + بیمه دی با خدمتی که سقف را رد میکند (تست سقف). -
صورتحسابها را نهایی کن و با
ClaimService::createFromInvoice()مطالبه بساز؛ سپس چند مطالبه را باClaimService::transition()جلو ببر: یکیsubmitted، یکیapproved، یکیpaid، یکیrejectedبا دلیل — تا هر پنج وضعیتِ فیلتر داده داشته باشد. -
در پایان یک جدول خلاصه در خروجی کنسول چاپ کن: بیمار، بیمه، کل، سهم بیمه، سهم بیمار، وضعیت.
نحوه تست:
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رگرسیون جدید ندهد.