# Policy — موتور قوانین شش‌دسته‌ای

اندپوینت‌های `src/Policy/*`. قوانین کلینیک را بدون تغییر کد تعریف می‌کنند: چه چیزی
انتخاب‌شدنی است، چه کسی واجد شرایط است، چه منبعی لازم است، چقدر طول می‌کشد، چه فاصله‌ای
بین جلسات باشد، و چه تخفیفی بخورد.

همهٔ مسیرها `IS_AUTHENTICATED_FULLY` می‌خواهند و به **محیط جاری** کاربر
(`entity_type`,`entity_id`) محدودند؛ قانون محیط دیگر `404` می‌دهد نه `403`.

---

## شش دسته و اثرهایشان

| دسته | فیلدهای مجاز | اثرهای مجاز | ترکیب | جای اجرا |
|---|---|---|---|---|
| `selection` | `item_count`, `item_uuids`, `catalog_category` | `forbid` | veto | `POST /api/v1/service-selection/validate` |
| `eligibility` | `patient_age`, `patient_gender`, `patient_tags`, `has_parental_consent`, `visit_count` | `forbid`, `require_flag` | veto / union | `POST /api/v1/appointment-hold` |
| `resource` | `catalog_category`, `service_uuid`, `item_count` | `require_resource`, `forbid` | union / veto | ساخت برنامهٔ نوبت |
| `timing` | `catalog_category`, `service_uuid`, `item_count`, `patient_age` | `min_duration_minutes`, `add_duration_minutes` | max / sum | ساخت برنامهٔ نوبت |
| `spacing` | `catalog_category`, `service_uuid` | `min_days_between` | max | `POST /api/v1/appointment-hold` |
| `pricing` | `patient_tags`, `visit_count`, `item_count`, `subtotal_rials` | `discount_percent`, `discount_rials` | sum | `POST /api/v1/pricing/quote` |

عملگرها (برای همهٔ دسته‌ها یکسان): `equals`, `not_equals`, `greater_than`, `less_than`,
`in`, `contains`.

**ترتیب حل تناقض:** اولویت بزرگ‌تر ← اختصاصی‌تر (شعبه ۴، سرویس ۲، دسته ۱) ← قانون
قدیمی‌تر. یک `forbid` کل عملیات را رد می‌کند حتی اگر ده قانون مجازکننده باشند.

**قانون تازه پیش‌نویس است** (`active: false`) و تا `activate` نشود اجرا نمی‌شود.

**قانون ویرایش نمی‌شود**؛ `POST /policy/{uuid}/version` نسخهٔ تازه می‌سازد و نسخهٔ قبلی در
`policy_version_logs` می‌ماند. شمارهٔ نسخه در فاکتور ثبت می‌شود
(`breakdown.sources.applied_policies[].version`).

---

## GET `/api/v1/policy-schema`

فهرست بستهٔ فیلدها، عملگرها و اثرها به تفکیک دسته. فرم ساخت قانون در پنل باید از همین
ساخته شود، نه از فهرستی که در فرانت دوباره نوشته شده باشد.

**Permission:** `IS_AUTHENTICATED_FULLY`

هر دسته `label` فارسی، فهرست `fields`، همهٔ `operators`، `field_meta` (فراداده per فیلد
شامل **عملگرهای معنادار برای همان نوع**) و `effects` با برچسب و نوع مقدار دارد.

> فرم باید عملگرها را از `field_meta[].operators` بخواند نه از `operators` کلی؛ وگرنه
> کاربر `patient_tags > 5` می‌سازد و `422` می‌گیرد بدون اینکه بفهمد چرا.

