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:
hamed
2026-07-31 10:44:28 +03:30
co-authored by Claude Opus 5
parent 56bd1b474a
commit bcfa87bfad
27 changed files with 2939 additions and 70 deletions
+136 -2
View File
@@ -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` | گزارش آزمایش + دکمهٔ فعال‌سازی |