Files
clinicpro/docs/api/policy.md
T
hamedandClaude Opus 5 bcfa87bfad 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>
2026-07-31 10:44:28 +03:30

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 گزارش آزمایش + دکمهٔ فعال‌سازی