Files
clinicpro/docs/api/policy.md
T
hamedandClaude Opus 5 f2600f9922 refactor(policy): build the registries and six engines the architecture asked for
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>
2026-08-01 15:56:50 +03:30

20 KiB
Raw Blame History

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 قانون‌های موجود را با همان فرمول پر می‌کند، وگرنه همه صفر می‌ماندند و ترتیب حل تناقض یک‌شبه عوض می‌شد.