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