# معماری — تسک ۰۹

## ساختار فایل

```
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
```

هیچ امضایی عوض نمی‌شود — این دقیقاً دلیلی است که آن قلاب‌ها از روز اول گذاشته شدند.
