Files
clinicpro/.claude/prompt/appointment-insurance-service-type.md
hamedandClaude Opus 5 1f58b1b9b3 feat(insurance): bill an appointment with a chosen service kind and insurance
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>
2026-07-25 17:50:14 +03:30

26 KiB
Raw Permalink Blame History

انتخاب نوع خدمت بیمه (سرپایی/بستری) در نوبت و انتشار آن تا فاکتور

پروژه

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 فرض شده است.

اما در جریان کاری واقعی، پزشک ممکن است اصلاً خدمات بستری ارائه نکند، یا هر دو را ارائه کند و لازم باشد سرِ پذیرش مشخص شود این مراجعه سرپایی است یا بستری. الآن هیچ‌کدام از این‌ها وجود ندارد:

  1. پزشک/کلینیک نمی‌تواند بگوید بیمه‌هایش کدام نوع خدمات را پوشش می‌دهند.
  2. نوبت هیچ فیلدی برای «بیمهٔ انتخابی» و «نوع خدمت» ندارد → مودال «قطعی کردن نوبت» فقط جمع کل را نشان می‌دهد و کل مبلغ سهم بیمار می‌شود.
  3. PatientService::autoCreateOnAppointmentConfirm عامدانه بیمه را نادیده می‌گیرد: applyShares($gross, 0, 0, $gross).
  4. پرونده بیمار (بخش سرویس‌ها) و فاکتور، نوع خدمت و نام بیمه را نشان نمی‌دهند.

هدف

الف) در تنظیمات ← مدیریت بیمه (/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 OutpatientcoverageRuleForService()، 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_categoryinsuranceId (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.tsx export شده — از همان استفاده کن، منطق تازه ننویس):
    • درصد از 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).