Rules become data instead of code: a clinic can say "laser under 18 requires parental consent" without a deploy. Engine - Policy / PolicyVersionLog entities, closed field/operator/effect lists per category (PolicySchema), condition validation at write time - PolicyResolver: priority -> specificity -> age, combining effects by veto / max / sum / union - A missing fact fails its clause instead of silently passing it - Policies are drafts until activated, and are versioned rather than edited Wiring - selection -> ServiceSelectionValidator - eligibility + spacing -> BookingPolicyGuard, at hold time not confirm time - resource + timing -> AppointmentPlanBuilder, including template-less services - pricing -> PricingEngine, alongside (not replacing) the manual discount The condition column is named condition_json: `condition` is a MariaDB keyword and broke every INSERT. Tests: 17 in tests/Policy including NoPolicyRegressionTest, which pins that a clinic with no policies sees byte-identical output to task 08. Docs: docs/api/policy.md (real captured JSON) + docs/architecture/policy-engine.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
12 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
Response 200
{
"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
{
"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 |
|---|---|---|---|
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
{
"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 ثبت میشود |