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

188 lines
9.3 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.
# نکات پیاده‌سازی — تسک ۰۹
## ۱. چرا `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` تنها منبع حقیقت
```php
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 باشد
```php
// ❌
$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` — کوئری، نه حلقه
بدترین اشتباه ممکن در این تسک:
```php
// ❌ فاجعهٔ کارایی — ۹۰ روز × ده‌ها اسلات × یک کوئری
foreach ($slots as $slot) {
$last = $this->appointmentRepo->findLastSession($patient, $service);
if ($slot['start'] - $last < $minDays * 86400) continue;
}
```
```php
// ✅ یک کوئری، بعد بازهٔ ممنوعه
$last = $this->appointmentRepo->findLastCompletedAt($patient, $service); // ۱ کوئری
if ($last !== null) {
$forbidden[] = ['start' => $last, 'end' => $last + $minDays * 86400];
}
// بازه به CandidateGenerator داده می‌شود → آن نقطه‌ها ساخته نمی‌شوند
```
`AvailabilityPerformanceTest` تسک ۰۶ باید **با قوانین فعال** هم سبز بماند. اگر بعد از این
تسک قرمز شد، دلیلش همین است.
## ۵. ترتیب اعمال تخفیف — پشت‌سرهم
```php
$remaining = $subtotal;
foreach ($sortedPolicies as $policy) { // به ترتیب اولویت
$amount = intdiv($remaining * $policy->percent(), 100);
$remaining -= $amount;
$lines[] = PriceLine::discount($policy->getName(), -$amount);
}
```
نه جمع درصدها. ۴۰٪ سپس ۱۰٪ = ۴۶٪ کل، نه ۵۰٪. مستند بند ۸ صریح: «به ترتیب اولویت پشت
سر هم».
## ۶. `combinable` و short-circuit
```php
foreach ($sorted as $policy) {
if (!$this->conditions->matches($policy, $ctx)) continue;
$applied[] = $policy;
if (!$policy->isCombinable()) break; // ← اولین غیرترکیب‌شدنی، پایان
}
```
قانون غیرترکیب‌شدنی با اولویت بالا، بقیه را می‌بلعد. این همان رفتار `DiscountRule` موجود
است و باید یکسان بماند، وگرنه دو دستهٔ تخفیف دو رفتار متفاوت می‌گیرند.
اثر `deny` استثناست: **همیشه** short-circuit، مستقل از `combinable`.
## ۷. نسخه‌بندی — ویرایش ممنوع
```php
// 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`