### Response `200`
```json
{
  "success": true,
  "data": {
    "selection": {
      "label": "انتخاب خدمات",
      "fields": ["item_count", "item_uuids", "catalog_category"],
      "operators": ["equals", "not_equals", "greater_than", "less_than", "in", "contains"],
      "field_meta": [
        { "label": "تعداد موارد انتخابی", "type": "int", "key": "item_count",
          "operators": ["equals", "not_equals", "greater_than", "less_than"] },
        { "label": "موارد انتخابی", "type": "list", "key": "item_uuids", "operators": ["contains"] },
        { "label": "دستهٔ کاتالوگ", "type": "uuid", "key": "catalog_category",
          "operators": ["equals", "not_equals", "in"] }
      ],
      "effects": [{ "label": "ممنوع کن", "value_type": "none", "type": "forbid", "combination": "veto" }]
    },
    "eligibility": {
      "fields": ["patient_age", "patient_gender", "patient_tags", "has_parental_consent", "visit_count"],
      "operators": ["equals", "not_equals", "greater_than", "less_than", "in", "contains"],
      "effects": [
        { "type": "forbid", "combination": "veto" },
        { "type": "require_flag", "combination": "union" }
      ]
    },
    "resource": { "fields": ["catalog_category", "service_uuid", "item_count"], "operators": ["…"], "effects": [{ "type": "require_resource", "combination": "union" }, { "type": "forbid", "combination": "veto" }] },
    "timing": { "fields": ["catalog_category", "service_uuid", "item_count", "patient_age"], "operators": ["…"], "effects": [{ "type": "min_duration_minutes", "combination": "max" }, { "type": "add_duration_minutes", "combination": "sum" }] },
    "spacing": { "fields": ["catalog_category", "service_uuid"], "operators": ["…"], "effects": [{ "type": "min_days_between", "combination": "max" }] },
    "pricing": { "fields": ["patient_tags", "visit_count", "item_count", "subtotal_rials"], "operators": ["…"], "effects": [{ "type": "discount_percent", "combination": "sum" }, { "type": "discount_rials", "combination": "sum" }] }
  }
}
```

---

## GET `/api/v1/policies`

فهرست قوانین محیط جاری.

| Query | Type | Description |
|---|---|---|
| `category` | string | یکی از شش دسته؛ نامعتبر → `422` |

### Response `200`
```json
{
  "success": true,
  "data": [
    {
      "uuid": "f2e97553-835d-41c9-b465-49107821d7d2",
      "category": "timing",
      "name": "حداقل یک ساعت برای لیزر",
      "condition": { "match": "all", "conditions": [{ "field": "item_count", "operator": "greater_than", "value": 1 }] },
      "effects": [{ "type": "min_duration_minutes", "value": 90 }],
      "priority": 10,
      "version": 2,
      "active": true,
      "valid_from": null,
      "valid_to": null,
      "address_uuid": null,
      "service_uuid": null,
      "catalog_category_uuid": null,
      "specificity": 0
    }
  ]
}
```

---

## POST `/api/v1/policy`

ساخت قانون. خروجی همیشه `active: false` است.

### Request Body
```json
{
  "category": "timing",
  "name": "حداقل یک ساعت برای لیزر",
  "priority": 10,
  "condition": {
    "match": "all",
    "conditions": [{ "field": "item_count", "operator": "greater_than", "value": 1 }]
  },
  "effects": [{ "type": "min_duration_minutes", "value": 60 }]
}
```

| Field | Type | Required | Description |
|---|---|---|---|
| `template` | string | — | کلید یک الگو از `GET /policy-templates`؛ اگر بیاید، `category`/`condition`/`effects` از الگو ساخته می‌شوند |
| `values` | object | — | مقادیر ورودی‌های همان الگو |
| `category` | string | ✅ (بدون `template`) | یکی از شش دسته |
| `name` | string | ✅ | نام قابل‌فهم؛ در پیام ممنوعیت به کاربر نشان داده می‌شود |
| `condition` | object | — | `{match: "all"\|"any", conditions: [...]}` — خالی یعنی «همیشه» |
| `condition.conditions[].field` | string | ✅ | باید در فهرست فیلدهای همان دسته باشد |
| `condition.conditions[].operator` | string | — | پیش‌فرض `equals` |
| `condition.conditions[].value` | mixed | — | برای `in`/`contains` آرایه |
| `effects` | array | — | حداقل یک اثر؛ هر اثر `{type, value}` و برای `forbid` می‌تواند `reason` داشته باشد |
| `priority` | int | — | پیش‌فرض `0`؛ بزرگ‌تر برنده |
| `valid_from` / `valid_to` | int\|null | — | Unix؛ `valid_to` باید بعد از `valid_from` باشد |
| `address_uuid` | string\|null | — | محدود کردن به یک شعبه (اختصاصی‌بودن ۴) |
| `service_uuid` | string\|null | — | محدود کردن به یک سرویس (۲) |
| `catalog_category_uuid` | string\|null | — | محدود کردن به یک دستهٔ کاتالوگ (۱) |

