Files
clinicpro/docs/api/pricing.md
T
hamedandClaude Opus 5 4fe0c4f9bf refactor(pricing): make the service the only price source
Price lists, annual tariffs and per-branch price overrides each answered
"what does this service cost?" differently, so a single date could carry
several answers and nobody could say which one was right. Price now lives
only on ServiceItem.price_rials, edited from the services page.

- drop PriceList/PriceListItem, their repositories and the seven
  /api/v1/price-list(s) endpoints; PricingController keeps only quote and
  the appointment price snapshot
- drop Tariff, TariffRepository, TariffService and the two
  /service-items/{uuid}/tariffs endpoints; creating or repricing a service
  no longer upserts a current-year tariff
- drop price_rials from ServiceBranchOverride; the entity stays for its
  duration columns, which DurationCalculator and ServiceSelectionValidator
  still read
- InvoiceService reads the item price directly
- PricingEngine collapses to a single source; breakdown.sources always
  reports service_item, keeping the response contract intact
- remove the price-lists admin page, its route and settings-menu entry, the
  tariff modal and the service detail tariffs tab; useAppointmentInvoice
  moves to its own hook file

Migration drops price_lists, price_list_items, service_tariffs and the
override price column.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 18:00:48 +03:30

6.3 KiB

Pricing API — پیش‌نمایش قیمت و فاکتور تفکیک‌شده

Base: /api/v1 · Auth: JWT مکمل clinic-services.md و appointment-booking.md.


تنها یک منبع قیمت

قیمت هر سرویس فقط از خودِ سرویس می‌آید: ServiceItem.price_rials، همان عددی که در صفحهٔ سرویس‌ها ویرایش می‌شود.

لایه‌های پیشین — لیست قیمت بازه‌دار (price_listsتعرفهٔ سالانه (service_tariffs) و قیمت اختصاصی شعبه (service_branch_overrides.price_rials) — حذف شده‌اند: هرکدام جواب متفاوتی به «این سرویس چند است؟» می‌دادند و یک تاریخ می‌توانست چند قیمت داشته باشد. override شعبه سر جایش است ولی فقط مدت را تعیین می‌کند.

زنجیرهٔ قیمت‌گذاری

قیمت پایه → + آیتم‌ها → − تخفیف → − بیمهٔ پایه → − تکمیلی → + مالیات → بیعانه

breakdown.sources برای هر سرویس همیشه service_item است — قرارداد پاسخ حفظ شده تا مصرف‌کننده‌ها نشکنند.

PriceSnapshot فاکتور تفکیک‌شدهٔ لحظهٔ ثبت را نگه می‌دارد: بعد از تغییر قیمت یا تخفیف، باید بشود گفت آن ۲٬۴۰۰٬۰۰۰ ریال از چه تشکیل شده بود.

دو تصمیم محاسباتی

مالیات روی سهم بیمار حساب می‌شود، نه روی کل. بیمار مالیاتِ سهمی که بیمه می‌دهد را نمی‌پردازد.

تخفیف بیشتر از مبلغ، مبلغ را صفر می‌کند نه منفی. بدهی منفی یعنی کلینیک به بیمار پول بدهکار شود، که هیچ‌جای این جریان معنا ندارد.

max_total_discount_percent سقف جمع تخفیف‌هاست: چند تخفیفِ جداگانه که هرکدام منطقی‌اند، با هم می‌توانند مبلغ را بی‌معنا کنند.


POST /api/v1/pricing/quote

{
  "service_uuid": "…",
  "branch_uuid": "…",
  "item_uuids": ["…"],
  "at": 1785562200,
  "policy": {
    "discount_percent": 10,
    "max_total_discount_percent": 25,
    "insurance_base_percent": 20,
    "insurance_supplementary_percent": 50,
    "tax_percent": 10,
    "deposit_percent": 30
  }
}

at اختیاری است (پیش‌فرض الان) و در پارامترهای درخواست می‌ماند؛ چون قیمت دیگر بازه‌ای نیست، روی عدد خروجی اثری ندارد.

۲۰۰: همان شکلی که price_snapshot دارد — عمداً یکی، تا «قیمتی که نشان دادیم» و «قیمتی که ثبت کردیم» نتوانند واگرا شوند.

{
  "base_rials": 10000000, "items_rials": 2000000, "discount_rials": 1200000,
  "insurance_base_rials": 2160000, "insurance_supplementary_rials": 4320000,
  "tax_rials": 432000, "final_rials": 4752000, "deposit_rials": 1425600,
  "breakdown": { "discounts": [  ], "sources": { "<service-uuid>": "service_item" } }
}

اندپوینت‌های حذف‌شده

این مسیرها دیگر وجود ندارند و 404 می‌دهند:

متد مسیر
GET/POST /api/v1/price-lists
GET/PATCH/DELETE /api/v1/price-list/{uuid}
PUT /api/v1/price-list/{uuid}/items
POST /api/v1/price-list/{uuid}/activate
GET /api/v1/service-items/{uuid}/tariffs
PUT /api/v1/service-items/{uuid}/tariffs/{year}

برای تغییر قیمت، PATCH /api/v1/service-item/{uuid} با price_rials را صدا بزنید.


فاکتور نوبت

GET /api/v1/appointment/{uuid}/price-snapshot

فاکتور هنگام POST /appointment-confirm و با قیمت‌های همان لحظه ثبت می‌شود. اگر بعداً محاسبه می‌شد، تغییر قیمت سرویس بین ثبت و صدور فاکتور عدد دیگری می‌داد.

قانون پنجم مستند: «تغییر قیمت هرگز نوبت‌های ثبت‌شده را عوض نمی‌کند.» PriceSnapshot هیچ setter ای ندارد و کلید یکتای appointment_id دو فاکتور برای یک نوبت را در سطح دیتابیس غیرممکن می‌کند. اصلاح قیمت با ردیف تازه و ابطال قبلی انجام می‌شود، نه با بازنویسی.

نوبتِ بدون سرویس (ویزیت سادهٔ حالت اسلاتی) هم فاکتور می‌گیرد، با همان visit_price_rials موجود — خالی گذاشتنش یعنی گزارش مالی یک ردیف کم دارد.


طبقه‌بندی محیط

جدول وضعیت
price_snapshots جفت محیط

تست‌ها

ddev exec php bin/phpunit tests/Pricing   # ۱۱ تست

مهم‌ترینش testBookedAppointmentKeepsItsOriginalInvoiceAfterAPriceChange است: نوبت ثبت می‌شود، قیمت سرویس دو برابر می‌شود، quote عدد جدید می‌دهد و فاکتور نوبت همان عدد قبلی را. بدون آن، قانون پنجم فقط یک ادعاست.


قوانین دستهٔ «قیمت»

تخفیفی که موتور قوانین می‌دهد کنار تخفیف دستیِ درخواست می‌نشیند نه به‌جایش، و شناسه و نسخهٔ هر قانون در breakdown.sources.applied_policies ثبت می‌شود:

"breakdown": {
  "sources": {
    "applied_policies": [
      { "uuid": "…", "name": "تخفیف همین سرویس", "version": 2 }
    ]
  }
}

جزئیات دسته‌ها و اثرها: policy.md


پکیج

quote یک patient_uuid اختیاری می‌گیرد؛ با آن، پکیج معتبرِ بیمار قیمت پایهٔ سرویس را می‌پوشاند و package_will_be_consumed روشن می‌شود. پیش‌نمایش هرگز مصرف نمی‌کند — مصرف در ثبت نهایی است.

جزئیات: package.md