"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>
167 lines
6.8 KiB
Markdown
167 lines
6.8 KiB
Markdown
# Pricing API — لیست قیمت بازهدار و فاکتور تفکیکشده
|
||
|
||
> **Base:** `/api/v1` · **Auth:** JWT
|
||
> مکمل [clinic-services.md](clinic-services.md) و [appointment-booking.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`
|
||
|
||
```json
|
||
{
|
||
"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` دارد — عمداً یکی، تا «قیمتی که نشان دادیم» و
|
||
«قیمتی که ثبت کردیم» نتوانند واگرا شوند.
|
||
|
||
```json
|
||
{
|
||
"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` |
|
||
|
||
## تستها
|
||
|
||
```bash
|
||
ddev exec php bin/phpunit tests/Pricing # ۱۲ تست
|
||
```
|
||
|
||
مهمترینش `testBookedAppointmentKeepsItsOriginalInvoiceAfterAPriceChange` است: نوبت ثبت
|
||
میشود، قیمت سرویس دو برابر میشود، `quote` عدد جدید میدهد و فاکتور نوبت **همان عدد
|
||
قبلی** را. بدون آن، قانون پنجم فقط یک ادعاست.
|
||
|
||
---
|
||
|
||
## قوانین دستهٔ «قیمت»
|
||
|
||
تخفیفی که موتور قوانین میدهد **کنار** تخفیف دستیِ درخواست مینشیند نه بهجایش، و
|
||
شناسه و نسخهٔ هر قانون در `breakdown.sources.applied_policies` ثبت میشود:
|
||
|
||
```json
|
||
"breakdown": {
|
||
"sources": {
|
||
"applied_policies": [
|
||
{ "uuid": "…", "name": "تخفیف همین سرویس", "version": 2 }
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
جزئیات دستهها و اثرها: [policy.md](policy.md)
|
||
|
||
---
|
||
|
||
## پکیج
|
||
|
||
`quote` یک `patient_uuid` اختیاری میگیرد؛ با آن، پکیج معتبرِ بیمار قیمت پایهٔ سرویس را
|
||
میپوشاند و `package_will_be_consumed` روشن میشود. **پیشنمایش هرگز مصرف نمیکند** —
|
||
مصرف در ثبت نهایی است.
|
||
|
||
جزئیات: [package.md](package.md)
|