Files
hamedandClaude Opus 5 bcfa87bfad feat(policy): rule builder and mandatory dry-run sandbox
Task 09 shipped a powerful API that a non-technical clinic owner could not
safely use. This closes that gap: activation now requires having seen what the
rule actually does.

- PolicySimulator runs a policy against real past appointments and writes
  nothing: evaluation works on facts (never entities), the whole run sits in a
  transaction rolled back and cleared in `finally`, and a test counts rows in
  five sensitive tables before and after
- activate() now demands a simulation of the *same version* — a report for
  version 1 does not unlock version 2
- PolicyTemplateRegistry: six ready-made rules, so the common case never
  touches a raw condition
- Severity from the affected ratio; 0% is a warning too, since a rule that
  changes nothing usually has a condition that never matches
- An empty clinic still succeeds with a warning, otherwise a new clinic could
  never activate anything

Admin: PoliciesPage, PolicyFormPage, PolicySimulationPage, and a
PolicyConditionBuilder built entirely from GET /policy-schema — a test proves a
field that exists only in the schema shows up with no frontend change, and that
operators are filtered per field type.

The schema response now carries per-field metadata (label, type, meaningful
operators) so the form has one source of truth instead of two.

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

10 KiB
Raw Permalink 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 روی جمعشان اعمال می‌شود.


چرا آزمایش اجباری است

بند ۱۷ مستند، ریسک دوم: «کاربر غیرفنی نمی‌تواند قانون درست تعریف کند → قانون‌های اشتباه، رفتار عجیب». موتور قانون بدون آزمایشگاه یک API قدرتمند است که هیچ‌کس نمی‌تواند درست از آن استفاده کند.

پس activate یک شرط دارد: یک اجرای آزمایشیِ همین نسخه باید ثبت شده باشد. آزمایش قانون را روی نوبت‌های واقعیِ گذشته اجرا می‌کند و می‌گوید چند نوبت تغییر می‌کردند و دقیقاً چه تغییری. عددِ «۷۵٪ نوبت‌ها رد می‌شدند» چیزی است که کاربر غیرفنی هم می‌فهمد.

نسخه‌محور بودن شرط عمدی است: کاربری که گزارش را دید و بعد متن قانون را عوض کرد، دیگر گزارشی از قانونِ فعلی ندارد.

هیچ چیز ثبت نمی‌شود — سه لایه

۱. ارزیابی روی حقایق انجام می‌شود نه روی entity؛ هیچ entity ای تغییر نمی‌کند. ۲. کل اجرا در تراکنشی است که در finally همیشه rollback و clear می‌شود. clear اختیاری نیست: entity های لمس‌شده در identity map می‌مانند و اولین flush بعدی در همان request ثبتشان می‌کند — باگی که پیدا کردنش روزها می‌برد. ۳. PolicySimulationTest::testSimulationWritesNothingButItsOwnRun تعداد ردیف جدول‌های حساس را قبل و بعد می‌شمارد.

خودِ PolicySimulationRun بعد از این بلوک و در تراکنش خودش ثبت می‌شود.

دو حالتِ مرزی که عمداً موفق‌اند

  • محیط بدون نوبت گذشته → گزارش خالی با warning. اگر خطا بود، کلینیک تازه هرگز نمی‌توانست قانونی فعال کند.
  • قانونی که هیچ نوبتی را تغییر نمی‌دهد → موفق ولی با شدت none، که خودش هشدار است: شرط احتمالاً هرگز برقرار نمی‌شود.

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

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