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:
+4
-1
@@ -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 |
|
||||
|
||||
@@ -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`. بدون این پرچم (حالت اسلاتی)، ساعت پایانِ دستی حفظ میشود.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user