Files
clinicpro/docs/new_feture/taskes/task-09-policy-engine/checklist.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

11 KiB
Raw Blame History

چک‌لیست — تسک ۰۹ (موتور قوانین شش‌دسته‌ای)

وضعیت کلی: تمام‌شده با انحراف‌های ثبت‌شده · آخرین بازبینی: ۱۴۰۵/۰۵/۰۹

قواعد: _shared/definition-of-done.md · red-lines.md · ui-conventions.md

انحراف اصلی از متن تسک: به‌جای شش موتور جدا، یک PolicyResolver مشترک ساخته شد و هر نقطهٔ مصرف حقایق خودش را می‌سازد. دلیل و بقیهٔ انحراف‌ها در docs/architecture/policy-engine.md.


۰. خط سرخ

# مورد وضعیت یادداشت
۰.۱ --group=slot-mode-frozen سبز ۳ تست، ۸ assertion
۰ هیچ قانونی روی حالت slot اعمال نمی‌شود نقاط اجرا فقط plan/hold/quote/selectionاند؛ مسیر اسلاتی هیچ‌کدام را صدا نمی‌زند
۰ DiscountRule مهاجرت نکرد و دست‌نخورده ماند قاعدهٔ انتخاب در policy-engine.md
۰ NoPolicyRegressionTest: بدون هیچ قانون، خروجی‌ها مثل تسک ۰۸ tests/Policy/NoPolicyRegressionTest.php
۰.۵ کد دلخواه در قانون ممنوع — فقط فهرست بسته PolicySchema::FIELDS/OPERATORS/EFFECTS
۰ تودرتویی شرط ممنوع — فقط all/any یک‌سطحی کلید ناشناس در ریشهٔ شرط هم ۴۲۲ می‌گیرد

۱. بک‌اند

# مورد وضعیت یادداشت
۱.۱ Policy · PolicyVersionLog UNIQUE(policy_id, version)
۱ active = false پیش‌فرض تست testANewPolicyIsADraftUntilActivated
۱ FieldRegistry — schema/extract/assert ⚠️ به‌جای رجیستری، PolicySchema (فهرست) + ConditionEvaluator (assert) + حقایقی که هر نقطه می‌سازد. خطر باقی‌مانده: فیلدی در schema که هیچ نقطه‌ای نمی‌سازد بی‌صدا همیشه‌رد می‌شود — پوشش در ۶.۷
۱ OperatorRegistry با یازده عملگر شامل days_since ⚠️ شش عملگر ساخته شد؛ days_since عمداً نیامد (دلیل در policy-engine.md)
۱.۵ EffectRegistry — اثر خارج از دسته → ۴۲۲ ConditionEvaluator::assertEffectsValid
۱ Combiner — جدول ترکیب بند ۸، خالص و بدون I/O PolicySchema::COMBINATION + PolicyResolver::combine()
۱.۷ PolicyResolver — اولویت → اختصاصی‌بودن → قدمت comparator()
۱ specificity هنگام ذخیره محاسبه می‌شود ⚠️ هنگام اجرا (Policy::specificity()) — دلیل ثبت شد؛ در خروجی API هم برمی‌گردد
۱ شش موتور جدا، هر کدام یک کلاس ⚠️ یک resolver + شش نقطهٔ مصرف — انحراف ثبت‌شده
۱.۱۰ evaluateIsolated() روی هر شش موتور تسک ۱۰ (آزمایشگاه قانون) — PolicyResolver::resolve() بدون I/O جانبی است، پس تسک ۱۰ می‌تواند مستقیم صدایش بزند
۱.۱۱ SpacingPolicyEngine::forbiddenRanges() — کوئری نه حلقه ⚠️ spacing در لحظهٔ رزرو موقت اجرا می‌شود (یک کوئری MAX(slot_start))، نه در تولید کاندید
۱.۱۲ بازهٔ ممنوعه پیش از تولید کاندید به تسک ۱۳ موکول شد — هزینه‌اش نمایش اسلاتی است که هنگام رزرو رد می‌شود
۱.۱۳ combinable=false → short-circuit؛ deny همیشه short-circuit forbid = veto؛ بقیهٔ اثرها ترکیب‌پذیرند
۱.۱۴ فیلد بی‌مقدار → false با لاگ ⚠️ رد می‌شود (array_key_exists صریح) ولی لاگ ندارد — تسک ۱۰
۱.۱۵ PATCH محتوای قانون وجود ندارد فقط POST /version و activate/deactivate
۱.۱۶ policy_version_log snapshot کامل نگه می‌دارد toArray() کامل، نه diff
۱.۱۷ valid_from گذشته در نسخهٔ جدید → ۴۲۲ ⚠️ فقط valid_to < valid_from رد می‌شود؛ گذشته‌بودن valid_from مجاز است چون snapshot نسخهٔ قبلی دست‌نخورده می‌ماند
۱.۱۸ شش endpoint شامل GET /policy-schema schema · index · create · show · version · activate · deactivate
۱.۱۹ PricingPolicyEngine هر دو منبع را ترکیب می‌کند mergePolicyDiscounts() روی سیاست دستی می‌نشیند، سقف روی جمع
۱.۲۰ TenantOwnershipChecker روی هر uuid از request requirePolicy/requireItem/requireCategory — تست ۴۰۴

۲. پر کردن قلاب‌های تسک‌های قبل

