Task 09 shipped a powerful API that a non-technical clinic owner could not safely use. This closes that gap: activation now requires having seen what the rule actually does. - PolicySimulator runs a policy against real past appointments and writes nothing: evaluation works on facts (never entities), the whole run sits in a transaction rolled back and cleared in `finally`, and a test counts rows in five sensitive tables before and after - activate() now demands a simulation of the *same version* — a report for version 1 does not unlock version 2 - PolicyTemplateRegistry: six ready-made rules, so the common case never touches a raw condition - Severity from the affected ratio; 0% is a warning too, since a rule that changes nothing usually has a condition that never matches - An empty clinic still succeeds with a warning, otherwise a new clinic could never activate anything Admin: PoliciesPage, PolicyFormPage, PolicySimulationPage, and a PolicyConditionBuilder built entirely from GET /policy-schema — a test proves a field that exists only in the schema shows up with no frontend change, and that operators are filtered per field type. The schema response now carries per-field metadata (label, type, meaningful operators) so the form has one source of truth instead of two. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
412 lines
17 KiB
Markdown
412 lines
17 KiB
Markdown
# 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` | گزارش آزمایش + دکمهٔ فعالسازی |
|