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>
17 KiB
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
{
"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
{
"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
{
"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
{
"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 خارج از محیط جاری |
فیلد خارج از دسته:
{
"success": false,
"data": null,
"errors": [{
"code": "ERR_VALIDATION_001",
"message": "فیلد «subtotal_rials» برای دستهٔ «timing» مجاز نیست. مجازها: catalog_category، service_uuid، item_count، patient_age",
"field": "condition"
}]
}
اثر خارج از دسته:
{
"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
{
"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
{
"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 سمت سرور ساخته میشوند و از همان اعتبارسنجی عادی رد میشوند.
{
"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 }
]
}
]
}
ساخت قانون با الگو:
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
{
"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 حالا یک اجرای آزمایشیِ همین نسخه لازم دارد:
{
"success": false,
"data": null,
"errors": [{
"code": "ERR_VALIDATION_001",
"message": "ابتدا قانون را آزمایش کنید و نتیجه را ببینید",
"field": "simulation"
}]
}
آزمایش نسخهٔ ۱ اجازهٔ فعالسازی نسخهٔ ۲ را نمیدهد.
صفحههای پنل
| مسیر | صفحه |
|---|---|
/admin/policies |
فهرست قوانین |
/admin/policies/new |
ساخت با الگو یا حالت پیشرفته |
/admin/policies/{uuid}/simulate |
گزارش آزمایش + دکمهٔ فعالسازی |