# 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` ### Response `200` ```json { "success": true, "data": { "selection": { "fields": ["item_count", "item_uuids", "catalog_category"], "operators": ["equals", "not_equals", "greater_than", "less_than", "in", "contains"], "effects": [{ "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 | |---|---|---|---| | `category` | string | ✅ | یکی از شش دسته | | `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` ثبت می‌شود |