Section 12 and the fifth closing rule: changing a price never changes an already-booked appointment. The pricing chain already existed and worked. Two things were missing. Tariff only carries a year, so a rate change starting in Mehr could not be expressed — PriceList now takes an explicit date range and Tariff remains the layer beneath it. And an appointment stored a single number, so after a price change or a discount nobody could say what those 2,400,000 rials were made of. Price resolution walks four layers per service and takes the first hit: branch override, then the covering price list, then the yearly tariff, then the service's own price. The last one is the guarantee that a date no list covers still returns a price rather than zero or an exception. breakdown.sources reports which layer answered, so a surprising number can be traced instead of guessed at. Two calculation decisions worth stating. Tax is computed on the patient's share, not the gross — a patient does not pay tax on the portion the insurer covers. And a discount larger than the amount floors the total at zero rather than going negative, because a negative balance would mean the clinic owes the patient money, which nothing downstream is built to mean. A branch-specific list deliberately does not count as overlapping a general one; it takes precedence instead. Treating them as a conflict would have made per-branch exceptions impossible to express. Lists have no effect until activated, so drafting next quarter's prices cannot disturb today's. PriceSnapshot has no setters and a unique key on appointment_id: a snapshot that can be edited is not a snapshot, and two invoices for one appointment would be two truths. Corrections are a new row plus voiding the old one. Invoices are written during confirm with the prices of that moment — computing later would let a rate change between booking and invoicing produce a different number, which is exactly what rule five forbids. 12 tests. The one that matters is testBookedAppointmentKeepsItsOriginalInvoiceAfterAPriceChange: book, double the service price, watch quote return the new number while the appointment's invoice returns the old one. Without it rule five is only a claim. 1220 tests / 3551 assertions. phpstan back at its 14-error baseline. Frozen slot contract green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
5.9 KiB
Pricing API — لیست قیمت بازهدار و فاکتور تفکیکشده
Base:
/api/v1· Auth: JWT مکمل clinic-services.md و appointment-booking.md.
دو شکافی که پر شد
زنجیرهٔ قیمت از قبل وجود داشت و کار میکرد
(ServiceItem → Tariff → بیمه → DiscountRule → Invoice → Payment). دو چیز کم بود:
۱. Tariff فقط سال دارد. تغییر تعرفه از اول مهر قابل بیان نبود. حالا PriceList
بازهٔ دقیق میگیرد و Tariff لایهٔ پشتیبان میماند.
۲. روی نوبت فقط یک عدد بود. بعد از تغییر قیمت یا تخفیف نمیشد گفت آن ۲٬۴۰۰٬۰۰۰
ریال از چه تشکیل شده بود. حالا PriceSnapshot فاکتور تفکیکشدهٔ لحظهٔ ثبت را
نگه میدارد.
زنجیرهٔ قیمتگذاری
قیمت پایه → + آیتمها → − تخفیف → − بیمهٔ پایه → − تکمیلی → + مالیات → بیعانه
برای هر سرویس، اولین منبعی که پیدا شود برنده است:
| اولویت | منبع | از کجا |
|---|---|---|
| ۱ | override شعبه | تسک ۰۴ |
| ۲ | لیست قیمتِ حاکم بر آن تاریخ | همین تسک |
| ۳ | Tariff سال |
لایهٔ موجود |
| ۴ | ServiceItem.price_rials |
همیشه هست |
مرحلهٔ چهارم ضامن است که هرگز صفر یا خطا برنگردد — تاریخی که هیچ لیستی نمیپوشاند
باید قیمت بدهد. breakdown.sources میگوید هر قیمت از کدام لایه آمده.
دو تصمیم محاسباتی
مالیات روی سهم بیمار حساب میشود، نه روی کل. بیمار مالیاتِ سهمی که بیمه میدهد را نمیپردازد.
تخفیف بیشتر از مبلغ، مبلغ را صفر میکند نه منفی. بدهی منفی یعنی کلینیک به بیمار پول بدهکار شود، که هیچجای این جریان معنا ندارد.
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>": "price_list" } }
}
لیست قیمت
| متد | مسیر |
|---|---|
| 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 |
address_uuid تهیپذیر است: null یعنی «همهٔ شعبههای این محیط». لیستِ مخصوصِ یک شعبه
بر لیست عمومی مقدم است و با آن تداخل حساب نمیشود — وگرنه تعریف استثنا برای یک
شعبه ناممکن میشد.
لیست تا فعال نشده هیچ اثری ندارد. ساختن پیشنویس نباید قیمت امروز را عوض کند.
activate بازهٔ همپوشان با لیست فعالِ همدامنه را 422 میکند: یک تاریخ نباید دو
قیمت داشته باشد.
فاکتور نوبت
GET /api/v1/appointment/{uuid}/price-snapshot
فاکتور هنگام POST /appointment-confirm و با قیمتهای همان لحظه ثبت میشود. اگر
بعداً محاسبه میشد، تغییر تعرفه بین ثبت و صدور فاکتور عدد دیگری میداد.
قانون پنجم مستند: «تغییر قیمت هرگز نوبتهای ثبتشده را عوض نمیکند.»
PriceSnapshotهیچ setter ای ندارد و کلید یکتایappointment_idدو فاکتور برای یک نوبت را در سطح دیتابیس غیرممکن میکند. اصلاح قیمت با ردیف تازه و ابطال قبلی انجام میشود، نه با بازنویسی.
نوبتِ بدون سرویس (ویزیت سادهٔ حالت اسلاتی) هم فاکتور میگیرد، با همان
visit_price_rials موجود — خالی گذاشتنش یعنی گزارش مالی یک ردیف کم دارد.
طبقهبندی محیط
| جدول | وضعیت |
|---|---|
price_lists · price_snapshots |
جفت محیط |
price_list_items |
AGGREGATE_CHILDREN — ریشه PriceList |
تستها
ddev exec php bin/phpunit tests/Pricing # ۱۲ تست
مهمترینش testBookedAppointmentKeepsItsOriginalInvoiceAfterAPriceChange است: نوبت ثبت
میشود، قیمت سرویس دو برابر میشود، quote عدد جدید میدهد و فاکتور نوبت همان عدد
قبلی را. بدون آن، قانون پنجم فقط یک ادعاست.