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

146 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# موتور قوانین
قوانین کلینیک را **داده** می‌کند، نه کد. یک کلینیک می‌تواند بگوید «لیزر زیر ۱۸ سال بدون
رضایت والدین ممنوع» بدون اینکه کسی چیزی 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` بود؛ عملگری که یک مصرف دارد، اثر است نه عملگر |