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:
+1
-1
@@ -8,7 +8,7 @@
|
||||
|
||||
صورتحساب (`Invoice`) از یک Encounter (`PatientSession`) ساخته میشود. برای هر آیتم سهم بیمهی پایه، بیمهی مکمل و بیمار با `BillingCalculator` محاسبه میشود:
|
||||
|
||||
- تعرفهی خدمت از `Tariff` سال جاری (با fallback به `ServiceItem.priceRials`).
|
||||
- قیمت خدمت از `ServiceItem.priceRials` — تنها منبع قیمت.
|
||||
- قانون پوشش از قرارداد بیمهی tenant (`TenantInsurance`) + override خدمت (`TenantServiceCoverage`).
|
||||
- درصد پوشش به تفکیک **نوع خدمت** و از زنجیرهٔ resolve توضیحدادهشده در [insurance.md](insurance.md#قاعدهٔ-درصد-پوشش-coverage-percent-model) گرفته میشود: هر خدمت با `ServiceItem.service_category` خودش، و **ویزیت** با `PatientSession.insurance_service_category` (نوعی که سرِ پذیرش انتخاب شده؛ در نبودش سرپایی).
|
||||
- `invoices.service_category` همان نوع را snapshot میکند و در `toArray()` بهصورت `service_category` / `service_category_label` برمیگردد.
|
||||
|
||||
+10
-45
@@ -267,7 +267,7 @@ refresh مستقیم هم کار کند، بنابراین فیلترکردن س
|
||||
|
||||
**Response 201:** ServiceItem object (شامل `insurance_covered`)
|
||||
|
||||
> **قیمت واحد:** هنگام ساخت سرویس، یک تعرفه برای **سال جاری** با همان `price_rials` بهصورت خودکار ثبت میشود. قیمت سرویس = تعرفهی سال جاری است و همهجا (صورتحساب، مراجعه، مطالبات) از همین قیمت استفاده میشود.
|
||||
> **قیمت واحد:** `price_rials` تنها منبع قیمت است و همهجا (صورتحساب، مراجعه، مطالبات) از همین عدد استفاده میشود.
|
||||
|
||||
---
|
||||
|
||||
@@ -313,49 +313,11 @@ refresh مستقیم هم کار کند، بنابراین فیلترکردن س
|
||||
|
||||
---
|
||||
|
||||
## تعرفهی نسخهدار سالانه (Tariff) — فاز ۳ سیستم صورتحساب
|
||||
|
||||
هر خدمت میتواند برای هر سال شمسی یک تعرفه داشته باشد. اگر تعرفهی سالی ثبت نشود، به `price_rials` خود خدمت fallback میشود (`TariffService::resolvePrice`). سال جاری شمسی سمت سرور با `IntlDateFormatter` (تقویم persian) محاسبه میشود.
|
||||
|
||||
### GET /api/v1/service-items/{uuid}/tariffs
|
||||
|
||||
لیست تعرفههای یک خدمت + قیمت پیشفرض + سال جاری.
|
||||
|
||||
**Permission:** `IS_AUTHENTICATED_FULLY` (مالک خدمت)
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"current_year": 1405,
|
||||
"default_price_rials": 500000,
|
||||
"data": [
|
||||
{ "uuid": "…", "service_item_id": 12, "year": 1405, "price_rials": 600000, "is_active": true },
|
||||
{ "uuid": "…", "service_item_id": 12, "year": 1404, "price_rials": 500000, "is_active": true }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### PUT /api/v1/service-items/{uuid}/tariffs/{year}
|
||||
|
||||
ثبت/بهروزرسانی تعرفهی یک سال (upsert). `year` بین ۱۳۹۰ تا ۱۵۰۰.
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{ "price_rials": 600000 }
|
||||
```
|
||||
|
||||
**Response 200:** `{ success, data: { …tariff } }`
|
||||
|
||||
> اگر `year` برابر **سال جاری** باشد، `ServiceItem.price_rials` هم با همین مقدار همگام میشود (قیمت واحد). تعرفهی سالهای دیگر فقط برای محاسبهی صورتحساب همان سال (`TariffService::resolvePrice`) بهکار میرود و قیمت پایهی سرویس را تغییر نمیدهد. همچنین `PATCH /service-item/{uuid}` با تغییر `price_rials`، تعرفهی سال جاری را upsert میکند.
|
||||
|
||||
**Errors:**
|
||||
| Code | HTTP | توضیح |
|
||||
|------|------|-------|
|
||||
| ERR_SERVICE_NOT_FOUND | 404 | سرویس یافت نشد |
|
||||
| ERR_VALIDATION_001 | 422 | سال نامعتبر |
|
||||
## قیمت خدمت — تنها یک منبع
|
||||
|
||||
قیمت هر خدمت فقط `ServiceItem.price_rials` است و با `PATCH /api/v1/service-item/{uuid}`
|
||||
عوض میشود. تعرفهٔ نسخهدار سالانه (`GET|PUT /api/v1/service-items/{uuid}/tariffs[/{year}]`)
|
||||
حذف شده و آن مسیرها `404` میدهند؛ جزئیات زنجیرهٔ محاسبه در [pricing.md](pricing.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -485,17 +447,20 @@ caller's personal ones.
|
||||
- **حلقهٔ پیشنیاز** هنگام ثبت `422` میگیرد، نه در اعتبارسنجی انتخاب: «الف نیازمند ب»
|
||||
و «ب نیازمند الف» اگر هر دو ذخیره میشدند، هیچ انتخابی هرگز معتبر نمیشد.
|
||||
|
||||
## قیمت و مدت اختصاصی شعبه
|
||||
## مدت اختصاصی شعبه
|
||||
|
||||
`PUT /api/v1/service-item/{uuid}/branch-overrides` — جایگزینی کامل.
|
||||
|
||||
```json
|
||||
{ "overrides": [{ "address_uuid": "…", "price_rials": 900000, "solo_duration_minutes": 25 }] }
|
||||
{ "overrides": [{ "address_uuid": "…", "solo_duration_minutes": 25, "additional_duration_minutes": 10 }] }
|
||||
```
|
||||
|
||||
هر فیلد تهیپذیر است و `null` یعنی «همان مقدار خودِ سرویس» — **نه صفر**.
|
||||
override فقط وقتی اعمال میشود که `branch_uuid` به `validate` داده شود.
|
||||
|
||||
**قیمت اینجا نیست.** `price_rials` از این اندپوینت حذف شده؛ ارسالش نادیده گرفته میشود
|
||||
و در پاسخ هم نمیآید.
|
||||
|
||||
## دستهٔ درختی
|
||||
|
||||
`GET /api/v1/service-categories/tree` · `POST/PATCH/DELETE /api/v1/service-category[/{uuid}]`
|
||||
|
||||
+24
-34
@@ -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` است: نوبت ثبت
|
||||
|
||||
@@ -144,7 +144,7 @@ Create a secretary for a doctor.
|
||||
| `insurances` | `InsuranceController` (insurance-pricing, tenant-insurances, service-coverage, doctor-insurance) | view/create/update/delete |
|
||||
| `inventory` | `InventoryController` (items + packages) | view/create/update/delete |
|
||||
| `tags` | `TenantTagController` (لیست با `tags.view` یا `patients.view`؛ نوشتنها با `tags.*`) | view/create/update/delete |
|
||||
| `services` | `ClinicServiceController` (sections + items + tariffs). owner از محیطِ فعال با `SecretaryAccessChecker::resolveOwnerEntity` حل میشود چون `EntityContextResolver` منشی را نمیشناسد. گیتِ `services.*` پیش از گیتِ اشتراک اجرا میشود | view/create/update/delete |
|
||||
| `services` | `ClinicServiceController` (sections + items). owner از محیطِ فعال با `SecretaryAccessChecker::resolveOwnerEntity` حل میشود چون `EntityContextResolver` منشی را نمیشناسد. گیتِ `services.*` پیش از گیتِ اشتراک اجرا میشود | view/create/update/delete |
|
||||
| `staff` | `StaffController` (resolveEntity منشیآگاه) | view/create/update/delete |
|
||||
| `discounts` | `DiscountController` (CRUD؛ `suggestions` جزو flowِ جلسه است و با discounts گِیت نمیشود) | view/create/update/delete |
|
||||
| `sms` | `SmsWalletController` (balance/charge/logs/settings). endpointهای admin (قالب/ارسال) همچنان `ROLE_ADMIN` | view/create/update |
|
||||
|
||||
Reference in New Issue
Block a user