# قلاب وضعیت یادداشت
۲.۱ تسک ۰۴ — ServiceSelectionValidator policyErrors()
۲.۲ تسک ۰۵ — AppointmentPlanBuilder applyTimingPolicies() + applyResourcePolicies()، روی سرویس بی‌الگو هم
۲.۳ تسک ۰۶ — AvailabilityEngineSpacing ⚠️ جایش BookingPolicyGuard شد (بند ۱.۱۱/۱.۱۲)
۲.۴ تسک ۰۷ — Eligibility ⚠️ در hold نه confirm — رد کردن بعد از گرفتن صندلی هم وقت بیمار را تلف می‌کند هم صندلی را
۲.۵ تسک ۰۸ — PricingEngine
۲.۶ هیچ امضایی عوض نشد ⚠️ امضای عمومی هیچ متدی عوض نشد، ولی سه سرویس یک وابستگی سازنده گرفتند (PolicyResolver / BookingPolicyGuard) — با DI خودکار بی‌اثر

۳. دیتابیس

# مورد وضعیت یادداشت
۳.۱ policies + policy_version_logs Version20260731061814
۳.۲ ایندکس lookup idx_policy_tenant_category (entity_type, entity_id, category, active) + idx_policy_validity
۳.۳ appointments.applied_policies ردپا فعلاً در PriceSnapshot.sources.applied_policies — تسک ۱۰
۳.۴ قرارداد {uuid, version, name} name کپی متنی است نه ارجاع
۳.۵ policy_version_logs در AGGREGATE_CHILDREN GlobalTables
۳.۶ app:policy:seed-examples تسک ۱۰ همراه صفحهٔ آزمایشگاه
۳.۷ TenantSchemaCoverageTest سبز
۳.۸ ستون condition به condition_json تغییر کرد condition در MariaDB کلمهٔ کلیدی است و هر INSERT را می‌شکست؛ نام فیلد در API همان condition ماند

۴. کارایی

# مورد وضعیت یادداشت
۴.۱ AvailabilityPerformanceTest با قوانین فعال سبز سبز — و چون spacing وارد تولید کاندید نشد، تعداد کوئری اصلاً تغییر نکرد
۴.۲ spacing تعداد کوئری ثابت دارد یک MAX(slot_start) به‌ازای هر رزرو، مستقل از تعداد اسلات

۵. UI

این تسک صفحه نمی‌سازد (تسک ۱۰ می‌سازد).

# مورد وضعیت یادداشت
۵.۱ پیام‌های forbid فارسی و قابل فهم بیمار reason دلخواه؛ نبودنش → «قانون «X» این عملیات را مجاز نمی‌داند»
۵ خطای require_resource بدون منبع شامل نام نقش ⚠️ نام نقش و شعبه می‌آید، نام قانون نمی‌آید — تسک ۱۰

۶. تست

# مورد وضعیت یادداشت
۶.۱ همهٔ عملگرها × نوع‌ها، all/any، فیلد ناموجود ⚠️ فیلد ناموجود و all پوشش دارند؛ تست واحدِ هر شش عملگر ندارد
۶.۲ جدول ترکیب max · sum · veto تست شدند (union در ۱.۱۳ غیرمستقیم)
۶.۳ حل تناقض اختصاصی‌بودن و اولویت هر دو
۶.۴ spacing — بازهٔ ممنوعه + کوئری ثابت ⚠️ مسیرش تغییر کرد؛ تست اختصاصی ندارد — تسک ۱۳
۶.۵ نسخه‌بندی (قانون پنجم) testEditingAPolicyCreatesANewVersionAndTheQuoteRecordsIt
۶.۶ یکپارچگی چند دسته end-to-end timing · selection · pricing
۶.۷ هر فیلد schema قابل extract است تسک ۱۰ — تا آن‌وقت خطرش در ۱.۳ ثبت است
۶.۸ NoPolicyRegressionTest

اجرا: ddev exec php bin/phpunit tests/Policy → ۱۷ تست (۱ skip عمدی: تولید خروجی مستندات).

۷. مستندات

# مورد وضعیت یادداشت
۷.۱ docs/api/policy.md JSON واقعی از اجرای DocsCaptureTest
۷ قاعدهٔ «DiscountRule یا Policy؟» جدول تصمیم در policy-engine.md
۷ docs/architecture/policy-engine.md حل تناقض، ترکیب، دلیل ممنوعیت کد دلخواه، دلیل عدم مهاجرت، انحراف‌ها
۷ یادداشت متقابل در docs مصرف‌کننده‌ها pricing.md · appointment-plan.md · appointment-booking.md · clinic-services.md

۸. بازبینی پایانی

# مورد وضعیت یادداشت
۸.۱ هیچ 🔄 و بی‌دلیل نمانده ۶ مورد همه با تسک مقصد
۸.۲ bin/phpunit کامل سبز ۱۲۳۷ تست
۸.۳ --group=slot-mode-frozen سبز
۸.۴ AvailabilityPerformanceTest سبز
۸.۵ phpstan بدون خطای جدید ۱۴ خطا = همان baseline
۸.۶ npx tsc --noEmit و yarn test سبز این تسک هیچ فایل فرانتی عوض نکرد
۸.۷ تست‌های tenant سبز
۸.۸ docs/api/* به‌روز
۸.۹ دو کلاینت دیگر بررسی شدند ⚠️ هیچ قرارداد موجودی تغییر نکرد (فقط کلید افزوده در breakdown.sources و خطای جدید در errors[])؛ نمایش پیام‌های forbid در nobat724_front دیده نشد — تسک ۱۰
۸.۱۰ commit، سپس graphify update . دو کامیت جدا
۸.۱۱ موارد به‌تعویق با دلیل و تسک مقصد تسک ۱۰: ۱.۱۰، ۱.۱۴، ۳.۳، ۳.۶، ۵.۲، ۶.۷، ۸.۹ · تسک ۱۳: ۱.۱۲، ۶.۴