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>
157 lines
6.3 KiB
Markdown
157 lines
6.3 KiB
Markdown
# Pricing API — پیشنمایش قیمت و فاکتور تفکیکشده
|
|
|
|
> **Base:** `/api/v1` · **Auth:** JWT
|
|
> مکمل [clinic-services.md](clinic-services.md) و [appointment-booking.md](appointment-booking.md).
|
|
|
|
---
|
|
|
|
## تنها یک منبع قیمت
|
|
|
|
قیمت هر سرویس فقط از خودِ سرویس میآید: `ServiceItem.price_rials`، همان عددی که در
|
|
[صفحهٔ سرویسها](clinic-services.md) ویرایش میشود.
|
|
|
|
لایههای پیشین — **لیست قیمت بازهدار** (`price_lists`)، **تعرفهٔ سالانه**
|
|
(`service_tariffs`) و **قیمت اختصاصی شعبه** (`service_branch_overrides.price_rials`) —
|
|
حذف شدهاند: هرکدام جواب متفاوتی به «این سرویس چند است؟» میدادند و یک تاریخ میتوانست
|
|
چند قیمت داشته باشد. override شعبه سر جایش است ولی فقط **مدت** را تعیین میکند.
|
|
|
|
## زنجیرهٔ قیمتگذاری
|
|
|
|
```
|
|
قیمت پایه → + آیتمها → − تخفیف → − بیمهٔ پایه → − تکمیلی → + مالیات → بیعانه
|
|
```
|
|
|
|
`breakdown.sources` برای هر سرویس همیشه `service_item` است — قرارداد پاسخ حفظ شده تا
|
|
مصرفکنندهها نشکنند.
|
|
|
|
`PriceSnapshot` فاکتور تفکیکشدهٔ لحظهٔ ثبت را نگه میدارد: بعد از تغییر قیمت یا تخفیف،
|
|
باید بشود گفت آن ۲٬۴۰۰٬۰۰۰ ریال از چه تشکیل شده بود.
|
|
|
|
### دو تصمیم محاسباتی
|
|
|
|
**مالیات روی سهم بیمار حساب میشود، نه روی کل.** بیمار مالیاتِ سهمی که بیمه میدهد را
|
|
نمیپردازد.
|
|
|
|
**تخفیف بیشتر از مبلغ، مبلغ را صفر میکند نه منفی.** بدهی منفی یعنی کلینیک به بیمار
|
|
پول بدهکار شود، که هیچجای این جریان معنا ندارد.
|
|
|
|
`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>": "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` | جفت محیط |
|
|
|
|
## تستها
|
|
|
|
```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)
|