An appointment can now carry the insurance it is billed with: the service kind (outpatient/inpatient) and the basic insurance. Confirming it no longer hands the whole amount to the patient — the visit is split through BillingCalculator with the coverage percent of that service kind, and the choice travels to the encounter and the invoice built from it. The enabled service kinds are a tenant-wide setting (all of that tenant's insurances share it), so a tenant covering only one kind is never asked which one: the panel resolves it the same way the server does. - add tenant_service_category_settings + TenantServiceCategoryService, exposed on the existing insurance-pricing endpoint (service_categories, default_service_category); at least one kind must stay enabled - add appointments.insurance_service_category / insurance_base_id with AppointmentInsuranceService validating them against the tenant's own settings and active contracts (basic only), accepted by PATCH and by confirm - snapshot the kind on patient_sessions and invoices; the visit's coverage rule is resolved per kind (services keep using their own ServiceItem.service_category) - lib/insuranceShares becomes the single client-side mirror of BillingCalculator, shared by the confirm modal, the appointment edit page and the session form - surface the selection: confirm modal (with live shares), turns timeline chip, appointment edit page, patient record service card and invoice summary - the session form shows the insurance block whenever the tenant has an active contract and prefills the patient's own insurance, so it can be changed - fix: the confirm modal showed a zero visit price when the appointment had none — it now falls back to the tenant's free-visit price like the server - fix: useServiceCategories read one level too shallow, so Persian labels never arrived and raw enum keys leaked into the contract summary - fix: BlogsPage test asserted the public blogs endpoint after the page moved to the admin one Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
26 KiB
انتخاب نوع خدمت بیمه (سرپایی/بستری) در نوبت و انتشار آن تا فاکتور
پروژه
clinicpro (بکاند Symfony + پنل ادمین React). دامنهها: src/Insurance/، src/Appointment/، src/Patient/، src/Billing/، assets/admin/.
سایت عمومی nobat724_front هیچجا بیمه را در فرآیند رزرو انتخاب نمیکند؛ فیلدهای جدید نوبت optional و nullable هستند → پرامپت همتا لازم نیست (فقط قرارداد پاسخ GET /api/v1/appointment/{uuid} دو کلید جدید میگیرد که مصرفکنندهٔ فعلی ندارد).
زمینه
در پرامپت قبلی (insurance-coverage-percent-rework.md) درصد پوشش هر بیمه به تفکیک نوع خدمت (ServiceCategory: outpatient / inpatient) پیاده شد: پیشفرض مرکزی ادمین (insurance_coverage_defaults)، override قرارداد (tenant_insurance_category_coverage) و زنجیرهٔ resolve در TenantInsuranceService::resolvePercent. نوع خدمتِ هر «آیتم سرویس» هم روی ServiceItem.service_category ذخیره میشود و ویزیت تا الآن همیشه outpatient فرض شده است.
اما در جریان کاری واقعی، پزشک ممکن است اصلاً خدمات بستری ارائه نکند، یا هر دو را ارائه کند و لازم باشد سرِ پذیرش مشخص شود این مراجعه سرپایی است یا بستری. الآن هیچکدام از اینها وجود ندارد:
- پزشک/کلینیک نمیتواند بگوید بیمههایش کدام نوع خدمات را پوشش میدهند.
- نوبت هیچ فیلدی برای «بیمهٔ انتخابی» و «نوع خدمت» ندارد → مودال «قطعی کردن نوبت» فقط جمع کل را نشان میدهد و کل مبلغ سهم بیمار میشود.
PatientService::autoCreateOnAppointmentConfirmعامدانه بیمه را نادیده میگیرد:applyShares($gross, 0, 0, $gross).- پرونده بیمار (بخش سرویسها) و فاکتور، نوع خدمت و نام بیمه را نشان نمیدهند.
هدف
الف) در تنظیمات ← مدیریت بیمه (/admin/insurance-pricing) یک تنظیم سراسری برای همهٔ بیمههای همان پزشک/کلینیک: کدام نوع خدمات بیمه فعال است — «خدمات سرپایی» و/یا «خدمات بستری». (این تنظیم per-insurance نیست؛ یک بار برای کل tenant.)
ب) اگر فقط یک نوع فعال باشد، همان بهصورت خودکار مبنای محاسبه است و هیچ انتخابی از کاربر پرسیده نمیشود. اگر هر دو فعال باشند، انتخاب نوع خدمت نمایش داده شود در:
- مودال «قطعی کردن نوبت» (که از تایملاین نوبتها و صفحهٔ جزئیات نوبت باز میشود)
- صفحهٔ ویرایش نوبت (
/admin/appointments/{uuid}/edit)
ج) پس از انتخاب نوع خدمت، لیست بیمههای پایهٔ فعالِ همان پزشک نمایش داده شود؛ با انتخاب بیمه، سهم بیمه و مبلغ پرداختی بیمار خودکار محاسبه و نمایش داده شود (آینهٔ BillingCalculator، همان قاعدهٔ round(کل × درصد ÷ 100)).
د) نوع خدمت + بیمهٔ انتخابی روی نوبت ذخیره شود، به مراجعه (PatientSession) منتقل شود، در پرونده بیمار ← سرویسها نمایش داده شود و در فاکتور درج شود (نوع خدمت، بیمه، سهم بیمه، سهم بیمار).
فایلهای مرتبط
| فایل | نقش |
|---|---|
src/Insurance/Enum/ServiceCategory.php |
enum موجود outpatient/inpatient (+ values(), label(), tryFromValue()) |
src/Insurance/Entity/EntityInsurancePricing.php |
تنظیمات بیمهٔ tenant (ردیف insurance_id = NULL = قیمت ویزیت آزاد + require_visit_price) |
src/Insurance/Controller/InsuranceController.php |
GET/PUT /api/v1/insurance-pricing (pricingPayload()) + قراردادهای tenant |
src/Insurance/Service/TenantInsuranceService.php |
coverageRule() (ویزیت، الآن hardcode Outpatient)، coverageRuleForService()، resolvePercent() |
src/Appointment/Entity/Appointment.php |
فاقد فیلد بیمه/نوع خدمت |
src/Appointment/Controller/AppointmentController.php |
PATCH /api/v1/appointment/{uuid} (خط ۱۰۵۶) و POST /api/v1/appointment/{uuid}/confirm (خط ۹۸۵) |
src/Appointment/Service/AppointmentConfirmationService.php |
confirmWithPayments() — تراکنش قطعیکردن + ساخت مراجعه + پرداختها |
src/Patient/Service/PatientService.php |
autoCreateOnAppointmentConfirm() / autoCreateForEntity() / calculateFinalPrice() / createSession() |
src/Patient/Entity/PatientSession.php |
insurance_base_id, base_insurance_rials, … — فاقد نوع خدمت |
src/Billing/Service/InvoiceService.php |
createFromSession() — همان CoverageRule |
src/Billing/Entity/Invoice.php |
base_insurance_id, base_insurance_rials, … — فاقد نوع خدمت |
assets/admin/pages/InsurancePricingPage.tsx |
صفحهٔ «مدیریت بیمه» داخل SettingsLayout active="insurance" |
assets/admin/components/TenantInsuranceContracts.tsx |
کارت قراردادهای بیمه + useServiceCategories() |
assets/admin/components/appointments/ConfirmAppointmentModal.tsx |
مودال «قطعی کردن نوبت» |
assets/admin/components/appointments/TurnsTimeline.tsx |
تایملاین نوبتها (خط ۱۴۵ مودال را باز میکند) |
assets/admin/pages/AppointmentEditPage.tsx |
صفحهٔ ویرایش نوبت |
assets/admin/components/SessionServiceCard.tsx |
کارت سرویسهای مراجعه در پرونده بیمار |
assets/admin/components/InvoiceSummaryModal.tsx |
نمایش فاکتور |
assets/admin/hooks/useServiceCategories.ts |
لیست انواع خدمت از GET /api/v1/service-categories |
docs/api/insurance.md, docs/api/appointment.md, docs/api/patient.md, docs/api/billing.md |
مستندات |
وضعیت فعلی
۱) قطعیکردن نوبت، بیمه را کاملاً نادیده میگیرد — src/Patient/Service/PatientService.php:212-226
// خطوط هزینهی سرویس (قیمت snapshot از خود سرویس، بدون ورود دستی).
$servicesTotal = 0;
$lines = [];
foreach ($appointment->getServiceItems() as $item) {
$line = new SessionService($session, $item, null, 1);
$lines[] = $line;
$servicesTotal += $line->getLineTotalRials();
}
$session->setServicesTotalRials($servicesTotal);
// پذیرش خودکار بیمهای ندارد: تمام مبلغ سهم بیمار است.
$gross = $servicesTotal + $visitPrice;
$session->applyShares($gross, 0, 0, $gross);
۲) قاعدهٔ ویزیت نوع خدمت را نمیپذیرد — src/Insurance/Service/TenantInsuranceService.php
public function coverageRule(string $entityType, int $entityId, ?int $insuranceId): CoverageRule
{
// ...
return $this->buildRule($contract, ServiceCategory::Outpatient, null);
}
۳) مودال قطعیکردن فقط جمع کل را میشناسد — assets/admin/components/appointments/ConfirmAppointmentModal.tsx:130-146
const visitPrice = Number(appt?.visit_price_rials ?? 0);
const services = appt?.service_items ?? [];
const servicesTotal = useMemo(
() => services.reduce((sum, s) => sum + Number(s.price_rials ?? 0), 0),
[services],
);
const total = visitPrice + servicesTotal;
// ردیفِ اول تا لحظهای که کاربر مبلغ را دستی تغییر ندهد پیشفرضِ «پرداخت کامل» است؛
useEffect(() => {
if (!open || touched || total <= 0) return;
setRows(prev => prev.map((r, i) => (i === 0 ? { ...r, amountToman: rialToToman(total) } : r)));
}, [open, touched, total]);
و بدنهٔ ارسالی هیچ فیلد بیمهای ندارد:
api.post(`/api/v1/appointment/${appointmentUuid}/confirm`, {
version: appt?.version,
payments: rows.filter(...).map(...),
})
۴) صفحهٔ ویرایش نوبت — assets/admin/pages/AppointmentEditPage.tsx:82-94
const save = useMutation({
mutationFn: () => api.patch(`/api/v1/appointment/${uuid}`, {
slot_start: toEpoch(date, start),
slot_end: toEpoch(date, end),
service_section_uuid: sectionUuid,
service_item_uuid: itemUuid,
staff_uuid: staffUuid,
deposit_required: depositRequired,
deposit_amount_rials: depositRequired ? tomanToRial(depositToman) : null,
note,
...(status !== a?.status ? { status } : {}),
version: a?.version,
}),
وظایف
ترتیب: ۱ → ۹. بکاند اول، بعد فرانت، بعد مستندات/تست. هر گام مستقل تستشدنی باشد.
۱. تنظیم سراسریِ «نوع خدمات بیمه» برای هر tenant
Entity جدید src/Insurance/Entity/TenantServiceCategorySetting.php (tenant_service_category_settings) + src/Insurance/Repository/TenantServiceCategorySettingRepository.php:
#[ORM\Entity(repositoryClass: TenantServiceCategorySettingRepository::class)]
#[ORM\Table(name: 'tenant_service_category_settings')]
#[ORM\UniqueConstraint(name: 'uniq_tenant_service_category_setting', columns: ['entity_type', 'entity_id', 'service_category'])]
class TenantServiceCategorySetting
{
#[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(name: 'entity_type', type: 'string', length: 10)]
private string $entityType; // doctor|clinic — همان الگوی EntityInsurancePricing
#[ORM\Column(name: 'entity_id', type: 'integer')]
private int $entityId;
#[ORM\Column(name: 'service_category', type: 'string', length: 30, enumType: ServiceCategory::class)]
private ServiceCategory $serviceCategory;
#[ORM\Column(type: 'boolean', options: ['default' => true])]
private bool $enabled = true;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
// getters/setters + toArray()
}
Service جدید src/Insurance/Service/TenantServiceCategoryService.php:
/** @return list<string> نوعهای فعال؛ نبودِ ردیف = همهٔ نوعها فعال (سازگاری عقبرو) */
public function enabledKeys(string $entityType, int $entityId): array;
/** @return list<array{key: string, label: string, enabled: bool}> برای UI — همیشه همهٔ caseها */
public function settingsRows(string $entityType, int $entityId): array;
/**
* نوع خدمتِ پیشفرض: اگر فقط یک نوع فعال باشد همان، وگرنه null
* (یعنی کاربر باید انتخاب کند).
*/
public function defaultCategory(string $entityType, int $entityId): ?ServiceCategory;
public function isEnabled(string $entityType, int $entityId, ServiceCategory $category): bool;
/** @param list<array{key?: string, enabled?: mixed}> $rows */
public function save(string $entityType, int $entityId, array $rows): void; // حداقل یک نوع باید فعال بماند
migration + ddev exec php bin/console doctrine:migrations:migrate --no-interaction (روی db و --env=test). backfill لازم نیست: نبودِ ردیف = «همه فعال».
۲. انتشار تنظیم در API موجود (بدون endpoint جدید)
GET /api/v1/insurance-pricing همینحالا تنظیماتِ سراسریِ بیمهٔ tenant را میدهد (free_visit_price_rials, require_visit_price, insurances[]), پس همان را توسعه بده — endpoint جدید نساز:
- در
pricingPayload()کلید تازه:"service_categories": [ { "key": "outpatient", "label": "خدمات سرپایی", "enabled": true }, { "key": "inpatient", "label": "خدمات بستری", "enabled": false } ], "default_service_category": "outpatient"default_service_categoryهمان خروجیdefaultCategory()است (nullوقتی هر دو فعالاند). PUT /api/v1/insurance-pricingبدنهٔ اختیاریservice_categories: [{ key, enabled }]بپذیرد. اعتبارسنجی:key ∈ ServiceCategory::values()و حداقل یک نوع فعال → وگرنه$this->error(ErrorCodes::ERR_VALIDATION_001, 'حداقل یک نوع خدمت باید فعال باشد', 422, 'service_categories').- همان قواعد دسترسیِ فعلیِ این endpoint (منشی:
insurances.view/update، وdoctor_uuidبرای کلینیک چندپزشکه) بدون تغییر اعمال شود.
۳. فیلدهای بیمه روی نوبت
src/Appointment/Entity/Appointment.php دو ستون جدید:
/** نوع خدمتِ بیمهایِ این نوبت؛ null = هنوز انتخاب نشده. */
#[ORM\Column(name: 'insurance_service_category', type: 'string', length: 30, nullable: true, enumType: ServiceCategory::class)]
private ?ServiceCategory $insuranceServiceCategory = null;
/** بیمهٔ پایهٔ انتخابشده (ارجاع خام int، مثل TenantInsurance/Tariff). */
#[ORM\Column(name: 'insurance_base_id', type: 'integer', nullable: true)]
private ?int $insuranceBaseId = null;
- در
toArray():insurance_service_category,insurance_service_category_label,insurance_base_id,insurance_base_name(نام ازInsuranceRepository؛ در لیستها بدون N+1 — اگر لازم شد نام را در کنترلر با یک map تزریق کن، نه داخل entity). - migration.
PATCH /api/v1/appointment/{uuid} (خط ۱۰۵۶) این دو فیلد را بپذیرد؛ اعتبارسنجی مشترک را در یک helper خصوصی بگذار تا confirm هم از آن استفاده کند:
/**
* نوع خدمت و بیمهٔ پایه را روی نوبت مینشاند.
* - نوع خدمت باید در تنظیمات همان tenant فعال باشد
* - بیمه باید قرارداد فعال داشته باشد و پایه باشد
*/
private function applyAppointmentInsurance(Appointment $appointment, array $data): ?JsonResponse
خطاها: 422 ERR_VALIDATION_001 با فیلد insurance_service_category (نوع نامعتبر یا غیرفعال) · 422 ERR_VALIDATION_001 با فیلد insurance_base_id (بیمه بدون قرارداد فعال یا نوعش تکمیلی است).
POST /api/v1/appointment/{uuid}/confirm هم همان دو فیلد را در بدنه بپذیرد (انتخاب سرِ پذیرش) و قبل از confirmWithPayments روی نوبت بنشاند تا مراجعه با بیمهٔ درست ساخته شود.
۴. نوع خدمت روی مراجعه + محاسبهٔ واقعیِ سهم در قطعیکردن
src/Patient/Entity/PatientSession.php: ستون insurance_service_category (nullable, enumType: ServiceCategory::class) + getter/setter + کلید در toArray() (insurance_service_category, insurance_service_category_label). migration.
TenantInsuranceService::coverageRule() یک پارامتر اختیاری بگیرد و پیشفرضش رفتار فعلی بماند:
public function coverageRule(
string $entityType,
int $entityId,
?int $insuranceId,
ServiceCategory $category = ServiceCategory::Outpatient,
): CoverageRule
PatientService::calculateFinalPrice() هم پارامتر اختیاری ?ServiceCategory $visitCategory = null بگیرد و برای ویزیت آن را (یا Outpatient در نبودش) به coverageRule() بدهد. سهم خدمات بدون تغییر از service_category خودِ ServiceItem میآید.
PatientService::autoCreateForEntity() جای applyShares($gross, 0, 0, $gross):
$session->setInsuranceBaseId($appointment->getInsuranceBaseId());
$session->setInsuranceServiceCategory($category); // از نوبت، یا defaultCategory() همان tenant
$session->setBaseInsuranceDiscountPercent($this->contractPercent(...)); // snapshot نمایشی
$calc = $this->calculateFinalPrice($visitPrice, $serviceLines, $entityType, $entityId, $appointment->getInsuranceBaseId(), null, $category);
$session->applyShares(
$calc['gross_total_rials'],
$calc['base_insurance_rials'],
$calc['supplementary_insurance_rials'],
$calc['patient_share_rials'],
);
- اگر نوبت بیمه ندارد، رفتار فعلی (کل مبلغ سهم بیمار) دقیقاً حفظ شود.
- نوع خدمتِ مؤثر:
appointment->getInsuranceServiceCategory() ?? $categoryService->defaultCategory(...) ?? ServiceCategory::Outpatient. createSession()و مسیرPATCH /api/v1/session/{uuid}همinsurance_service_categoryرا بپذیرند و بهcalculateFinalPrice()بدهند (همخوان باMyPatientsPage).
۵. مودال «قطعی کردن نوبت»
assets/admin/components/appointments/ConfirmAppointmentModal.tsx:
useQueryروی/api/v1/insurance-pricing(برایservice_categories+default_service_category) و/api/v1/billing/tenant-insurances(قراردادها). هر دوenabled: open.- state:
serviceCategory(init: مقدار نوبت، وگرنهdefault_service_category)،insuranceId(init:appt.insurance_base_id). SearchableSelect«نوع خدمت» فقط وقتیservice_categories.filter(c => c.enabled).length > 1رندر شود؛ در غیر این صورت نوع فعال بیسروصدا استفاده شود (بدون UI).SearchableSelect«بیمه» با قراردادهایis_active === trueوinsurance_kind === 'basic'.- محاسبهٔ زنده (آینهٔ
BillingCalculator، همانpatientShareOfکه درassets/admin/components/session/CreateStep.tsxexport شده — از همان استفاده کن، منطق تازه ننویس):- درصد از
contract.category_coverages[serviceCategory](fallback:contract.coverage_percent). - ویزیت با نوع خدمتِ انتخابی، هر سرویس با
service_categoryخودش. - دو ردیف نمایشی جدید در خلاصهٔ مبالغ: «سهم بیمه» و «سهم بیمار (قابل پرداخت)» با
formatRial.
- درصد از
- پیشفرضِ ردیف اولِ پرداخت از
totalبه سهم بیمار تغییر کند (وقتی بیمه انتخاب شده)؛ سقفoverpaidهم بر همان مبنا. - بدنهٔ
confirmدو کلید تازه بفرستد:api.post(`/api/v1/appointment/${appointmentUuid}/confirm`, { version: appt?.version, ...(serviceCategory ? { insurance_service_category: serviceCategory } : {}), ...(insuranceId ? { insurance_base_id: Number(insuranceId) } : {}), payments: [...], })
۶. تایملاین نوبتها
assets/admin/components/appointments/TurnsTimeline.tsx:
- کارت نوبت، وقتی
insurance_service_categoryیاinsurance_base_nameدارد، یک چیپ کوچک نمایش دهد (سرپایی · بیمه ایران) با همان توکنهای موجود (--primary-soft/--text-2)؛ طراحی تازه نساز. appointmentرا بهConfirmAppointmentModalپاس بده (appointment={a}) تا درخواست جزئیات تکراری نزند و مقدارهای فعلی بیمه در مودال پیشپر شوند.
۷. صفحهٔ ویرایش نوبت
assets/admin/pages/AppointmentEditPage.tsx:
AppointmentDetailدو فیلد جدید بگیرد؛ state + hydrate در همانuseEffect.- دو
SearchableSelect(«نوع خدمت بیمه» با همان شرط «هر دو فعال»، و «بیمه») در بلوک مشخصات، با همانlabelاستایل موجود. - پیشنمایش «سهم بیمه / سهم بیمار» زیر انتخاب بیمه (همان تابع مشترک محاسبه).
save.mutationFnدو کلید جدید را در بدنهٔ PATCH بفرستد.
۸. پرونده بیمار (سرویسها) + فاکتور
assets/admin/components/SessionServiceCard.tsx:
- در بخش تفکیک بیمه، دو ردیف تازه: «نوع خدمت بیمه» (
insurance_service_category_label) و «بیمه» (نام بیمه). داده ازPatientSession::toArray(). - نام بیمه در پاسخ session لازم است: در
src/Patient/Controller/PatientController.php(مسیر لیست/جزئیات مراجعه) نامها را با یک کوئری map شده اضافه کن (insurance_base_name) — بدون N+1 و بدون گذاشتن ریپازیتوری داخل entity.
src/Billing/Entity/Invoice.php + src/Billing/Service/InvoiceService.php:
- ستون
service_categoryرویinvoices(nullable, enumType) که ازPatientSession::getInsuranceServiceCategory()snapshot میشود؛ migration. Invoice::toArray()→service_category,service_category_label؛ وbase_insurance_nameدر پاسخ کنترلر فاکتور (همان الگوی map).
assets/admin/components/InvoiceSummaryModal.tsx:
- در هدر/خلاصهٔ فاکتور: «نوع خدمت بیمه»، «بیمه»، و ستونهای موجود «سهم بیمه پایه» و «سهم بیمار» حفظ شوند (خط ۱۲۰ فعلی).
۹. مستندات و تست
docs/api/insurance.md: کلیدهایservice_categoriesوdefault_service_categoryدرGET/PUT /api/v1/insurance-pricing+ قاعدهٔ «حداقل یک نوع فعال» + توضیح اینکه این تنظیم سراسری است نه per-insurance.docs/api/appointment.md: فیلدهایinsurance_service_category/insurance_base_idدرGET,PATCH, و بدنهٔPOST /confirmبا خطاهایشان.docs/api/patient.md:insurance_service_categoryروی مراجعه + اینکه قطعیکردن نوبت دیگر کل مبلغ را سهم بیمار نمیگذارد.docs/api/billing.md:service_categoryروی فاکتور.- PHPUnit:
TenantServiceCategoryService: نبودِ ردیف → همه فعال · یک نوع فعال →defaultCategory()همان · هر دو فعال →null· غیرفعالکردن همه → ۴۲۲.PUT /api/v1/insurance-pricingباservice_categoriesنامعتبر → ۴۲۲.PATCH /api/v1/appointment/{uuid}با نوع غیرفعال → ۴۲۲؛ با بیمهٔ بدون قرارداد فعال → ۴۲۲.POST /confirmباinsurance_base_id: مراجعهٔ ساختهشده بایدbase_insurance_rialsوpatient_share_rialsدرست داشته باشد (سناریوی مرجع: ویزیت5_952_000ریال، پوشش بستری ۳۰٪ →1_785_600/4_166_400).InvoiceService:service_categoryروی فاکتور snapshot شود.
- Vitest:
ConfirmAppointmentModal(پنهانبودن انتخاب نوع خدمت وقتی فقط یکی فعال است؛ محاسبهٔ سهم بیمه/بیمار؛ فیلدهای بدنهٔ confirm)،AppointmentEditPage(ارسال دو فیلد جدید). - اجرای واقعی:
ddev exec php bin/phpunit·ddev exec php vendor/bin/phpstan analyse·ddev exec npx tsc --noEmit --project tsconfig.json·ddev exec yarn dev·npx vitest run(⚠️ vitest داخل ddev بهخاطر معماری esbuild اجرا نمیشود؛ روی host اجرا کن).
نکات مهم
- منبع واحد محاسبه: فقط
BillingCalculator(سرور) وpatientShareOf(آینهٔ کلاینت درCreateStep.tsx). هیچ فرمول درصدیِ موازیِ جدیدی در مودال قطعیکردن یا صفحهٔ ویرایش نوبت ننویس؛ همان تابع را import کن. - قاعدهٔ بیمهٔ پایه:
سهم پایه = round(کل × درصد ÷ 100)وسهم بیمار = کل − سهم پایه. فرانشیز فقط در قراردادsupplementary. - زنجیرهٔ درصد دستنخورده باقی بماند: service override → override قرارداد برای نوع خدمت → پیشفرض مرکزی ادمین →
coverage_percentقرارداد. - سازگاری عقبرو: نبودِ ردیف در
tenant_service_category_settingsیعنی هر دو نوع فعال؛ نوبتِ بدون بیمه باید دقیقاً مثل امروز رفتار کند (کل مبلغ سهم بیمار). - نوعِ غیرفعال روی دادهٔ قدیمی: اگر نوبتی نوع خدمتی دارد که بعداً غیرفعال شده، محاسبه با همان مقدارِ ذخیرهشده انجام شود (snapshot)، ولی در فرم فقط نوعهای فعال قابل انتخاب باشند.
- بیمهٔ تکمیلی در این پرامپت به نوبت اضافه نمیشود (فقط پایه) —
calculateFinalPriceپارامتر تکمیلی راnullمیگیرد؛ ساختار را طوری بنویس که افزودنش بعداً یک فیلد باشد. - الگوهای پروژه:
$this->success()/$this->paginated()/$this->error()· لیستهای ادمینgetArrayResult()· تاریخها Unix timestamp · رشتههای UI فارسی · در فرانت همیشهSearchableSelect(هرگز<select>بومی) · مودال/کارت جدید با تم و کامپوننتهای موجود، بدون طراحی تازه ·tenant-insurancesدر فرانت با(data as any)?.data?.dataخوانده میشود. ServiceItem.insurance_coveredهمچنان gate نهاییِ هر خدمت است؛ نوع خدمت آن را دور نمیزند.- بعد از اتمام:
graphify update .(پس از commit).