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,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`
|
||||
Reference in New Issue
Block a user