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.
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
# معماری — تسک ۰۹
|
||||
|
||||
## ساختار فایل
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
هیچ امضایی عوض نمیشود — این دقیقاً دلیلی است که آن قلابها از روز اول گذاشته شدند.
|
||||
Reference in New Issue
Block a user