Files
clinicpro/.claude/prompt/require-visit-price-setting.md
T
hamed a0ddb4c0d1 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.
2026-07-16 19:44:30 +03:30

205 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# گزینه «الزامی کردن هزینه ویزیت» در تنظیمات نوبت‌دهی
## پروژه
`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 را از الگوی موجود کپی کن؛ کامپوننت جدید عمومی نساز مگر تکرار سوم.