# گزینه «الزامی کردن هزینه ویزیت» در تنظیمات نوبت‌دهی ## پروژه `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//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]); // ... قیمت ویزیت (تومان) 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 را از الگوی موجود کپی کن؛ کامپوننت جدید عمومی نساز مگر تکرار سوم.