- 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.
15 KiB
گزینه «الزامی کردن هزینه ویزیت» در تنظیمات نوبتدهی
پروژه
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 اضافه شود که وقتی فعال است:
- «قیمت ویزیت آزاد» الزامی شود (بدون مقدار > 0 ذخیره تنظیمات ممکن نباشد) — هم در UI هم در backend.
- در گام «ایجاد سرویس» ثبت مراجعه (
/admin/patients/<uuid>/session/new)، فیلد «قیمت ویزیت» الزامی شود؛ بدون مقدار > 0 ثبت نشود — UI + backend. - در صفحه ثبت نوبت (
/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با statevisitPriceRials، مقدار اولیه از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 را از الگوی موجود کپی کن؛ کامپوننت جدید عمومی نساز مگر تکرار سوم.