# معماری — تسک ۰۹ ## ساختار فایل ``` src/Policy/ ├── Entity/ │ ├── Policy.php │ └── PolicyVersionLog.php ├── Condition/ │ ├── ConditionEvaluator.php # ارزیابی شرط │ ├── FieldRegistry.php # فهرست بستهٔ فیلدها │ ├── OperatorRegistry.php # فهرست بستهٔ عملگرها │ └── PolicyContext.php # داده‌های در دسترس قانون ├── Effect/ │ ├── EffectRegistry.php │ └── Combiner.php # جدول ترکیب اثرها (مستند بند ۸) ├── Engine/ │ ├── PolicyResolver.php # انتخاب قوانین مرتبط + حل تناقض │ ├── SelectionPolicyEngine.php │ ├── EligibilityPolicyEngine.php │ ├── ResourcePolicyEngine.php │ ├── TimingPolicyEngine.php │ ├── SpacingPolicyEngine.php # ← تنها موتوری که SQL تولید می‌کند │ └── PricingPolicyEngine.php ├── Controller/PolicyController.php └── Repository/PolicyRepository.php ``` شش موتور جدا، نه یک `PolicyEngine` بزرگ. هر کدام ورودی و خروجی نوع‌دار خودش را دارد و در نقطهٔ متفاوتی از زنجیره صدا زده می‌شود — ادغامشان یعنی یک کلاس با شش دلیل تغییر. ## `Policy` ```php class Policy { use TenantOwnedTrait; public const CATEGORIES = ['selection','eligibility','resource','timing','spacing','pricing']; private string $name; private string $category; private ?Branch $branch = null; // null = همهٔ شعب محیط private int $priority = 0; private int $version = 1; private ?int $validFrom = null; private ?int $validTo = null; private bool $active = false; // ← پیش‌فرض غیرفعال، تا آزمایش شود private array $conditions = []; // JSON — فهرست بسته private array $effects = []; // JSON — فهرست بسته private int $specificity = 0; // محاسبه‌شده، برای حل تناقض } ``` `active = false` پیش‌فرض عمدی است — مستند بند ۸: «قبل از اینکه یک قانون واقعاً فعال شود، باید بشود آن را روی داده واقعی اجرا کرد». تسک ۱۰ همین را می‌سازد. ## شرط — فهرست بسته ```json { "all": [ { "field": "service.category_path", "op": "contains", "value": "جراحی" }, { "field": "patient.age", "op": "gte", "value": 18 } ], "any": [ { "field": "patient.tags", "op": "in", "value": ["vip", "gold"] } ] } ``` فقط `all` و `any` در **یک** سطح. تودرتویی ممنوع — مستند بند ۸: قانون باید قابل تبدیل به کوئری باشد و تودرتویی دلخواه یعنی همان کد دلخواهی که ممنوع شده. ### `FieldRegistry` — فهرست کامل | دامنه | فیلدها | |---|---| | `service` | `id`, `category_path`, `tags`, `session_count` | | `options` | `ids`, `group_ids`, `count` | | `patient` | `age`, `gender`, `tags`, `last_session_at`, `completed_sessions_count` | | `booking` | `at`, `day_of_week`, `branch_id`, `channel` (`online`\|`panel`\|`phone`) | | `plan` | `total_minutes`, `total_price_rials` | هر فیلد جدید باید **صریحاً** اینجا اضافه شود. `FieldRegistry` هم schema را برای `GET /policy-schema` تولید می‌کند و هم استخراج مقدار از `PolicyContext` را انجام می‌دهد — یک منبع حقیقت، پس فیلدی که در فرم هست و در ارزیابی نیست، ممکن نمی‌شود. ### `OperatorRegistry` `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `not_in`, `between`, `contains`, `days_since` `days_since` عملگر ویژهٔ مستند است («تعداد روز گذشته») و روی فیلدهای زمانی کار می‌کند. ## اثر — فهرست بسته per دسته | دسته | اثرهای مجاز | |---|---| | `selection` | `require_option`, `forbid_option`, `auto_add_option` | | `eligibility` | `deny` (با پیام), `require_flag` | | `resource` | `add_requirement`, `restrict_requirement` | | `timing` | `min_duration`, `add_duration` | | `spacing` | `min_days_since_last`, `max_days_since_last`, `deny_weekday` | | `pricing` | `discount_percent`, `discount_fixed`, `surcharge_percent`, `surcharge_fixed` | اثر خارج از دستهٔ خودش → `422` هنگام ذخیره. این جدول عیناً در `GET /policy-schema` می‌آید. ## حل تناقض — `PolicyResolver` ```php usort($policies, function (Policy $a, Policy $b) { return [$b->getPriority(), $b->getSpecificity(), $a->getCreatedAt()] <=> [$a->getPriority(), $a->getSpecificity(), $b->getCreatedAt()]; }); ``` سه معیار به ترتیب مستند: اولویت بالاتر → اختصاصی‌تر → قدیمی‌تر. `specificity` هنگام ذخیره محاسبه و ذخیره می‌شود (نه در زمان اجرا): ``` +8 branch مشخص +4 service مشخص در شرط +2 service_category مشخص در شرط +1 هر شرط اضافه ``` ## ترکیب اثرها — `Combiner` (جدول مستند بند ۸) | نوع اثر | قاعده | پیاده‌سازی | |---|---|---| | `min_duration` | بیشترین برنده | `max()` | | `add_duration` | جمع | `array_sum()` | | `add_requirement` | همه، تکراری حذف | union روی `groupKey` | | `restrict_requirement` | اشتراک محدودیت‌ها | `array_intersect` روی کاندیدها | | `discount_percent` | پشت‌سرهم به ترتیب اولویت، با سقف | حلقهٔ ضربی + `min($total, $cap)` | | `deny` / `forbid_option` | یکی کافی است | short-circuit | `Combiner` یک کلاس خالص و بدون I/O — تست واحد کامل بدون دیتابیس. ## `SpacingPolicyEngine` — تنها موتور SQL-ساز مستند بند ۸: «قوانین مربوط به فاصله زمانی باید قابل تبدیل به کوئری دیتابیس باشند، وگرنه جستجوی وقت آزاد کند می‌شود». ```php /** به‌جای فیلتر کردن ۹۰ روز اسلات در PHP، یک بازهٔ ممنوعه برمی‌گرداند. */ public function forbiddenRanges(PolicyContext $ctx): array { // قانون: min_days_since_last = 21 // آخرین جلسهٔ بیمار برای این سرویس: 1405/05/01 // → بازهٔ ممنوعه: [آخرین جلسه, آخرین جلسه + 21 روز) // یک کوئری برای «آخرین جلسه»، بعد محاسبهٔ بازه در PHP } ``` `AvailabilityEngine` این بازه‌ها را **پیش از** تولید کاندیدها به `CandidateGenerator` می‌دهد تا آن نقطه‌ها هرگز ساخته نشوند — نه اینکه بعد فیلتر شوند. ## نسخه‌بندی ``` POST /policy/{uuid}/version { valid_from: …, conditions: …, effects: … } │ ├─ نسخهٔ فعلی: valid_to = new.valid_from - 1 ├─ ردیف جدید در policy_version_log با snapshot کامل نسخهٔ قبلی └─ policy.version++ و مقادیر جدید روی همان ردیف ``` قانون **ویرایش نمی‌شود** — هر تغییر نسخهٔ جدید با تاریخ شروع می‌سازد. نوبت‌ها `{policy_id, version}` را ذخیره می‌کنند (`applied_policy_ids` تسک ۰۸)، پس فاکتور دیروز با تغییر امروز خراب نمی‌شود. `policy_version_log` snapshot کامل نگه می‌دارد نه diff: بازسازی نسخهٔ قدیم باید یک `SELECT` باشد، نه اعمال زنجیرهٔ diff. ## نقاط اتصال — همه از قبل آماده‌اند ```php // تسک ۰۵ — AppointmentPlanBuilder::build() مرحلهٔ ۷ return $this->policies->applyToPlan($withResources); // ↑ ResourcePolicyEngine + TimingPolicyEngine // تسک ۰۶ — AvailabilityEngine::search() مرحلهٔ ۶ return $this->policies->filterSlots($result, $req); // ↑ SpacingPolicyEngine (به‌صورت forbiddenRanges، پیش از تولید کاندید) // تسک ۰۷ — BookingService::confirm() مرحلهٔ ۳ $this->policies->assertEligibility($appointment); // ↑ EligibilityPolicyEngine // تسک ۰۸ — PricingEngine::quote() مرحلهٔ ۳ $lines = $this->discounts->apply($lines, $req); // ↑ PricingPolicyEngine + DiscountEngine موجود // تسک ۰۴ — ServiceSelectionValidator::validate() $errors = array_merge($errors, $this->policies->validateSelection($ctx, …)); // ↑ SelectionPolicyEngine ``` هیچ امضایی عوض نمی‌شود — این دقیقاً دلیلی است که آن قلاب‌ها از روز اول گذاشته شدند.