feat(policy): six-category policy engine wired into the booking flow

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>
This commit is contained in:
hamed
2026-07-31 10:19:19 +03:30
co-authored by Claude Opus 5
parent 281420ab4d
commit 584ea4067f
26 changed files with 2870 additions and 86 deletions
+111
View File
@@ -0,0 +1,111 @@
# موتور قوانین
قوانین کلینیک را **داده** می‌کند، نه کد. یک کلینیک می‌تواند بگوید «لیزر زیر ۱۸ سال بدون
رضایت والدین ممنوع» بدون اینکه کسی چیزی 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` روی جمعشان
اعمال می‌شود.
---
## تصمیم‌های ثبت‌شده و انحراف‌ها
| موضوع | تصمیم | دلیل |
|---|---|---|
| یک `PolicyResolver` به‌جای شش موتور جدا | یک resolver + یک نقطهٔ اجرا در هر سرویس مقصد | شش کلاس با همان بدنه فقط تکرار بود؛ تفاوت واقعی در حقایق است که هر نقطه خودش می‌سازد |
| `spacing` در لحظهٔ رزرو موقت، نه در تولید کاندید | رد کردن هنگام `hold` | نگه داشتن تعداد کوئریِ `AvailabilityEngine` ثابت؛ **هزینه‌اش** این است که اسلات نمایش داده می‌شود و بعد رد؛ بستنِ آن در تولید کاندید به تسک ۱۳ موکول شد |
| `specificity` هنگام اجرا حساب می‌شود | متد `Policy::specificity()` | ستون ذخیره‌شده باید با تغییر دامنه هم‌زمان به‌روز بماند؛ محاسبهٔ درجا سه مقایسهٔ صحیح است |
| `appointments.applied_policies` ساخته نشد | فعلاً `PriceSnapshot.sources.applied_policies` | نوبت‌های بدون فاکتور هنوز ردپای قانون ندارند — تسک ۱۰ |
| عملگر `days_since` اضافه نشد | `min_days_between` مستقیم فاصله را می‌سنجد | تنها مصرفش همان دستهٔ `spacing` بود؛ عملگری که یک مصرف دارد، اثر است نه عملگر |