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:
hamed
2026-07-30 11:43:58 +03:30
parent 1d338503c8
commit 021d0eb6b2
62 changed files with 8098 additions and 0 deletions
@@ -0,0 +1,187 @@
# نکات پیاده‌سازی — تسک ۰۹
## ۱. چرا `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`