Files
clinicpro/docs/architecture/policy-engine.md
T
hamedandClaude Opus 5 584ea4067f feat(policy): six-category policy engine wired into the booking flow
Rules become data instead of code: a clinic can say "laser under 18 requires
parental consent" without a deploy.

Engine
- Policy / PolicyVersionLog entities, closed field/operator/effect lists per
  category (PolicySchema), condition validation at write time
- PolicyResolver: priority -> specificity -> age, combining effects by
  veto / max / sum / union
- A missing fact fails its clause instead of silently passing it
- Policies are drafts until activated, and are versioned rather than edited

Wiring
- selection -> ServiceSelectionValidator
- eligibility + spacing -> BookingPolicyGuard, at hold time not confirm time
- resource + timing -> AppointmentPlanBuilder, including template-less services
- pricing -> PricingEngine, alongside (not replacing) the manual discount

The condition column is named condition_json: `condition` is a MariaDB keyword
and broke every INSERT.

Tests: 17 in tests/Policy including NoPolicyRegressionTest, which pins that a
clinic with no policies sees byte-identical output to task 08.
Docs: docs/api/policy.md (real captured JSON) + docs/architecture/policy-engine.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:19:19 +03:30

7.5 KiB
Raw Blame History

موتور قوانین

قوانین کلینیک را داده می‌کند، نه کد. یک کلینیک می‌تواند بگوید «لیزر زیر ۱۸ سال بدون رضایت والدین ممنوع» بدون اینکه کسی چیزی deploy کند.

مرجع: بند ۸ مستند طراحی. پیادهٔ اندپوینت‌ها: ../api/policy.md


شش دسته، شش نقطهٔ اجرا

دسته کجا اجرا می‌شود چه چیزی را عوض می‌کند
selection ServiceSelectionValidator خطای policy_forbidden در نتیجهٔ اعتبارسنجی
eligibility BookingPolicyGuard (لحظهٔ رزرو موقت) 422 یا الزام یک پرچم
resource AppointmentPlanBuilder نقش لازم به بخشِ حضور بیمار اضافه می‌شود
timing AppointmentPlanBuilder مجموع مدت نوبت
spacing BookingPolicyGuard (لحظهٔ رزرو موقت) رد رزرو وقتی فاصله تا جلسهٔ قبلی کم است
pricing PricingEngine تخفیف، کنارِ تخفیف دستی نه به‌جایش

هر شش دسته از یک PolicyResolver مشترک عبور می‌کنند؛ تفاوتشان در حقایقی است که هر نقطه می‌سازد و در اثرهایی که می‌خواند.


چرا کد دلخواه ممنوع است

شرط قانون یک عبارت است، نه یک اسکریپت: فیلد باید در فهرست بستهٔ PolicySchema::FIELDS همان دسته باشد و عملگر یکی از شش عملگر ثابت. دلیلش سه چیز است:

۱. امنیت — اجرای رشتهٔ کاربر روی سرور، هر «تنظیمات» را به RCE تبدیل می‌کند. ۲. پیش‌بینی‌پذیری — عبارت بسته را می‌شود قبل از ذخیره اعتبارسنجی کرد؛ کد دلخواه فقط موقع اجرا می‌ترکد، یعنی وسط رزرو بیمار. ۳. قابلیت توضیح — پنل باید بتواند بگوید «چرا رد شد»؛ از یک عبارت بسته می‌شود، از یک تابع دلخواه نمی‌شود.

به همین دلیل شرط تودرتو هم نیست: فقط یک سطح match: all|any. تودرتویی یعنی فرم درخت‌ساز، و درختی که کاربر نمی‌فهمد قانونی است که کسی جرأت خاموش کردنش را ندارد.

فیلد بی‌مقدار، شرط را رد می‌کند

اگر حقیقتِ لازم در آن نقطه وجود نداشته باشد (مثلاً patient_age در پیش‌نمایش برنامه که بیماری ندارد)، آن بند برقرار نیست. جایگزینش — نادیده گرفتن بند — یعنی قانون «زیر ۱۸ ممنوع» وقتی سن نامشخص است بی‌صدا اجازه بدهد.

حل تناقض

اولویت بزرگ‌تر  ←  اختصاصی‌تر  ←  قدیمی‌تر

