# نکات پیاده‌سازی — تسک ۰۹ ## ۱. چرا `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`