The task 09 architecture specified FieldRegistry, OperatorRegistry, six engine classes and a stored specificity. What shipped was a single PolicySchema constant list, six operators, one resolver and a specificity recomputed on every booking. Each shortcut was defensible on its own; together they left the starred risk the task itself recorded — a field can be advertised in the form and supplied by nobody, and the rule silently never matches. OperatorRegistry now holds all eleven operators. The five that were missing are real capability, not ceremony: greater_or_equal and less_or_equal make boundary rules expressible without off-by-one, not_in is the natural way to write an exclusion, between stops "18 to 65" needing two clauses, and days_since is the documented operator for "more than N days since" — until now every caller computed that by hand. between is inclusive at both ends because that is what the Persian phrasing means and what the user will type. FieldRegistry is now the single source: it builds the form schema and extracts the value, so a field that exists in one and not the other is impossible. It also declares which categories each field belongs to, which is what the closed list per category used to do separately. Adding it immediately caught its own first case — last_visit_at was advertised and supplied nowhere, so the guard now populates it and days_since has something to read. The six engines are thin on purpose. They give the call site a type — "the pricing engine" rather than "the resolver with the string pricing" — and a place for evaluateIsolated, which the sandbox needs to answer "what would this one rule do". Conflict resolution and effect combination stay in PolicyResolver: six copies of that would be six places to break. specificity is a stored column now, computed on save with the documented weights, and the migration backfills existing rows with the same formula. Left at zero they would all have tied and the ordering would have changed overnight. Field names stay as they are rather than moving to the document's dotted names (patient.age). Stored condition_json rows point at the current names on live clinic policies; renaming them is a data migration, and the mapping is not one-to-one — implementation_notes.md says as much. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
20 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 |
گزارش آزمایش + دکمهٔ فعالسازی |
اعتبار نسخهٔ تازه
POST /policy/{uuid}/version مقدار valid_from در گذشته را رد میکند (۴۲۲). نوبتهای
گذشته با متن قبلی حساب شدهاند و ردپای قیمتشان به نسخه اشاره میکند؛ اعتبارِ عقبرونده
یعنی آن ارجاع قانونی را توصیف کند که آن روز وجود نداشت. روی نسخهٔ نخست آزاد است.
فیلدِ غایب در شرط
شرطی که فیلدش در حقایق درخواست نباشد رد میشود (نه نادیده گرفته) و یک warning با
نام قانون و فهرست حقایق موجود لاگ میشود. رد کردنِ خاموش یعنی قانونی که هر بار به این خط
میرسد عملاً خاموش است و کسی خبردار نمیشود.
رجیستریها و شش موتور
| کلاس | مسئولیت |
|---|---|
OperatorRegistry |
یازده عملگر بند ۸، فیلترشده per نوع فیلد |
FieldRegistry |
فهرست فیلدها + استخراج مقدار + دستههای مجاز — یک منبع |
PolicyEngine و شش زیرکلاس |
evaluate() و evaluateIsolated() per دسته |
PolicyResolver |
حل تناقض و ترکیب اثرها — یک بار، نه شش بار |
عملگرها: equals · not_equals · greater_than · greater_or_equal · less_than ·
less_or_equal · in · not_in · between · contains · days_since.
between بازهٔ بسته است ([min, max]) چون «بین ۱۸ تا ۶۵ سال» در فارسی هر دو سر را
شامل میشود. days_since روی فیلد زمانی کار میکند (last_visit_at) و «بیش از N روز
گذشته» را میسنجد؛ مقدار صفر یعنی «هرگز» و شرط را رد میکند.
FieldRegistry هم schema فرم را میسازد و هم مقدار را استخراج میکند. یکی بودنشان همان
چیزی است که خطرِ ثبتشدهٔ تسک ۰۹ را میبندد: فیلدی که در فرم باشد و هیچکس نسازدش،
بیصدا «همیشهرد» میشود.
شش موتور فقط تایپ میدهند: نقطهٔ مصرف میگوید «موتور قیمت» نه «resolver با رشتهٔ
pricing». منطقشان یکی است و در PolicyResolver میماند — شش کپی یعنی شش جای شکستن.
specificity
هنگام ذخیره حساب و ذخیره میشود:
| وزن | شرط |
|---|---|
| ۸ | شعبهٔ مشخص |
| ۴ | سرویس مشخص |
| ۲ | دستهٔ کاتالوگ مشخص |
| ۱ | هر شرط اضافه |
محاسبه در زمان اجرا یعنی کاری که یک بار در عمر قانون کافی بود در هر رزرو تکرار شود؛
ذخیرهشدنش یعنی میشود روزی مرتبسازی را به SQL برد. مهاجرت Version20260801122211
قانونهای موجود را با همان فرمول پر میکند، وگرنه همه صفر میماندند و ترتیب حل تناقض
یکشبه عوض میشد.