### Response `201`
```json
{
  "success": true,
  "data": {
    "uuid": "f2e97553-835d-41c9-b465-49107821d7d2",
    "category": "timing",
    "name": "حداقل یک ساعت برای لیزر",
    "condition": { "match": "all", "conditions": [{ "field": "item_count", "operator": "greater_than", "value": 1 }] },
    "effects": [{ "type": "min_duration_minutes", "value": 60 }],
    "priority": 10,
    "version": 1,
    "active": false,
    "valid_from": null,
    "valid_to": null,
    "address_uuid": null,
    "service_uuid": null,
    "catalog_category_uuid": null,
    "specificity": 0
  }
}
```

### Errors
| Code | HTTP | Description |
|---|---|---|
| `ERR_VALIDATION_001` | 422 | دستهٔ نامعتبر، فیلد/عملگر/اثر خارج از فهرست دسته، کلید ناشناس در `condition`، `valid_to` قبل از `valid_from` |
| `ERR_VALIDATION_002` | 422 | نام خالی یا `effects` خالی |
| `ERR_NOT_FOUND_001` | 404 | `address_uuid` / `service_uuid` / `catalog_category_uuid` خارج از محیط جاری |

فیلد خارج از دسته:
```json
{
  "success": false,
  "data": null,
  "errors": [{
    "code": "ERR_VALIDATION_001",
    "message": "فیلد «subtotal_rials» برای دستهٔ «timing» مجاز نیست. مجازها: catalog_category، service_uuid، item_count، patient_age",
    "field": "condition"
  }]
}
```

اثر خارج از دسته:
```json
{
  "success": false,
  "data": null,
  "errors": [{
    "code": "ERR_VALIDATION_001",
    "message": "اثر «discount_percent» با دستهٔ «timing» سازگار نیست. مجازها: min_duration_minutes، add_duration_minutes",
    "field": "effects"
  }]
}
```

> کلید ناشناس در ریشهٔ `condition` (مثلاً `{"all": [...]}` به‌جای
> `{"match": "all", "conditions": [...]}`) هم `422` می‌گیرد — چون شرطِ خالی «همیشه صادق»
> است و قانون بی‌سروصدا روی همه‌چیز اجرا می‌شد.

---

## GET `/api/v1/policy/{uuid}`

قانون به‌همراه **همهٔ نسخه‌هایش**.

### Response `200`
```json
{
  "success": true,
  "data": {
    "uuid": "f2e97553-835d-41c9-b465-49107821d7d2",
    "category": "timing",
    "name": "حداقل یک ساعت برای لیزر",
    "condition": { "match": "all", "conditions": [{ "field": "item_count", "operator": "greater_than", "value": 1 }] },
    "effects": [{ "type": "min_duration_minutes", "value": 90 }],
    "priority": 10,
    "version": 2,
    "active": true,
    "valid_from": null,
    "valid_to": null,
    "address_uuid": null,
    "service_uuid": null,
    "catalog_category_uuid": null,
    "specificity": 0,
    "versions": [
      { "version": 1, "snapshot": { "…": "متن کامل نسخهٔ ۱" }, "created_at": 1785480121 },
      { "version": 2, "snapshot": { "…": "متن کامل نسخهٔ ۲" }, "created_at": 1785480121 }
    ]
  }
}
```

### Errors
| Code | HTTP | Description |
|---|---|---|
| `ERR_NOT_FOUND_001` | 404 | قانون نیست یا متعلق به محیط دیگری است |

---

## POST `/api/v1/policy/{uuid}/version`

نسخهٔ تازه. بدنه همان فیلدهای `POST /policy` است (هرچه بفرستید جایگزین می‌شود، بقیه
دست‌نخورده می‌ماند). `version` یکی بالا می‌رود و متن قبلی در تاریخچه می‌ماند.

### Response `200`
```json
{
  "success": true,
  "data": {
    "uuid": "f2e97553-835d-41c9-b465-49107821d7d2",
    "effects": [{ "type": "min_duration_minutes", "value": 90 }],
    "version": 2,
    "active": true,
    "…": "بقیهٔ فیلدها مثل GET"
  }
}
```

---

## POST `/api/v1/policy/{uuid}/activate` · `/deactivate`

روشن و خاموش کردن. قانون خاموش در هیچ محاسبه‌ای شرکت نمی‌کند.

### Response `200`
همان بدنهٔ قانون با `active` به‌روزشده.

