# نکات پیاده‌سازی — تسک ۰۹

## ۱. چرا `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`
