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>
This commit is contained in:
hamed
2026-08-02 18:00:48 +03:30
co-authored by Claude Opus 5
parent f06efe26c0
commit 4fe0c4f9bf
41 changed files with 208 additions and 1835 deletions
+24 -34
View File
@@ -1,20 +1,19 @@
# Pricing API — لیست قیمت بازه‌دار و فاکتور تفکیک‌شده
# Pricing API — پیش‌نمایش قیمت و فاکتور تفکیک‌شده
> **Base:** `/api/v1` · **Auth:** JWT
> مکمل [clinic-services.md](clinic-services.md) و [appointment-booking.md](appointment-booking.md).
---
## دو شکافی که پر شد
## تنها یک منبع قیمت
زنجیرهٔ قیمت از قبل وجود داشت و کار می‌کرد
(`ServiceItem → Tariff → بیمه → DiscountRule → Invoice → Payment`). دو چیز کم بود:
قیمت هر سرویس فقط از خودِ سرویس می‌آید: `ServiceItem.price_rials`، همان عددی که در
[صفحهٔ سرویس‌ها](clinic-services.md) ویرایش می‌شود.
۱. **`Tariff` فقط سال دارد.** تغییر تعرفه از اول مهر قابل بیان نبود. حالا `PriceList`
بازهٔ دقیق می‌گیرد و `Tariff` لایهٔ پشتیبان می‌ماند.
۲. **روی نوبت فقط یک عدد بود.** بعد از تغییر قیمت یا تخفیف نمی‌شد گفت آن ۲٬۴۰۰٬۰۰۰
ریال از چه تشکیل شده بود. حالا `PriceSnapshot` فاکتور تفکیک‌شدهٔ لحظهٔ ثبت را
نگه می‌دارد.
لایه‌های پیشین — **لیست قیمت بازه‌دار** (`price_lists`**تعرفهٔ سالانه**
(`service_tariffs`) و **قیمت اختصاصی شعبه** (`service_branch_overrides.price_rials`) —
حذف شده‌اند: هرکدام جواب متفاوتی به «این سرویس چند است؟» می‌دادند و یک تاریخ می‌توانست
چند قیمت داشته باشد. override شعبه سر جایش است ولی فقط **مدت** را تعیین می‌کند.
## زنجیرهٔ قیمت‌گذاری
@@ -22,17 +21,11 @@
قیمت پایه → + آیتم‌ها → − تخفیف → − بیمهٔ پایه → − تکمیلی → + مالیات → بیعانه
```
برای **هر** سرویس، اولین منبعی که پیدا شود برنده است:
`breakdown.sources` برای هر سرویس همیشه `service_item` است — قرارداد پاسخ حفظ شده تا
مصرف‌کننده‌ها نشکنند.
| اولویت | منبع | از کجا |
|---|---|---|
| ۱ | override شعبه | تسک ۰۴ |
| ۲ | لیست قیمتِ حاکم بر آن تاریخ | همین تسک |
| ۳ | `Tariff` سال | لایهٔ موجود |
| ۴ | `ServiceItem.price_rials` | همیشه هست |
مرحلهٔ چهارم ضامن است که **هرگز صفر یا خطا** برنگردد — تاریخی که هیچ لیستی نمی‌پوشاند
باید قیمت بدهد. `breakdown.sources` می‌گوید هر قیمت از کدام لایه آمده.
`PriceSnapshot` فاکتور تفکیک‌شدهٔ لحظهٔ ثبت را نگه می‌دارد: بعد از تغییر قیمت یا تخفیف،
باید بشود گفت آن ۲٬۴۰۰٬۰۰۰ ریال از چه تشکیل شده بود.
### دو تصمیم محاسباتی
@@ -66,7 +59,8 @@
}
```
`at` اختیاری است (پیش‌فرض الان) و تعیین می‌کند کدام لیست قیمت حاکم است.
`at` اختیاری است (پیش‌فرض الان) و در پارامترهای درخواست می‌ماند؛ چون قیمت دیگر بازه‌ای
نیست، روی عدد خروجی اثری ندارد.
**۲۰۰:** همان شکلی که `price_snapshot` دارد — عمداً یکی، تا «قیمتی که نشان دادیم» و
«قیمتی که ثبت کردیم» نتوانند واگرا شوند.
@@ -76,13 +70,15 @@
"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" } }
"breakdown": { "discounts": [ ], "sources": { "<service-uuid>": "service_item" } }
}
```
---
## لیست قیمت
## اندپوینت‌های حذف‌شده
این مسیرها دیگر وجود ندارند و `404` می‌دهند:
| متد | مسیر |
|---|---|
@@ -90,15 +86,10 @@
| 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}` |
`address_uuid` تهی‌پذیر است: `null` یعنی «همهٔ شعبه‌های این محیط». لیستِ مخصوصِ یک شعبه
بر لیست عمومی **مقدم** است و با آن **تداخل حساب نمی‌شود** — وگرنه تعریف استثنا برای یک
شعبه ناممکن می‌شد.
**لیست تا فعال نشده هیچ اثری ندارد.** ساختن پیش‌نویس نباید قیمت امروز را عوض کند.
`activate` بازهٔ هم‌پوشان با لیست فعالِ **هم‌دامنه** را `422` می‌کند: یک تاریخ نباید دو
قیمت داشته باشد.
برای تغییر قیمت، `PATCH /api/v1/service-item/{uuid}` با `price_rials` را صدا بزنید.
---
@@ -107,7 +98,7 @@
`GET /api/v1/appointment/{uuid}/price-snapshot`
فاکتور هنگام `POST /appointment-confirm` و با قیمت‌های **همان لحظه** ثبت می‌شود. اگر
بعداً محاسبه می‌شد، تغییر تعرفه بین ثبت و صدور فاکتور عدد دیگری می‌داد.
بعداً محاسبه می‌شد، تغییر قیمت سرویس بین ثبت و صدور فاکتور عدد دیگری می‌داد.
> **قانون پنجم مستند:** «تغییر قیمت هرگز نوبت‌های ثبت‌شده را عوض نمی‌کند.»
> `PriceSnapshot` هیچ setter ای ندارد و کلید یکتای `appointment_id` دو فاکتور برای یک
@@ -123,13 +114,12 @@
| جدول | وضعیت |
|---|---|
| `price_lists` · `price_snapshots` | جفت محیط |
| `price_list_items` | `AGGREGATE_CHILDREN` — ریشه `PriceList` |
| `price_snapshots` | جفت محیط |
## تست‌ها
```bash
ddev exec php bin/phpunit tests/Pricing # ۱۲ تست
ddev exec php bin/phpunit tests/Pricing # ۱۱ تست
```
مهم‌ترینش `testBookedAppointmentKeepsItsOriginalInvoiceAfterAPriceChange` است: نوبت ثبت