اختصاصی‌بودن عددی است: شعبه ۴، سرویس ۲، دستهٔ کاتالوگ ۱، بدون دامنه ۰ (جمع می‌شوند).

قاعدهٔ سوم عمداً «قدیمی‌تر» است نه «تازه‌تر»: قانونی که مدت‌هاست کار می‌کند رفتار جاافتادهٔ کلینیک است، و قانون تازه‌ای که تصادفاً هم‌اولویت شده نباید بی‌صدا عوضش کند.

ترکیب اثرها

اثر ترکیب معنی
forbid veto یک ممنوعیت کافی است، حتی مقابل ده قانون مجازکننده
require_resource · require_flag union اجتماع بدون تکرار
min_duration_minutes · min_days_between max سخت‌گیرترین برنده
add_duration_minutes · discount_percent · discount_rials sum جمع

min_duration با max ترکیب می‌شود چون «حداقل» یعنی حداقل؛ اگر آخرین قانون برنده بود، ترتیبِ نوشتن قوانین رفتار را عوض می‌کرد.

نسخه، نه ویرایش

قانون ویرایش نمی‌شود. هر تغییر یک نسخهٔ تازه است و متن قبلی در policy_version_logs به‌صورت snapshot کامل (نه diff) می‌ماند. فاکتور نوبت {uuid, name, version} هر قانون اعمال‌شده را نگه می‌دارد، پس سه ماه بعد می‌شود گفت دقیقاً کدام متن روی آن نوبت اجرا شده.

name هم کپی می‌شود نه ارجاع: قانونی که فردا اسمش عوض شود نباید فاکتور دیروز را بازنویسی کند.

پیش‌نویس بودن پیش‌فرض

قانون تازه active = false است. نوشتن قانون نباید یعنی اجرای آن — به‌خصوص وقتی یک forbid بدجا می‌تواند کل رزرو یک شعبه را بخواباند.


DiscountRule یا Policy؟

هر دو ماندند و هیچ‌کدام به دیگری مهاجرت نکرد.

بپرس جواب
تخفیف کمپین/کد تخفیف با سقف مصرف و بازهٔ تاریخ؟ DiscountRule
تخفیف مشروط به وضعیت بیمار یا سبد (تعداد آیتم، تعداد ویزیت، برچسب)؟ Policy دستهٔ pricing

DiscountRule یک ابزار بازاریابی با شمارندهٔ مصرف است؛ Policy یک قاعدهٔ عملیاتی بدون شمارنده. مهاجرت یکی به دیگری یعنی یا شمارندهٔ مصرف را به موتور قوانین تحمیل کنیم یا شرط‌های بیمار را به کد تخفیف — هر دو یک انتزاع را خراب می‌کنند تا دومی را جا بدهند.

در PricingEngine هر دو منبع جمع می‌شوند و سقف max_total_discount_percent روی جمعشان اعمال می‌شود.


تصمیم‌های ثبت‌شده و انحراف‌ها

موضوع تصمیم دلیل
یک PolicyResolver به‌جای شش موتور جدا یک resolver + یک نقطهٔ اجرا در هر سرویس مقصد شش کلاس با همان بدنه فقط تکرار بود؛ تفاوت واقعی در حقایق است که هر نقطه خودش می‌سازد
spacing در لحظهٔ رزرو موقت، نه در تولید کاندید رد کردن هنگام hold نگه داشتن تعداد کوئریِ AvailabilityEngine ثابت؛ هزینه‌اش این است که اسلات نمایش داده می‌شود و بعد رد؛ بستنِ آن در تولید کاندید به تسک ۱۳ موکول شد
specificity هنگام اجرا حساب می‌شود متد Policy::specificity() ستون ذخیره‌شده باید با تغییر دامنه هم‌زمان به‌روز بماند؛ محاسبهٔ درجا سه مقایسهٔ صحیح است
appointments.applied_policies ساخته نشد فعلاً PriceSnapshot.sources.applied_policies نوبت‌های بدون فاکتور هنوز ردپای قانون ندارند — تسک ۱۰
عملگر days_since اضافه نشد min_days_between مستقیم فاصله را می‌سنجد تنها مصرفش همان دستهٔ spacing بود؛ عملگری که یک مصرف دارد، اثر است نه عملگر