- 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.
9.0 KiB
معماری — تسک ۰۹
ساختار فایل
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
هیچ امضایی عوض نمیشود — این دقیقاً دلیلی است که آن قلابها از روز اول گذاشته شدند.