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>
10 KiB
موتور قوانین
قوانین کلینیک را داده میکند، نه کد. یک کلینیک میتواند بگوید «لیزر زیر ۱۸ سال بدون رضایت والدین ممنوع» بدون اینکه کسی چیزی 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 بود؛ عملگری که یک مصرف دارد، اثر است نه عملگر |