# گزینه «الزامی کردن هزینه ویزیت» در تنظیمات نوبت‌دهی

## پروژه

`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 را از الگوی موجود کپی کن؛ کامپوننت جدید عمومی نساز مگر تکرار سوم.
