Files
clinicpro/docs/new_feture/taskes/task-09-policy-engine/architecture.md
T
hamed 021d0eb6b2 feat: implement cancellation policy, no-show tracking, and waitlist management
- Add implementation notes for cancellation and waitlist features.
- Create task documentation outlining goals, current status, and acceptance criteria for cancellation policy and resource utilization reporting.
- Establish architecture for domain events and outbox pattern to ensure reliable event publishing.
- Define database schema for domain events and necessary queries for resource utilization and plan accuracy reports.
- Implement detailed implementation notes covering edge cases, testing strategies, and documentation requirements.
2026-07-30 11:43:58 +03:30

9.0 KiB
Raw Blame History

معماری — تسک ۰۹

ساختار فایل

src/Policy/
├── Entity/
│   ├── Policy.php
│   └── PolicyVersionLog.php
├── Condition/
│   ├── ConditionEvaluator.php      # ارزیابی شرط
│   ├── FieldRegistry.php           # فهرست بستهٔ فیلدها
│   ├── OperatorRegistry.php        # فهرست بستهٔ عملگرها
│   └── PolicyContext.php           # داده‌های در دسترس قانون
├── Effect/
│   ├── EffectRegistry.php
│   └── Combiner.php                # جدول ترکیب اثرها (مستند بند ۸)
├── Engine/
│   ├── PolicyResolver.php          # انتخاب قوانین مرتبط + حل تناقض
│   ├── SelectionPolicyEngine.php
│   ├── EligibilityPolicyEngine.php
│   ├── ResourcePolicyEngine.php
│   ├── TimingPolicyEngine.php
│   ├── SpacingPolicyEngine.php     # ← تنها موتوری که SQL تولید می‌کند
│   └── PricingPolicyEngine.php
├── Controller/PolicyController.php
└── Repository/PolicyRepository.php

شش موتور جدا، نه یک PolicyEngine بزرگ. هر کدام ورودی و خروجی نوع‌دار خودش را دارد و در نقطهٔ متفاوتی از زنجیره صدا زده می‌شود — ادغامشان یعنی یک کلاس با شش دلیل تغییر.

Policy

class Policy
{
    use TenantOwnedTrait;

    public const CATEGORIES = ['selection','eligibility','resource','timing','spacing','pricing'];

    private string $name;
    private string $category;
    private ?Branch $branch = null;         // null = همهٔ شعب محیط
    private int    $priority = 0;
    private int    $version = 1;
    private ?int   $validFrom = null;
    private ?int   $validTo = null;
    private bool   $active = false;         // ← پیش‌فرض غیرفعال، تا آزمایش شود
    private array  $conditions = [];        // JSON — فهرست بسته
    private array  $effects = [];           // JSON — فهرست بسته
    private int    $specificity = 0;        // محاسبه‌شده، برای حل تناقض
}

active = false پیش‌فرض عمدی است — مستند بند ۸: «قبل از اینکه یک قانون واقعاً فعال شود، باید بشود آن را روی داده واقعی اجرا کرد». تسک ۱۰ همین را می‌سازد.

شرط — فهرست بسته

{
  "all": [
    { "field": "service.category_path", "op": "contains", "value": "جراحی" },
    { "field": "patient.age",           "op": "gte",      "value": 18 }
  ],
  "any": [
    { "field": "patient.tags", "op": "in", "value": ["vip", "gold"] }
  ]
}

فقط all و any در یک سطح. تودرتویی ممنوع — مستند بند ۸: قانون باید قابل تبدیل به کوئری باشد و تودرتویی دلخواه یعنی همان کد دلخواهی که ممنوع شده.

FieldRegistry — فهرست کامل

دامنه فیلدها
service id, category_path, tags, session_count
options ids, group_ids, count
patient age, gender, tags, last_session_at, completed_sessions_count
booking at, day_of_week, branch_id, channel (online|panel|phone)
plan total_minutes, total_price_rials

هر فیلد جدید باید صریحاً اینجا اضافه شود. FieldRegistry هم schema را برای GET /policy-schema تولید می‌کند و هم استخراج مقدار از PolicyContext را انجام می‌دهد — یک منبع حقیقت، پس فیلدی که در فرم هست و در ارزیابی نیست، ممکن نمی‌شود.

OperatorRegistry

eq, neq, gt, gte, lt, lte, in, not_in, between, contains, days_since

days_since عملگر ویژهٔ مستند است («تعداد روز گذشته») و روی فیلدهای زمانی کار می‌کند.

اثر — فهرست بسته per دسته

