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>
146 lines
10 KiB
Markdown
146 lines
10 KiB
Markdown
# موتور قوانین
|
||
|
||
قوانین کلینیک را **داده** میکند، نه کد. یک کلینیک میتواند بگوید «لیزر زیر ۱۸ سال بدون
|
||
رضایت والدین ممنوع» بدون اینکه کسی چیزی deploy کند.
|
||
|
||
مرجع: بند ۸ مستند طراحی. پیادهٔ اندپوینتها: [../api/policy.md](../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` بود؛ عملگری که یک مصرف دارد، اثر است نه عملگر |
|