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:
@@ -0,0 +1,204 @@
|
||||
# گزینه «الزامی کردن هزینه ویزیت» در تنظیمات نوبتدهی
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (backend + پنل ادمین React)
|
||||
|
||||
## زمینه
|
||||
|
||||
در صفحه `/admin/appointment-settings` کامپوننت `FreeVisitPrice` مبلغ «قیمت ویزیت آزاد» را از `GET /api/v1/insurance-pricing` میخواند و با `PUT` همان endpoint ذخیره میکند (ذخیره در `EntityInsurancePricing` با `insurance_id = NULL`). این مبلغ در گام «ایجاد سرویس» ثبت مراجعه (`CreateStep.tsx`) بهعنوان مقدار پیشفرض «قیمت ویزیت» استفاده میشود، اما هیچکدام از فرمها آن را الزامی نمیکنند و صفحه ثبت نوبت (`AppointmentCreatePage.tsx`) اصلاً فیلد هزینه ویزیت ندارد. کلینیکهایی که میخواهند هیچ مراجعه/نوبتی بدون هزینه ویزیت ثبت نشود، ابزاری برای اجبار آن ندارند.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
یک تنظیم boolean با عنوان **«الزامی کردن هزینه ویزیت»** (بههمراه متن راهنما) به صفحه appointment-settings اضافه شود که وقتی فعال است:
|
||||
|
||||
1. «قیمت ویزیت آزاد» الزامی شود (بدون مقدار > 0 ذخیره تنظیمات ممکن نباشد) — هم در UI هم در backend.
|
||||
2. در گام «ایجاد سرویس» ثبت مراجعه (`/admin/patients/<uuid>/session/new`)، فیلد «قیمت ویزیت» الزامی شود؛ بدون مقدار > 0 ثبت نشود — UI + backend.
|
||||
3. در صفحه ثبت نوبت (`/admin/appointments/new`) فیلد جدید «هزینه ویزیت» اضافه شود (**تصمیم تأییدشده توسط کاربر**): با فلگ فعال الزامی، با فلگ غیرفعال اختیاری؛ مقدار پیشفرض از «قیمت ویزیت آزاد».
|
||||
|
||||
وقتی غیرفعال است، همه این فیلدها اختیاری بمانند (رفتار فعلی). وضعیت الزامی/اختیاری باید در UI واضح باشد (ستاره `*` روی label + پیام خطای فارسی زیر فیلد).
|
||||
|
||||
> «صدور فاکتور سرویس» در این پنل همان گام ۱ ویزارد ثبت مراجعه است (`CreateStep`) که `POST /api/v1/patient/{uuid}/session` را صدا میزند؛ گامهای پرداخت/جزییات (`PaymentStep`/`DetailsStep`) قیمت ویزیت را فقط از session ساختهشده میخوانند و فیلد ورودی ندارند. پس الزام فاکتور با اعتبارسنجی همین گام + گارد backend پوشش داده میشود.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `src/Insurance/Entity/EntityInsurancePricing.php` | محل ذخیره «قیمت ویزیت آزاد» (ردیف `insurance_id = NULL`) — ستون جدید فلگ اینجا اضافه میشود |
|
||||
| `src/Insurance/Controller/InsuranceController.php` | `GET`/`PUT /api/v1/insurance-pricing` — expose و اعتبارسنجی فلگ |
|
||||
| `src/Patient/Service/PatientService.php` | `createSession()` — اعتبارسنجی الزامی بودن `visit_price_rials` |
|
||||
| `src/Appointment/Entity/Appointment.php` | ستون جدید `visit_price_rials` (nullable) |
|
||||
| کنترلر ایجاد نوبت (`POST /api/v1/admin/appointment` و `/api/v1/my/appointment`) | پذیرش و اعتبارسنجی `visit_price_rials` — با `ddev exec php bin/console debug:router \| grep appointment` پیدا کن |
|
||||
| `assets/admin/components/FreeVisitPrice.tsx` | UI تنظیم قیمت ویزیت آزاد — toggle + helper text + الزامی شدن قیمت |
|
||||
| `assets/admin/components/session/CreateStep.tsx` | گام «ایجاد سرویس» — الزامی شدن «قیمت ویزیت» |
|
||||
| `assets/admin/pages/AppointmentCreatePage.tsx` | صفحه ثبت نوبت — فیلد جدید «هزینه ویزیت» |
|
||||
| `docs/api/insurance.md`، `docs/api/patient.md`، `docs/api/appointment.md` | بهروزرسانی مستندات (Standing Rule) |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
`EntityInsurancePricing` فقط مبلغ دارد، فلگ ندارد:
|
||||
|
||||
```php
|
||||
#[ORM\Column(name: 'patient_share_rials', type: 'integer')]
|
||||
private int $patientShareRials = 0;
|
||||
|
||||
public function isFreeVisit(): bool { return $this->insuranceId === null; }
|
||||
```
|
||||
|
||||
`InsuranceController::saveInsurancePricing` بدون هیچ اعتبارسنجی upsert میکند:
|
||||
|
||||
```php
|
||||
if (array_key_exists('free_visit_price_rials', $data)) {
|
||||
$this->upsertPricing($entityType, $entityId, null, (int) $data['free_visit_price_rials']);
|
||||
}
|
||||
```
|
||||
|
||||
`PatientService::createSession` مقدار صفر را میپذیرد:
|
||||
|
||||
```php
|
||||
$session->setVisitPriceRials((int) ($data['visit_price_rials'] ?? 0));
|
||||
```
|
||||
|
||||
`CreateStep.tsx` قیمت ویزیت را اختیاری میگیرد (پیشفرض از free visit، ولی صفر هم ثبت میشود):
|
||||
|
||||
```tsx
|
||||
const [visitPrice, setVisitPrice] = useState('0');
|
||||
// ...
|
||||
const freeVisit = (pricingData as any)?.data?.free_visit_price_rials ?? 0;
|
||||
useEffect(() => {
|
||||
if (freeVisit > 0 && (!visitPrice || visitPrice === '0')) setVisitPrice(String(freeVisit));
|
||||
}, [freeVisit]);
|
||||
// ...
|
||||
<span style={fieldLabel}>قیمت ویزیت (تومان)</span>
|
||||
<input className="input" type="number" min={0} dir="ltr" aria-label="قیمت ویزیت" value={visitPrice} onChange={(e) => setVisitPrice(e.target.value)} />
|
||||
```
|
||||
|
||||
`AppointmentCreatePage.tsx` payload فقط بیعانه دارد، هزینه ویزیت ندارد:
|
||||
|
||||
```tsx
|
||||
...(depositRequired ? { deposit_required: true, deposit_amount_rials: depositRials } : {}),
|
||||
```
|
||||
|
||||
و `Appointment` entity ستون قیمت ویزیت ندارد (فقط `deposit_amount_rials`).
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. Backend — فلگ `require_visit_price` روی EntityInsurancePricing
|
||||
|
||||
ستون boolean جدید روی همان ردیف free-visit (بدون endpoint جدید — توسعه endpoint موجود، طبق قاعده ۲):
|
||||
|
||||
```php
|
||||
#[ORM\Column(name: 'require_visit_price', type: 'boolean', options: ['default' => false])]
|
||||
private bool $requireVisitPrice = false;
|
||||
|
||||
public function isRequireVisitPrice(): bool { return $this->requireVisitPrice; }
|
||||
public function setRequireVisitPrice(bool $v): self { $this->requireVisitPrice = $v; $this->updatedAt = time(); return $this; }
|
||||
```
|
||||
|
||||
- `toArray()` هم `require_visit_price` را اضافه کن.
|
||||
- Migration: `ddev exec php bin/console doctrine:migrations:diff` سپس `migrate`.
|
||||
- در `EntityInsurancePricingRepository` (یا متد کمکی در سرویس مشترک) یک lookup ساده: فلگ فعال است اگر ردیف free-visit موجود و `requireVisitPrice === true`.
|
||||
|
||||
### ۲. Backend — `GET`/`PUT /api/v1/insurance-pricing`
|
||||
|
||||
در `getInsurancePricing`: کنار `free_visit_price_rials`، کلید `require_visit_price` (از ردیف free-visit، پیشفرض `false`) برگردان.
|
||||
|
||||
در `saveInsurancePricing`:
|
||||
|
||||
```php
|
||||
$requireVisitPrice = null;
|
||||
if (array_key_exists('require_visit_price', $data)) {
|
||||
$requireVisitPrice = (bool) $data['require_visit_price'];
|
||||
}
|
||||
$price = array_key_exists('free_visit_price_rials', $data) ? (int) $data['free_visit_price_rials'] : /* مقدار فعلی ردیف free-visit یا 0 */;
|
||||
|
||||
// اگر فلگ (جدید یا ذخیرهشده قبلی) فعال است، قیمت باید > 0 باشد
|
||||
$effectiveFlag = $requireVisitPrice ?? /* فلگ ذخیرهشده فعلی */;
|
||||
if ($effectiveFlag && $price <= 0) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'با فعال بودن «الزامی کردن هزینه ویزیت»، قیمت ویزیت آزاد الزامی است', 422, 'free_visit_price_rials');
|
||||
}
|
||||
```
|
||||
|
||||
سپس upsert موجود + ست کردن فلگ روی ردیف free-visit. (اگر فقط فلگ ارسال شود و ردیف free-visit وجود نداشته باشد، ردیف با قیمت 0 ساخته نشود مگر فلگ false باشد — سناریوی مرزی تست شود.)
|
||||
|
||||
### ۳. Backend — الزامی شدن `visit_price_rials` در ثبت session
|
||||
|
||||
در `PatientService::createSession` (یا کنترلر آن، هر جا اعتبارسنجیهای مشابه انجام میشود)، قبل از ساخت session:
|
||||
|
||||
```php
|
||||
$requireVisit = $this->pricingRepo->findOneForInsurance($entityType, $entityId, null)?->isRequireVisitPrice() ?? false;
|
||||
if ($requireVisit && (int) ($data['visit_price_rials'] ?? 0) <= 0) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'هزینه ویزیت الزامی است', 422);
|
||||
}
|
||||
```
|
||||
|
||||
(الگوی خطا را با بقیه اعتبارسنجیهای همین مسیر هماهنگ کن — اگر کنترلر `$this->error()` برمیگرداند همان الگو.)
|
||||
|
||||
### ۴. Backend — فیلد `visit_price_rials` روی Appointment
|
||||
|
||||
- ستون nullable روی `Appointment`:
|
||||
|
||||
```php
|
||||
#[ORM\Column(name: 'visit_price_rials', type: 'integer', nullable: true)]
|
||||
private ?int $visitPriceRials = null;
|
||||
```
|
||||
|
||||
- getter/setter + افزودن به `toArray()` (کنار `deposit_amount_rials`).
|
||||
- Migration جدید.
|
||||
- در endpointهای ایجاد نوبت (`POST /api/v1/admin/appointment` و `POST /api/v1/my/appointment` — کنترلر مربوطه را با `debug:router` پیدا کن): `visit_price_rials` را از payload بپذیر و ست کن. اعتبارسنجی: فلگ را برای entity پزشکِ نوبت (doctor) resolve کن؛ اگر فعال بود و مقدار ارسالی `<= 0` بود → خطای 422 با پیام فارسی «هزینه ویزیت الزامی است».
|
||||
- توجه: `resolveEntity` در `InsuranceController` بر اساس کاربر جاری است؛ برای ایجاد نوبت توسط admin/منشی، فلگ باید بر اساس **پزشک نوبت** (و در نبود قیمتگذاری پزشک، کلینیک مرتبط — همان ترتیبی که `BillingCalculator`/pricing فعلی استفاده میکند) خوانده شود، نه کاربر لاگینشده.
|
||||
|
||||
### ۵. Frontend — `FreeVisitPrice.tsx`
|
||||
|
||||
- Toggle «الزامی کردن هزینه ویزیت» (همان الگوی سوییچ `AppointmentCreatePage` خطوط ۴۳۷–۴۵۰) زیر فیلد قیمت.
|
||||
- Helper text (متن راهنما) زیر toggle با استایل `fontSize:12, color:'var(--text-3)'` — متن پیشنهادی:
|
||||
«با فعال شدن این گزینه، وارد کردن هزینه ویزیت در تنظیمات، ثبت مراجعه (سرویس)، فاکتور سرویس و ثبت نوبت الزامی میشود و بدون آن امکان ذخیره وجود ندارد.»
|
||||
- interface را گسترش بده: `interface Pricing { free_visit_price_rials: number; require_visit_price: boolean }` و state محلی برای toggle.
|
||||
- `saveMut` هر دو کلید را بفرستد: `{ free_visit_price_rials, require_visit_price }`.
|
||||
- اعتبارسنجی client-side: اگر toggle فعال و قیمت خالی/صفر → دکمه ذخیره خطا بدهد (پیام خطای فارسی زیر فیلد + `toast.error`)، درخواست ارسال نشود.
|
||||
- وقتی toggle فعال است، label قیمت با `*` قرمز: «قیمت (تومان) *».
|
||||
|
||||
### ۶. Frontend — `CreateStep.tsx`
|
||||
|
||||
- query `insurance-pricing` از قبل هست؛ فلگ را بخوان:
|
||||
|
||||
```tsx
|
||||
const requireVisit = (pricingData as any)?.data?.require_visit_price ?? false;
|
||||
```
|
||||
|
||||
- label: `قیمت ویزیت (تومان){requireVisit && ' *'}` (ستاره قرمز).
|
||||
- در `submit()` قبل از mutate:
|
||||
|
||||
```tsx
|
||||
if (requireVisit && visit <= 0) {
|
||||
toast.error('هزینه ویزیت الزامی است');
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
- پیام خطای inline زیر فیلد وقتی الزامی و خالی/صفر (state خطا که با تغییر مقدار پاک شود).
|
||||
|
||||
### ۷. Frontend — `AppointmentCreatePage.tsx`
|
||||
|
||||
- query جدید: `useQuery({ queryKey: ['insurance-pricing'], queryFn: () => api.get('/api/v1/insurance-pricing') })` → `freeVisit` و `requireVisit`.
|
||||
- بخش جدید «هزینه ویزیت» (بعد از «بیعانه» یا کنار آن): `PriceInput` با state `visitPriceRials`، مقدار اولیه از `freeVisit` (با `useEffect` مشابه CreateStep، فقط وقتی کاربر دستی تغییر نداده).
|
||||
- label با `*` وقتی `requireVisit` فعال است؛ helper کوتاه «هزینه ویزیت این نوبت (تومان)».
|
||||
- payload: `...(visitPriceRials > 0 || requireVisit ? { visit_price_rials: visitPriceRials } : {})` — و شرط `valid` را گسترش بده: `&& (!requireVisit || visitPriceRials > 0)` تا دکمه «ثبت اطلاعات» بدون مقدار غیرفعال بماند.
|
||||
- پیام inline قرمز زیر فیلد وقتی الزامی و صفر.
|
||||
|
||||
### ۸. تستها و مستندات
|
||||
|
||||
- **PHPUnit:** ذخیره تنظیمات با فلگ فعال و قیمت 0 → 422؛ با قیمت معتبر → 200؛ ثبت session با فلگ فعال بدون `visit_price_rials` → 422، با مقدار → 201؛ فلگ غیرفعال → رفتار قبلی (صفر مجاز)؛ ایجاد نوبت با/بدون فلگ.
|
||||
- **Vitest:** `FreeVisitPrice` — toggle فعال + قیمت خالی → خطا و عدم ارسال؛ `CreateStep` — الزامی بودن قیمت ویزیت با فلگ (mock query)؛ سناریوی فلگ غیرفعال بدون تغییر رفتار.
|
||||
- `docs/api/insurance.md` (کلید جدید `require_visit_price` در GET/PUT insurance-pricing + خطای 422)، `docs/api/patient.md` (اعتبارسنجی جدید session)، `docs/api/appointment.md` (فیلد جدید `visit_price_rials`) — همه در همین سشن.
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **واحدها:** backend ریال (`_rials`)، UI تومان — تبدیل با `rialToToman`/`tomanToRial` (الگوی `FreeVisitPrice`). `PriceInput` مقدار ریالی نگه میدارد (الگوی `depositRials`) — سازگاری واحد را در AppointmentCreatePage دوبار چک کن.
|
||||
- **Envelope:** پاسخ insurance-pricing با `$this->success([...])` تکسطح است و frontend فعلی با `(data as any)?.data` میخواند — همین الگو را حفظ کن، double-nesting نساز.
|
||||
- گارد اصلی backend است؛ اعتبارسنجی UI فقط تجربه کاربری. هر سه endpoint (insurance-pricing PUT، session POST، appointment POST) باید مستقل از UI مقدار را رد کنند.
|
||||
- edge case: فلگ فعال ولی ردیف free-visit حذف/بدون قیمت → ثبت مراجعه باید 422 بدهد نه crash؛ `?->` و پیشفرض `false` رعایت شود.
|
||||
- edge case: کاربر با نقش admin (بدون پروفایل پزشک) در appointments/new — query insurance-pricing ممکن است 403 بدهد (`resolveEntity` کاربر جاری)؛ در این حالت فلگ را `false` فرض کن و فیلد اختیاری بماند، یا فلگ را بر اساس پزشک انتخابشده از endpoint مناسب بخوان — هنگام پیادهسازی بررسی و مستند کن.
|
||||
- دو migration جدا (EntityInsurancePricing، Appointment) یا یکی — خروجی `migrations:diff` را قبل از migrate بازبینی کن.
|
||||
- رشتههای UI فارسی؛ کد/کامیت انگلیسی.
|
||||
- سوییچ toggle را از الگوی موجود کپی کن؛ کامپوننت جدید عمومی نساز مگر تکرار سوم.
|
||||
Reference in New Issue
Block a user