Files
clinicpro/docs/new_feture/taskes/task-09-policy-engine/implementation_notes.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.3 KiB
Raw Blame History

نکات پیاده‌سازی — تسک ۰۹

۱. چرا DiscountRule مهاجرت نمی‌کند

وسوسه‌اش زیاد است: DiscountRule عملاً یک Policy دستهٔ pricing است. ولی:

  • DiscountEngine روی پرونده (PatientRecord) و مراجعه هم اجرا می‌شود، نه فقط نوبت
  • DiscountsPage.tsx و docs/api/discount.md و تست‌های موجود روی همان قرارداد‌ند
  • شش نوع تخفیفش (patient_tag, occasion, visit_count, …) دقیقاً همان شرط‌های FieldRegistry نیستند و نگاشت یک‌به‌یک ندارند

هزینهٔ مهاجرت بالا و سودش صفر است. PricingPolicyEngine هر دو را می‌خواند و Combiner نتیجه‌شان را یکجا ترکیب می‌کند. ولی یک قاعده لازم است:

تخفیف جدید در DiscountRule ساخته می‌شود اگر روی مراجعه هم کار می‌کند؛ در Policy اگر فقط قیمت نوبت را عوض می‌کند. این را در docs/api/policy.md بنویس.

۲. FieldRegistry تنها منبع حقیقت

final class FieldRegistry
{
    private const FIELDS = [
        'patient.age' => ['type' => 'int',    'ops' => ['eq','gt','gte','lt','lte','between']],
        'patient.tags'=> ['type' => 'array',  'ops' => ['in','not_in','contains']],
        // …
    ];

    public function schema(): array;                                  // برای GET /policy-schema
    public function extract(string $field, PolicyContext $ctx): mixed; // برای ارزیابی
    public function assertValid(string $field, string $op): void;      // برای ذخیره
}

سه مسئولیت روی یک آرایه. اگر schema و extract جدا باشند، فیلدی در فرم ظاهر می‌شود که ارزیابی نمی‌شود — و قانونی که همیشه false است، بدترین باگ این سیستم است چون خطا نمی‌دهد.

۳. قانون خاموش هرگز نباید بی‌صدا false باشد

// ❌
$value = $ctx->get($field) ?? null;
if ($value === null) return false;    // قانون بی‌صدا رد می‌شود

// ✅
if (!$this->registry->has($field)) {
    throw new \LogicException("فیلد ناشناخته در قانون: {$field}");   // نباید ممکن باشد؛ ذخیره جلویش را گرفته
}
$value = $this->registry->extract($field, $ctx);
if ($value === self::UNAVAILABLE) {
    $this->logger->warning('policy_field_unavailable', ['policy' => $id, 'field' => $field]);
    return false;   // با لاگ، نه سکوت
}

مثال واقعی: قانون روی patient.last_session_at برای بیمار جدید. مقدار وجود ندارد و قانون باید رد شود — ولی با لاگ، تا اگر کلینیک گفت «قانونم کار نمی‌کند» جواب داشته باشیم.

۴. spacing — کوئری، نه حلقه

بدترین اشتباه ممکن در این تسک:

// ❌ فاجعهٔ کارایی — ۹۰ روز × ده‌ها اسلات × یک کوئری
foreach ($slots as $slot) {
    $last = $this->appointmentRepo->findLastSession($patient, $service);
    if ($slot['start'] - $last < $minDays * 86400) continue;
}
// ✅ یک کوئری، بعد بازهٔ ممنوعه
$last = $this->appointmentRepo->findLastCompletedAt($patient, $service);   // ۱ کوئری
if ($last !== null) {
    $forbidden[] = ['start' => $last, 'end' => $last + $minDays * 86400];
}
// بازه به CandidateGenerator داده می‌شود → آن نقطه‌ها ساخته نمی‌شوند

AvailabilityPerformanceTest تسک ۰۶ باید با قوانین فعال هم سبز بماند. اگر بعد از این تسک قرمز شد، دلیلش همین است.

۵. ترتیب اعمال تخفیف — پشت‌سرهم

$remaining = $subtotal;
foreach ($sortedPolicies as $policy) {     // به ترتیب اولویت
    $amount = intdiv($remaining * $policy->percent(), 100);
    $remaining -= $amount;
    $lines[] = PriceLine::discount($policy->getName(), -$amount);
}

نه جمع درصدها. ۴۰٪ سپس ۱۰٪ = ۴۶٪ کل، نه ۵۰٪. مستند بند ۸ صریح: «به ترتیب اولویت پشت سر هم».

۶. combinable و short-circuit

