feat: add visit price requirement feature

- Introduced a new boolean flag `require_visit_price` in the `EntityInsurancePricing` to enforce visit price for appointments.
- Updated the appointment creation endpoints to validate `visit_price_rials` based on the new flag.
- Added `visit_price_rials` field to the `Appointment` entity to store the visit price.
- Enhanced the `PatientService` to validate visit price during session creation.
- Updated API documentation to reflect changes in appointment and insurance pricing.
- Implemented a new service `VisitPriceRequirementResolver` to determine if a visit price is required for a doctor based on their pricing settings.
- Added migrations to update the database schema for the new fields.
This commit is contained in:
hamed
2026-07-16 19:44:30 +03:30
parent 880c4c06cb
commit a0ddb4c0d1
18 changed files with 497 additions and 22 deletions
+4 -1
View File
@@ -602,6 +602,7 @@ Create a new appointment for a patient. If no user exists with the given mobile,
"service_item_uuids": ["service-uuid-1", "service-uuid-2"],
"duration_from_services": false,
"service_durations": { "service-uuid-1": 75 },
"visit_price_rials": 3000000,
"note": "optional note"
}
```
@@ -609,6 +610,8 @@ Create a new appointment for a patient. If no user exists with the given mobile,
> `patient_mobile`، `patient_name` و `patient_national_code` هر سه اجباری هستند. کد ملی باید ۱۰ رقم معتبر باشد و روی **پروفایل** بیمار ذخیره می‌شود (`profiles.national_code`، یکتا). بیمار **اول با کد ملیِ پروفایل** و سپس با موبایل resolve می‌شود، تا پرونده برای هر کد ملی یکتا بماند (یک شخص می‌تواند چند موبایل داشته باشد). اگر بیماری یافت نشود، کاربر جدید (`ROLE_USER`) به‌همراه پروفایلِ حاملِ همان کد ملی ساخته می‌شود.
>
> `service_item_uuids[]` (اختیاری): یک یا چند سرویس که به نوبت پیوست می‌شوند؛ اولین سرویس = سرویسِ اصلی و همه در `service_items` پاسخ برمی‌گردند. UUID ناموجود ⇒ `422`. با `duration_from_services: true` مدت نوبت از مجموع `duration_minutes` سرویس‌ها محاسبه و `slot_end` بازنویسی می‌شود (سرویسِ غیرbookable/بدون مدت ⇒ `422`)؛ بدون آن ساعت پایانِ دستی حفظ می‌ماند. `service_durations` (`{ "<uuid>": <minutes> }`، فقط با `duration_from_services=true`): override مدتِ هر سرویس برای همان نوبت؛ پیش‌فرضِ سرویس در تنظیمات تغییر نمی‌کند.
>
> `visit_price_rials` (اختیاری/شرطی): هزینه ویزیت (ریال) که روی نوبت ذخیره می‌شود. اگر فلگ `require_visit_price` در [insurance-pricing](insurance.md) برای پزشک (یا کلینیکِ واحد او در نبود ردیف پزشک) فعال باشد، مقدار `> 0` الزامی است.
### Response `201`
```json
@@ -626,7 +629,7 @@ Create a new appointment for a patient. If no user exists with the given mobile,
### Error Responses
| Code | HTTP | Description |
|------|------|-------------|
| `VALIDATION` | 422 | Missing required fields (doctor_uuid, slot_start, slot_end, patient_mobile, patient_name), or missing/invalid `patient_national_code` (`field: patient_national_code`) |
| `VALIDATION` | 422 | Missing required fields (doctor_uuid, slot_start, slot_end, patient_mobile, patient_name), or missing/invalid `patient_national_code` (`field: patient_national_code`), or required `visit_price_rials <= 0` when `require_visit_price` is on (`field: visit_price_rials`) |
| `ERR_PROFILE_MOBILE_TAKEN` | 422 | این شماره موبایل با کد ملی دیگری ثبت شده است (`field: patient_mobile`) |
| `DOCTOR_NOT_FOUND` | 404 | Doctor UUID not found |
| `SLOT_TAKEN` | 409 | Slot already booked |
+4 -3
View File
@@ -474,6 +474,7 @@ Create a new appointment for a patient. Used by doctor/clinic/secretary to book
| `patient_mobile` | string | ✅ | راه تماس بیمار |
| `patient_name` | string | ✅ | نام بیمار — فقط برای بیمارِ **کاملاً جدید** استفاده می‌شود؛ اگر کد ملی به پروفایلِ موجود بخورد، نامِ همان پروفایل روی نوبت ذخیره و نمایش داده می‌شود و این ورودی نادیده گرفته می‌شود |
| `patient_national_code` | string | ✅ | کد ملی بیمار — باید ۱۰ رقم معتبر باشد (`isValidIranNationalCode`)؛ ارقام فارسی به انگلیسی تبدیل می‌شوند |
| `visit_price_rials` | int | شرطی | هزینه ویزیت (ریال). اختیاری؛ ولی اگر فلگ `require_visit_price` در [insurance-pricing](insurance.md) برای پزشک (یا کلینیکِ واحد او در نبود ردیف پزشک) فعال باشد، مقدار `> 0` الزامی است. روی نوبت ذخیره و در `toArray` با کلید `visit_price_rials` برمی‌گردد |
> **هویت بیمار بر پایه‌ی کد ملی:** کد ملی روی **پروفایل** بیمار ذخیره می‌شود (`profiles.national_code`، یکتا). بیمار **اول با کد ملیِ پروفایل** پیدا می‌شود، سپس با موبایل. پس یک شخص می‌تواند چند موبایل داشته باشد ولی پرونده‌اش (`PatientRecord`) یکتا می‌ماند. اگر موبایلی که پروفایلش کد ملی دیگری دارد دوباره با کد ملی متفاوت ارسال شود، خطای 422 برمی‌گردد. اگر هیچ بیماری یافت نشود، کاربر جدید (`ROLE_USER`) به‌همراه پروفایلِ حاملِ همان کد ملی ساخته می‌شود. موبایلِ واردشده در هر نوبت به‌صورت snapshot روی خودِ نوبت (`patient_mobile`) هم ذخیره می‌شود.
>
@@ -496,7 +497,7 @@ Create a new appointment for a patient. Used by doctor/clinic/secretary to book
| Code | HTTP | Description |
|------|------|-------------|
| `FORBIDDEN` | 403 | Role not allowed, or caller not scoped to this doctor |
| `VALIDATION` | 422 | Missing required fields, or missing/invalid `patient_national_code` (`field: patient_national_code`) |
| `VALIDATION` | 422 | Missing required fields, or missing/invalid `patient_national_code` (`field: patient_national_code`), or required `visit_price_rials <= 0` when `require_visit_price` is on (`field: visit_price_rials`) |
| `ERR_PROFILE_MOBILE_TAKEN` | 422 | این شماره موبایل با کد ملی دیگری ثبت شده است (`field: patient_mobile`) |
| `DOCTOR_NOT_FOUND` | 404 | Doctor UUID not found |
| `SLOT_TAKEN` | 409 | Slot already booked |
@@ -597,7 +598,7 @@ Role-aware paginated list of appointments. Returns only what the authenticated u
## Clinic workflow extensions (نوبتها Figma)
New optional fields on `Appointment` (all backward-compatible): `service_section` (بخش), `service_item` (سرویسِ اصلی/اول), `service_items` (آرایهٔ همهٔ سرویسهای نوبت چند سرویس، هر عضو `{uuid, name}`), `staff` (پرسنل), `deposit_required` / `deposit_amount_rials` (بیعانه), `is_reserve` (نوبت رزرو day-level, never occupies a slot).
New optional fields on `Appointment` (all backward-compatible): `service_section` (بخش), `service_item` (سرویسِ اصلی/اول), `service_items` (آرایهٔ همهٔ سرویسهای نوبت چند سرویس، هر عضو `{uuid, name}`), `staff` (پرسنل), `deposit_required` / `deposit_amount_rials` (بیعانه), `visit_price_rials` (هزینه ویزیت، nullable), `is_reserve` (نوبت رزرو day-level, never occupies a slot).
New statuses: `following_up` (در حال پیگیری), `salon` (سالن). Transitions:
`pending confirmed|following_up|cancelled_*|expired` · `confirmed completed|following_up|salon|cancelled_*|no_show` · `following_up confirmed|salon|completed|cancelled_*|no_show` · `salon completed|following_up|cancelled_*|no_show`
@@ -631,7 +632,7 @@ Response `200`: `{ success, data: { data: <appointment.toArray()> } }`
| 409 | slot taken or version conflict |
### POST `/api/v1/my/appointment` (extended)
Extra optional body fields: `service_section_uuid`, `service_item_uuid`, `staff_uuid`, `deposit_required`, `deposit_amount_rials`, `is_reserve`, `service_item_uuids[]`, `duration_from_services`.
Extra optional body fields: `service_section_uuid`, `service_item_uuid`, `staff_uuid`, `deposit_required`, `deposit_amount_rials`, `visit_price_rials`, `is_reserve`, `service_item_uuids[]`, `duration_from_services`.
`is_reserve: true` → day-level reserve entry: `slot_end` may equal `slot_start`, the past-slot rule is skipped, and the entry never occupies a slot (several reserves may share a day). Response `201` now also returns `is_reserve`.
`service_item_uuids[]` (غیرِ رزرو): یک یا چند سرویس که به نوبت **پیوست** می‌شوند (چند سرویس)؛ اولین سرویس = سرویسِ اصلی و همه در `service_items` برمی‌گردند. UUID ناموجود ⇒ `422`.
`duration_from_services: true` (حالت نوبت‌دهی سرویسی): مدت نوبت از مجموع `duration_minutes` سرویس‌ها محاسبه و `slot_end` بازنویسی می‌شود؛ در این حالت سرویسِ غیرbookable یا بدون مدت ⇒ `422`. بدون این پرچم (حالت اسلاتی)، ساعت پایانِ دستی حفظ می‌شود.
+7
View File
@@ -292,6 +292,7 @@ entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR
"entity_type": "doctor",
"entity_id": 7,
"free_visit_price_rials": 5000000,
"require_visit_price": false,
"insurances": [
{
"insurance_id": 3,
@@ -311,6 +312,7 @@ entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR
```
- `patient_share_rials = null` یعنی این بیمه پذیرفته نمی‌شود (قیمت‌گذاری ندارد).
- `require_visit_price` — فلگ «الزامی کردن هزینه ویزیت». وقتی `true` باشد، ثبت مراجعه (session)، فاکتور سرویس و ثبت نوبت بدون هزینه ویزیت (`> 0`) رد می‌شوند.
### خطاها
- `403` `ERR_FORBIDDEN_001` — پروفایل (doctor/clinic) برای کاربر یافت نشد.
@@ -327,6 +329,7 @@ entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR
```json
{
"free_visit_price_rials": 5000000,
"require_visit_price": true,
"insurances": [
{ "insurance_id": 3, "patient_share_rials": 1500000 },
{ "insurance_id": 9, "patient_share_rials": null }
@@ -337,14 +340,18 @@ entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR
| فیلد | نوع | توضیح |
|------|-----|-------|
| `free_visit_price_rials` | int | مبلغ ویزیت آزاد (ریال). اختیاری؛ اگر نباشد تغییر نمی‌کند. |
| `require_visit_price` | bool | فلگ «الزامی کردن هزینه ویزیت». اختیاری؛ اگر نباشد مقدار ذخیره‌شده حفظ می‌شود. |
| `insurances[].insurance_id` | int | شناسه‌ی بیمه (الزامی برای هر ردیف). |
| `insurances[].patient_share_rials` | int \| null | سهم بیمار با این بیمه. `null` → ردیف حذف می‌شود. |
اعتبارسنجی: اگر فلگ مؤثر (ارسالی یا ذخیره‌شده) `true` باشد و قیمت مؤثر (ارسالی یا ذخیره‌شده) `<= 0`، درخواست رد می‌شود.
### Response `200`
همان ساختار `GET /api/v1/insurance-pricing` (وضعیت پس از ذخیره).
### خطاها
- `403` `ERR_FORBIDDEN_001` — پروفایل یافت نشد.
- `422` `ERR_VALIDATION_001` (field: `free_visit_price_rials`) — فلگ الزامی فعال است ولی قیمت ویزیت آزاد `<= 0`.
---
+2
View File
@@ -431,6 +431,7 @@ Creates a new visit session for a patient record.
**Field notes:**
- `visit_price_rials`: هزینه ویزیت (ریال). به‌طور پیش‌فرض اختیاری (پیش‌فرض `0`)؛ اگر فلگ `require_visit_price` در [insurance-pricing](insurance.md) برای tenant فعال باشد، مقدار `> 0` **الزامی** است.
- `payment_method`: `cash` | `card` | `insurance` | `online` | `pending`
- `session_at` (اختیاری): زمان پذیرش (unix)؛ اگر نیاید `null` می‌ماند و زمان ثبت (`created_at`) مبنا است.
- `inventory_package_uuid` (اختیاری): مرجع پکیج مصرفی ([inventory](inventory.md))؛ فقط پکیج متعلق به همان tenant پذیرفته می‌شود، وگرنه بی‌صدا نادیده گرفته می‌شود. روی قیمت اثری ندارد (فقط مرجع).
@@ -459,6 +460,7 @@ Creates a new visit session for a patient record.
|------|------|-------------|
| `ERR_PATIENT_NOT_FOUND` | 404 | Record not found or not owned |
| `ERR_SUBSCRIPTION_REQUIRED` | 403 | No `patient_records` feature |
| `ERR_VALIDATION_001` | 422 | فلگ `require_visit_price` فعال است ولی `visit_price_rials <= 0` (field: `visit_price_rials`) |
---