PUT /service-item/{uuid}/segments deletes and rewrites. deleteForService issues
a DQL DELETE that runs immediately, and three validations — duration, occupancy
and constraints — only ran afterwards, while building the new rows. A rejected
request therefore deleted the service's segments and saved nothing, and the
service silently fell back to "one continuous block": different duration,
different resources, on every future appointment, with a 422 as the only clue.
Validation now happens before the delete, and the delete plus rewrite are one
transaction. A test pins it: an unknown constraint is refused and the previous
two segments are still there afterwards.
While in there, the caps the task asked for and never got: 20 segments and 10
requirements per segment. The availability engine evaluates resource
combinations per segment per requirement, so the numbers protect the search
rather than the table. They are generous — no real service reaches them, but a
bad payload does.
The plan response now carries patient_facing_minutes. "Set aside 90 minutes"
is wrong for an appointment where 40 of them are waiting for anaesthetic to
take effect, and computing it once in the backend stops each client summing it
differently.
A condition on a fact the request never supplies still evaluates to false —
that part was right — but it now logs a warning naming the policy and listing
the facts that were available. A rule that hits that line every time is
effectively switched off, and nothing said so.
A new policy version can no longer start in the past: yesterday's appointments
were priced under the previous text, and their price trace points at the
version. Backdating makes that trace describe a rule that did not exist.
require_resource errors name the policy that demanded the role. Knowing a room
is missing does not tell an operator which of ten active rules to look at.
Six operators now have a test each. An operator that compares wrongly produces
a rule that always matches or never does, and neither raises anything.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
18 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 با
نام قانون و فهرست حقایق موجود لاگ میشود. رد کردنِ خاموش یعنی قانونی که هر بار به این خط
میرسد عملاً خاموش است و کسی خبردار نمیشود.