"Six laser sessions" is the common case in an aesthetics clinic: the patient pays once and books the sessions later. Credit is a ledger, not a counter. No table has a remaining/used_count column and a schema test enforces that — the balance is always SUM(delta) over append-only rows, so every number a patient sees has a full history behind it. Corrections are new rows, never edits. - purchase / consume / refund / adjustment / expiry, each with a reason, an author and the appointment it belongs to - consume happens in confirm(), never in quote(): if the preview consumed, a page refresh would cost the patient a session - cancelling adds a refund row; the consume row stays - FIFO across a patient's packages — the oldest is closest to expiring - an empty package is not an error, it just does not apply and the patient pays - adjust/expire need a doctor or clinic role, and adjust always needs a reason - app:package:expire writes the closing row so "where did my 3 sessions go?" always has an answer Consume takes a pessimistic lock on the one package row. That is the opposite of task 07's slot buckets, and docs/api/package.md carries the table explaining why, so nobody unifies them later. Idempotency checks for an existing consume row before inserting rather than catching the unique violation: in Doctrine that exception closes the EntityManager and burns the rest of the request. The unique key stays as the last line of defence. Admin: PackagesPage, a packages tab on the patient record, and a ledger page whose running-balance column shows where the final number came from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
6.8 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 عدد جدید میدهد و فاکتور نوبت همان عدد
قبلی را. بدون آن، قانون پنجم فقط یک ادعاست.
قوانین دستهٔ «قیمت»
تخفیفی که موتور قوانین میدهد کنار تخفیف دستیِ درخواست مینشیند نه بهجایش، و
شناسه و نسخهٔ هر قانون در breakdown.sources.applied_policies ثبت میشود:
"breakdown": {
"sources": {
"applied_policies": [
{ "uuid": "…", "name": "تخفیف همین سرویس", "version": 2 }
]
}
}
جزئیات دستهها و اثرها: policy.md
پکیج
quote یک patient_uuid اختیاری میگیرد؛ با آن، پکیج معتبرِ بیمار قیمت پایهٔ سرویس را
میپوشاند و package_will_be_consumed روشن میشود. پیشنمایش هرگز مصرف نمیکند —
مصرف در ثبت نهایی است.
جزئیات: package.md