---

## اثر قوانین روی اندپوینت‌های دیگر

| اندپوینت | چه تغییری می‌بینید |
|---|---|
| `POST /api/v1/service-selection/validate` | خطای `policy_forbidden` در `errors[]` وقتی قانون `selection` انتخاب را رد کند |
| `POST /api/v1/appointment-plan/preview` | `total_minutes` با `min_duration_minutes`/`add_duration_minutes` بزرگ می‌شود؛ نقشِ `require_resource` به اولین بخشِ حضور بیمار اضافه می‌شود |
| `POST /api/v1/appointment-hold` | `422` وقتی قانون `eligibility` بیمار را رد کند، پرچم لازم نیامده باشد، یا فاصلهٔ `spacing` رعایت نشده باشد |
| `POST /api/v1/pricing/quote` | تخفیف قوانین `pricing` به تخفیف دستی **اضافه** می‌شود و در `breakdown.sources.applied_policies` با `uuid`، `name` و `version` ثبت می‌شود |


---

## آزمایشگاه قانون (تسک ۱۰)

### GET `/api/v1/policy-templates`

الگوهای آماده. کاربر الگو را انتخاب می‌کند و فقط چند مقدار پر می‌کند؛ `condition` و
`effects` سمت سرور ساخته می‌شوند و از همان اعتبارسنجی عادی رد می‌شوند.

```json
{
  "success": true,
  "data": [
    {
      "key": "vip_discount",
      "title": "تخفیف بیمار وفادار",
      "description": "بیمارانی که بیش از N ویزیت داشته‌اند، درصدی تخفیف بگیرند.",
      "category": "pricing",
      "inputs": [
        { "key": "visit_count", "type": "int", "label": "بیشتر از چند ویزیت", "min": 1, "max": 100 },
        { "key": "percent", "type": "int", "label": "درصد تخفیف", "min": 1, "max": 100 }
      ]
    }
  ]
}
```

ساخت قانون با الگو:

```json
POST /api/v1/policy
{ "name": "تخفیف مشتری وفادار", "template": "vip_discount", "values": { "visit_count": 3, "percent": 15 } }
```

الگوهای موجود: `min_days_between_sessions` · `complex_min_duration` ·
`extra_time_for_many_items` · `surgery_needs_surgeon` · `minor_needs_consent` ·
`vip_discount`.

### POST `/api/v1/policy/{uuid}/simulate`

اجرای قانون روی نوبت‌های واقعیِ گذشته، **بدون نوشتن هیچ چیز** جز خودِ نتیجه.

| Field | Type | Required | Description |
|---|---|---|---|
| `sample_size` | int | — | پیش‌فرض ۵۰، سقف ۲۰۰ |

نمونه به دامنهٔ خود قانون محدود می‌شود (شعبه/سرویس/دسته)، وگرنه «۰٪ تحت تأثیر» فقط
یعنی نمونه اشتباه بوده.

#### Response `201`
```json
{
  "success": true,
  "data": {
    "uuid": "…",
    "policy_uuid": "…",
    "policy_version": 1,
    "sample_size": 4,
    "affected_count": 3,
    "affected_percent": 75,
    "severity": "high",
    "created_at": 1785480121,
    "rows": [
      {
        "appointment_uuid": "…",
        "patient_name": "ز. احمدی",
        "slot_start": 1785000000,
        "before": "2,000,000 ریال",
        "after": "1,500,000 ریال",
        "reason": "500,000 ریال تخفیف"
      }
    ],
    "warning": null
  }
}
```

| شدت | نسبت تحت تأثیر | معنی |
|---|---|---|
| `none` | ۰٪ | **هشدار** — شرط احتمالاً هرگز برقرار نمی‌شود |
| `low` | ۱–۲۰٪ | اثر محدود |
| `medium` | ۲۱–۶۰٪ | بخش قابل‌توجه |
| `high` | > ۶۰٪ | بیشتر نوبت‌ها؛ احتمالاً اشتباه نوشته شده |

محیطی که هیچ نوبت گذشته‌ای ندارد `201` می‌گیرد با `sample_size: 0`, `severity: "none"` و
`warning: "داده‌ای برای آزمایش نیست"` — وگرنه کلینیک تازه هرگز نمی‌توانست قانونی فعال کند.

### GET `/api/v1/policy/{uuid}/simulations`

