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