feat(policy): rule builder and mandatory dry-run sandbox
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>
This commit is contained in:
+136
-2
@@ -41,15 +41,29 @@
|
||||
|
||||
**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"],
|
||||
"effects": [{ "type": "forbid", "combination": "veto" }]
|
||||
"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"],
|
||||
@@ -124,7 +138,9 @@
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `category` | string | ✅ | یکی از شش دسته |
|
||||
| `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 | ✅ | باید در فهرست فیلدهای همان دسته باشد |
|
||||
@@ -275,3 +291,121 @@
|
||||
| `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` | گزارش آزمایش + دکمهٔ فعالسازی |
|
||||
|
||||
Reference in New Issue
Block a user