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

15 KiB
Raw Blame History

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

پروژه

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 فقط مبلغ دارد، فلگ ندارد:

#[ORM\Column(name: 'patient_share_rials', type: 'integer')]
private int $patientShareRials = 0;

public function isFreeVisit(): bool { return $this->insuranceId === null; }

InsuranceController::saveInsurancePricing بدون هیچ اعتبارسنجی upsert می‌کند:

if (array_key_exists('free_visit_price_rials', $data)) {
    $this->upsertPricing($entityType, $entityId, null, (int) $data['free_visit_price_rials']);
}

PatientService::createSession مقدار صفر را می‌پذیرد:

$session->setVisitPriceRials((int) ($data['visit_price_rials'] ?? 0));

CreateStep.tsx قیمت ویزیت را اختیاری می‌گیرد (پیش‌فرض از free visit، ولی صفر هم ثبت می‌شود):

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 فقط بیعانه دارد، هزینه ویزیت ندارد:

...(depositRequired ? { deposit_required: true, deposit_amount_rials: depositRials } : {}),

و Appointment entity ستون قیمت ویزیت ندارد (فقط deposit_amount_rials).

وظایف

۱. Backend — فلگ require_visit_price روی EntityInsurancePricing

ستون boolean جدید روی همان ردیف free-visit (بدون endpoint جدید — توسعه endpoint موجود، طبق قاعده ۲):

#[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:

$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:

$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:
#[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 از قبل هست؛ فلگ را بخوان:
const requireVisit = (pricingData as any)?.data?.require_visit_price ?? false;
  • label: قیمت ویزیت (تومان){requireVisit && ' *'} (ستاره قرمز).
  • در submit() قبل از mutate:
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 را از الگوی موجود کپی کن؛ کامپوننت جدید عمومی نساز مگر تکرار سوم.