دسته اثرهای مجاز
selection require_option, forbid_option, auto_add_option
eligibility deny (با پیام), require_flag
resource add_requirement, restrict_requirement
timing min_duration, add_duration
spacing min_days_since_last, max_days_since_last, deny_weekday
pricing discount_percent, discount_fixed, surcharge_percent, surcharge_fixed

اثر خارج از دستهٔ خودش → 422 هنگام ذخیره. این جدول عیناً در GET /policy-schema می‌آید.

حل تناقض — PolicyResolver

usort($policies, function (Policy $a, Policy $b) {
    return [$b->getPriority(), $b->getSpecificity(), $a->getCreatedAt()]
       <=> [$a->getPriority(), $a->getSpecificity(), $b->getCreatedAt()];
});

سه معیار به ترتیب مستند: اولویت بالاتر → اختصاصی‌تر → قدیمی‌تر.

specificity هنگام ذخیره محاسبه و ذخیره می‌شود (نه در زمان اجرا):

+8  branch مشخص
+4  service مشخص در شرط
+2  service_category مشخص در شرط
+1  هر شرط اضافه

ترکیب اثرها — Combiner (جدول مستند بند ۸)

نوع اثر قاعده پیاده‌سازی
min_duration بیشترین برنده max()
add_duration جمع array_sum()
add_requirement همه، تکراری حذف union روی groupKey
restrict_requirement اشتراک محدودیت‌ها array_intersect روی کاندیدها
discount_percent پشت‌سرهم به ترتیب اولویت، با سقف حلقهٔ ضربی + min($total, $cap)
deny / forbid_option یکی کافی است short-circuit

Combiner یک کلاس خالص و بدون I/O — تست واحد کامل بدون دیتابیس.

SpacingPolicyEngine — تنها موتور SQL-ساز

مستند بند ۸: «قوانین مربوط به فاصله زمانی باید قابل تبدیل به کوئری دیتابیس باشند، وگرنه جستجوی وقت آزاد کند می‌شود».

/** به‌جای فیلتر کردن ۹۰ روز اسلات در PHP، یک بازهٔ ممنوعه برمی‌گرداند. */
public function forbiddenRanges(PolicyContext $ctx): array
{
    // قانون: min_days_since_last = 21
    // آخرین جلسهٔ بیمار برای این سرویس: 1405/05/01
    // → بازهٔ ممنوعه: [آخرین جلسه, آخرین جلسه + 21 روز)
    // یک کوئری برای «آخرین جلسه»، بعد محاسبهٔ بازه در PHP
}

AvailabilityEngine این بازه‌ها را پیش از تولید کاندیدها به CandidateGenerator می‌دهد تا آن نقطه‌ها هرگز ساخته نشوند — نه اینکه بعد فیلتر شوند.

نسخه‌بندی

POST /policy/{uuid}/version  { valid_from: …, conditions: …, effects: … }
    │
    ├─ نسخهٔ فعلی: valid_to = new.valid_from - 1
    ├─ ردیف جدید در policy_version_log با snapshot کامل نسخهٔ قبلی
    └─ policy.version++ و مقادیر جدید روی همان ردیف

قانون ویرایش نمی‌شود — هر تغییر نسخهٔ جدید با تاریخ شروع می‌سازد. نوبت‌ها {policy_id, version} را ذخیره می‌کنند (applied_policy_ids تسک ۰۸)، پس فاکتور دیروز با تغییر امروز خراب نمی‌شود.

policy_version_log snapshot کامل نگه می‌دارد نه diff: بازسازی نسخهٔ قدیم باید یک SELECT باشد، نه اعمال زنجیرهٔ diff.

نقاط اتصال — همه از قبل آماده‌اند

// تسک ۰۵ — AppointmentPlanBuilder::build() مرحلهٔ ۷
return $this->policies->applyToPlan($withResources);
//              ↑ ResourcePolicyEngine + TimingPolicyEngine

// تسک ۰۶ — AvailabilityEngine::search() مرحلهٔ ۶
return $this->policies->filterSlots($result, $req);
//              ↑ SpacingPolicyEngine (به‌صورت forbiddenRanges، پیش از تولید کاندید)

// تسک ۰۷ — BookingService::confirm() مرحلهٔ ۳
$this->policies->assertEligibility($appointment);
//              ↑ EligibilityPolicyEngine

// تسک ۰۸ — PricingEngine::quote() مرحلهٔ ۳
$lines = $this->discounts->apply($lines, $req);
//              ↑ PricingPolicyEngine + DiscountEngine موجود

// تسک ۰۴ — ServiceSelectionValidator::validate()
$errors = array_merge($errors, $this->policies->validateSelection($ctx, ));
//              ↑ SelectionPolicyEngine

هیچ امضایی عوض نمی‌شود — این دقیقاً دلیلی است که آن قلاب‌ها از روز اول گذاشته شدند.