Files
clinicpro/docs/api/policy.md
T
hamedandClaude Opus 5 584ea4067f feat(policy): six-category policy engine wired into the booking flow
Rules become data instead of code: a clinic can say "laser under 18 requires
parental consent" without a deploy.

Engine
- Policy / PolicyVersionLog entities, closed field/operator/effect lists per
  category (PolicySchema), condition validation at write time
- PolicyResolver: priority -> specificity -> age, combining effects by
  veto / max / sum / union
- A missing fact fails its clause instead of silently passing it
- Policies are drafts until activated, and are versioned rather than edited

Wiring
- selection -> ServiceSelectionValidator
- eligibility + spacing -> BookingPolicyGuard, at hold time not confirm time
- resource + timing -> AppointmentPlanBuilder, including template-less services
- pricing -> PricingEngine, alongside (not replacing) the manual discount

The condition column is named condition_json: `condition` is a MariaDB keyword
and broke every INSERT.

Tests: 17 in tests/Policy including NoPolicyRegressionTest, which pins that a
clinic with no policies sees byte-identical output to task 08.
Docs: docs/api/policy.md (real captured JSON) + docs/architecture/policy-engine.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:19:19 +03:30

12 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

Response 200

{
  "success": true,
  "data": {
    "selection": {
      "fields": ["item_count", "item_uuids", "catalog_category"],
      "operators": ["equals", "not_equals", "greater_than", "less_than", "in", "contains"],
      "effects": [{ "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
category string یکی از شش دسته
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 ثبت می‌شود