ده اجرای آخر، جدیدترین اول.

### شرط تازهٔ `activate`

`POST /api/v1/policy/{uuid}/activate` حالا یک اجرای آزمایشیِ **همین نسخه** لازم دارد:

```json
{
  "success": false,
  "data": null,
  "errors": [{
    "code": "ERR_VALIDATION_001",
    "message": "ابتدا قانون را آزمایش کنید و نتیجه را ببینید",
    "field": "simulation"
  }]
}
```

آزمایش نسخهٔ ۱ اجازهٔ فعال‌سازی نسخهٔ ۲ را نمی‌دهد.

### صفحه‌های پنل

| مسیر | صفحه |
|---|---|
| `/admin/policies` | فهرست قوانین |
| `/admin/policies/new` | ساخت با الگو یا حالت پیشرفته |
| `/admin/policies/{uuid}/simulate` | گزارش آزمایش + دکمهٔ فعال‌سازی |

## اعتبار نسخهٔ تازه

`POST /policy/{uuid}/version` مقدار `valid_from` در گذشته را **رد می‌کند** (۴۲۲). نوبت‌های
گذشته با متن قبلی حساب شده‌اند و ردپای قیمتشان به نسخه اشاره می‌کند؛ اعتبارِ عقب‌رونده
یعنی آن ارجاع قانونی را توصیف کند که آن روز وجود نداشت. روی نسخهٔ نخست آزاد است.

## فیلدِ غایب در شرط

شرطی که فیلدش در حقایق درخواست نباشد **رد** می‌شود (نه نادیده گرفته) و یک `warning` با
نام قانون و فهرست حقایق موجود لاگ می‌شود. رد کردنِ خاموش یعنی قانونی که هر بار به این خط
می‌رسد عملاً خاموش است و کسی خبردار نمی‌شود.

## رجیستری‌ها و شش موتور

| کلاس | مسئولیت |
|---|---|
| `OperatorRegistry` | یازده عملگر بند ۸، فیلترشده per نوع فیلد |
| `FieldRegistry` | فهرست فیلدها + استخراج مقدار + دسته‌های مجاز — **یک منبع** |
| `PolicyEngine` و شش زیرکلاس | `evaluate()` و `evaluateIsolated()` per دسته |
| `PolicyResolver` | حل تناقض و ترکیب اثرها — یک بار، نه شش بار |

عملگرها: `equals` · `not_equals` · `greater_than` · `greater_or_equal` · `less_than` ·
`less_or_equal` · `in` · `not_in` · `between` · `contains` · `days_since`.

`between` بازهٔ **بسته** است (`[min, max]`) چون «بین ۱۸ تا ۶۵ سال» در فارسی هر دو سر را
شامل می‌شود. `days_since` روی فیلد زمانی کار می‌کند (`last_visit_at`) و «بیش از N روز
گذشته» را می‌سنجد؛ مقدار صفر یعنی «هرگز» و شرط را رد می‌کند.

`FieldRegistry` هم schema فرم را می‌سازد و هم مقدار را استخراج می‌کند. یکی بودنشان همان
چیزی است که خطرِ ثبت‌شدهٔ تسک ۰۹ را می‌بندد: فیلدی که در فرم باشد و هیچ‌کس نسازدش،
بی‌صدا «همیشه‌رد» می‌شود.

شش موتور فقط **تایپ** می‌دهند: نقطهٔ مصرف می‌گوید «موتور قیمت» نه «resolver با رشتهٔ
pricing». منطقشان یکی است و در `PolicyResolver` می‌ماند — شش کپی یعنی شش جای شکستن.

## `specificity`

هنگام **ذخیره** حساب و ذخیره می‌شود:

| وزن | شرط |
|---|---|
| ۸ | شعبهٔ مشخص |
| ۴ | سرویس مشخص |
| ۲ | دستهٔ کاتالوگ مشخص |
| ۱ | هر شرط اضافه |

محاسبه در زمان اجرا یعنی کاری که یک بار در عمر قانون کافی بود در هر رزرو تکرار شود؛
ذخیره‌شدنش یعنی می‌شود روزی مرتب‌سازی را به SQL برد. مهاجرت `Version20260801122211`
قانون‌های موجود را با همان فرمول پر می‌کند، وگرنه همه صفر می‌ماندند و ترتیب حل تناقض
یک‌شبه عوض می‌شد.