foreach ($sorted as $policy) {
    if (!$this->conditions->matches($policy, $ctx)) continue;
    $applied[] = $policy;
    if (!$policy->isCombinable()) break;    // ← اولین غیرترکیب‌شدنی، پایان
}

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

اثر deny استثناست: همیشه short-circuit، مستقل از combinable.

۷. نسخه‌بندی — ویرایش ممنوع

// PolicyController: PATCH وجود ندارد. فقط:
POST /policy/{uuid}/version

PATCH روی محتوای قانون عمداً نیست. تنها چیزهایی که بدون نسخهٔ جدید تغییر می‌کنند: active، name. شرط و اثر و اولویت → نسخهٔ جدید.

اگر کاربر گفت «فقط می‌خواهم غلط املایی نام را درست کنم» — name مجاز است. هر چیزی که روی محاسبه اثر دارد، نه.

۸. edge case ها

حالت رفتار درست
هیچ قانونی وجود ندارد همه‌چیز مثل قبل — تست سازگاری اجباری
قانون فعال با valid_from آینده اعمال نمی‌شود
دو قانون deny یکی کافی است؛ پیام اولی (بالاترین اولویت) نمایش داده می‌شود
قانون add_requirement که هیچ منبع واجد شرایطی ندارد NoEligibleResourceException با پیام شامل نام قانون: «قانون X جراح می‌خواهد ولی جراحی در این شعبه نیست»
قانون min_duration کمتر از مدت فعلی بی‌اثر (max)
قانون spacing برای بیمار مهمان بدون سابقه رد نمی‌کند، اعمال نمی‌شود
قانون روی booking.channel = online و ثبت از پنل اعمال نمی‌شود
conditions خالی ({}) همیشه true — مجاز، ولی در UI هشدار «این قانون روی همهٔ نوبت‌ها اعمال می‌شود»
قانون دستهٔ resource با اثر discount_percent 422 هنگام ذخیره
نسخهٔ جدید با valid_from گذشته 422 — نسخه گذشته را عوض نمی‌کند
حذف قانونی که در applied_policy_ids نوبت‌هاست مجاز — name کپی شده و فاکتور سالم است

سطر ماقبل آخر مهم است: valid_from گذشته یعنی بازنویسی تاریخ، که قانون پنجم مستند را نقض می‌کند.

۹. تست

tests/Policy/ConditionEvaluatorTest.php          ← واحد، بدون DB
  - همهٔ عملگرها روی همهٔ نوع‌ها
  - all/any
  - فیلد ناموجود → false با لاگ
tests/Policy/CombinerTest.php                    ← واحد
  - min_duration: max برنده
  - add_duration: جمع
  - add_requirement: union بدون تکرار
  - restrict_requirement: اشتراک
  - discount: پشت‌سرهم (۴۰ سپس ۱۰ → ۴۶ کل)
  - deny: یکی کافی
tests/Policy/PolicyResolverTest.php
  - اولویت > اختصاصی‌بودن > قدمت (سه سناریوی جدا)
  - combinable=false short-circuit
tests/Policy/SpacingPolicyEngineTest.php
  - بازهٔ ممنوعه درست
  - بیمار بدون سابقه → بی‌اثر
  - تعداد کوئری ثابت (نه per slot)
tests/Policy/PolicyVersioningTest.php            ← ⭐ قانون پنجم
  - نوبت با نسخهٔ ۱ ثبت شد → نسخهٔ ۲ ساخته شد → فاکتور نوبت تغییر نکرد
  - policy_version_log snapshot کامل دارد
  - valid_from گذشته → 422
tests/Policy/PolicyIntegrationTest.php
  - resource: نیازمندی اضافه در preview
  - timing: مدت افزایش می‌یابد
  - eligibility: confirm رد می‌شود با پیام فارسی
  - pricing: ردیف تخفیف در snapshot
tests/Policy/PolicySchemaTest.php
  - هر فیلد schema قابل extract است (نه فیلد نمایشی بی‌ارزیابی)
  - اثر خارج از دسته → 422
tests/Policy/NoPolicyRegressionTest.php          ← ⭐
  - بدون هیچ قانون: خروجی preview/availability/quote بیت‌به‌بیت مثل تسک ۰۸
tests/Appointment/AvailabilityPerformanceTest.php ← باید با قوانین فعال هم سبز باشد

۱۰. مستندات

  • docs/api/policy.md — endpoint ها + فهرست کامل فیلد/عملگر/اثر + قاعدهٔ «DiscountRule یا Policy؟»
  • docs/architecture/policy-engine.md — جدول حل تناقض، جدول ترکیب، دلیل ممنوعیت کد دلخواه، و دلیل عدم مهاجرت DiscountRule