Files
clinicpro/docs/api/policy.md
T
hamedandClaude Opus 5 fe48b10fb5 fix(plan): stop the segment replace from destroying segments when it rejects
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>
2026-08-01 15:01:38 +03:30

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 با نام قانون و فهرست حقایق موجود لاگ می‌شود. رد کردنِ خاموش یعنی قانونی که هر بار به این خط می‌رسد عملاً خاموش است و کسی خبردار نمی‌شود.