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

203 lines
9.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# معماری — تسک ۰۹
## ساختار فایل
```
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`
```php
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` پیش‌فرض عمدی است — مستند بند ۸: «قبل از اینکه یک قانون واقعاً فعال شود،
باید بشود آن را روی داده واقعی اجرا کرد». تسک ۱۰ همین را می‌سازد.
## شرط — فهرست بسته
```json
{
"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`
```php
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
/** به‌جای فیلتر کردن ۹۰ روز اسلات در 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.
## نقاط اتصال — همه از قبل آماده‌اند
```php
// تسک ۰۵ — 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
```
هیچ امضایی عوض نمی‌شود — این دقیقاً دلیلی است که آن قلاب‌ها از روز اول گذاشته شدند.