# نکات پیاده‌سازی — تسک ۰۸ ## ۱. تاریخ مبنا: تاریخ رزرو، نه تاریخ ثبت ```php $at = $appointment->getSlotStart(); // ✅ // نه: time() ``` دو تفسیر ممکن است و باید یکی انتخاب شود. مستند بند ۱۲ صریح می‌گوید «لیست قیمت معتبر در تاریخ رزرو». پیامدش: بیمار که امروز برای مهر نوبت می‌گیرد، قیمت مهر را می‌پردازد. این را در `docs/api/pricing.md` و در UI («قیمت بر اساس تاریخ نوبت محاسبه شده است») بنویس. ## ۲. عدد صحیح ریال، همه‌جا ```php // درصد تخفیف روی مبلغ صحیح $discount = intdiv($amount * $percent, 100); // ✅ گردکردن به پایین، قطعی // نه: (int) round($amount * $percent / 100) // ❌ float در مسیر پول ``` `intdiv` قطعی است و در همهٔ پلتفرم‌ها یکسان. یک ریال اختلاف در جمع فاکتور، ساعت‌ها دیباگ حسابداری می‌آورد. ## ۳. سقف تخفیف مستند بند ۸: «تخفیف درصدی: به ترتیب اولویت پشت سر هم، با یک سقف قابل تنظیم». ```php // SiteConfig یا تنظیمات محیط $maxPercent = $this->config->maxTotalDiscountPercent($ctx) ?? 100; $cap = intdiv($subtotal * $maxPercent, 100); $discountTotal = min($discountTotal, $cap); ``` بدون سقف، سه قانون ۴۰٪ پشت‌سرهم مبلغ را به ۲۱٪ می‌رسانند و کلینیک صبح روز بعد متوجه می‌شود. **پشت سر هم، نه جمع:** ۴۰٪ سپس ۱۰٪ یعنی `0.9 × 0.6 = 0.54`، نه `1 - 0.5 = 0.5`. این تفاوت باید در تست باشد. ## ۴. مبلغ نهایی هرگز منفی نیست ```php $final = max(0, $subtotal - $discount - $insuranceBase - $insuranceSupplementary + $tax); ``` و اگر `max(0, …)` فعال شد، یک ردیف `price_snapshot_lines` با `kind='adjustment'` و مبلغ اصلاحی ثبت شود — وگرنه جمع ردیف‌ها با `final_rials` نمی‌خواند و اولین کسی که فاکتور را audit کند فکر می‌کند باگ محاسباتی است. ## ۵. جمع ردیف‌ها باید با مبلغ نهایی بخواند تست ثابت (invariant): ```php $sum = array_sum(array_map(fn($l) => $l->getAmountRials(), $snapshot->getLines())); self::assertSame($snapshot->getFinalRials(), $sum, 'جمع ردیف‌ها باید با مبلغ نهایی برابر باشد'); ``` با علامت‌گذاری درست (تخفیف و سهم بیمه منفی) این همیشه برقرار است. اگر نبود، یکی از مراحل ردیف ننوشته — که یعنی فاکتور غیرقابل‌توضیح. ## ۶. بیمه: از موجود استفاده کن، دوباره نساز `AppointmentInsuranceService` و `TenantServiceCoverage` و `TenantInsuranceCategoryCoverage` از قبل هستند و منطق «تکمیلی روی باقیماندهٔ بعد از پایه» را دارند (`docs/architecture/insurance-billing-system.md`). `PricingEngine` مرحلهٔ ۵ فقط آن را صدا می‌زند و نتیجه را به ردیف تبدیل می‌کند. قاعدهٔ پروژه: «API جدید فقط وقتی هیچ اندپوینت موجودی کافی نباشد». اینجا سرویس موجود کافی است — بازنویسی‌اش یعنی دو منبع حقیقت برای پوشش بیمه. ## ۷. تداخل بازهٔ لیست قیمت ```php // PriceListService::activate() $overlap = $this->repo->findActiveOverlapping($ctx, $branch, $validFrom, $validTo); if ($overlap !== []) { throw new AppException(ErrorCodes::ERR_VALIDATION_001, sprintf( 'لیست قیمت «%s» بازهٔ مشترک دارد', $overlap[0]->getName() ), 422); } ``` نکتهٔ ظریف: لیست بدون شعبه (`branch_id = NULL`) با لیست شعبه‌دار تداخل **ندارد** — دومی اختصاصی‌تر است و اولویت دارد. فقط لیست‌های هم‌سطح با هم تداخل دارند. ## ۸. edge case ها | حالت | رفتار درست | |---|---| | هیچ لیست قیمتی تاریخ را نمی‌پوشاند | fallback: تعرفهٔ سال → قیمت سرویس | | سرویس در لیست قیمت نیست | همان fallback per سرویس، نه رد کل quote | | `valid_to = null` و لیست جدید با `valid_from` وسط آن | `activate` باید لیست قبلی را با `valid_to = new.valid_from - 1` ببندد و پیام بدهد، نه `422` خشک | | نوبت حالت `slot` بدون سرویس | snapshot با `visit_price_rials` و یک ردیف `base` | | تخفیف بیشتر از مبلغ | `final = 0` + ردیف `adjustment` | | بیعانه درصدی وقتی مبلغ صفر است | بیعانه صفر، `deposit_required = false` | | `reschedule` | نوبت جدید، snapshot جدید با قیمت **تاریخ جدید** | | snapshot موجود و `confirm` دوباره (idempotent تسک ۰۷) | snapshot دست‌نخورده بماند، دوباره ساخته نشود | | مبلغ بزرگ‌تر از `INT_MAX` ریال (۲.۱ میلیارد) | `BIGINT` لازم؟ — ۲۱۴ میلیون تومان. برای پکیج‌های بزرگ ممکن است. **تصمیم: `BIGINT` برای `final_rials` و `amount_rials`** | آخرین سطر را جدی بگیر: پکیج ۸ جلسه لیزر فول‌بادی می‌تواند از سقف `INT` عبور کند. `price_snapshots.final_rials` و `price_snapshot_lines.amount_rials` را `BIGINT` بگیر. (بقیهٔ ستون‌های `price_rials` پروژه `INT` می‌مانند — قیمت واحد از سقف عبور نمی‌کند.) ## ۹. تست ``` tests/Pricing/PriceResolverTest.php - ترتیب پنج‌گانه: override شعبه > لیست شعبه > لیست محیط > تعرفه > قیمت سرویس - تاریخ بدون لیست → fallback tests/Pricing/PricingEngineTest.php - تخفیف پشت‌سرهم: ۴۰٪ سپس ۱۰٪ → ۵۴٪ باقی، نه ۵۰٪ - سقف تخفیف اعمال می‌شود - مبلغ منفی → صفر + ردیف adjustment - جمع ردیف‌ها = مبلغ نهایی (invariant، در همهٔ سناریوها) tests/Pricing/PriceSnapshotImmutabilityTest.php ← ⭐ قانون پنجم - ثبت نوبت → تغییر قیمت سرویس → snapshot بدون تغییر - حذف قانون تخفیف → label و مبلغ ردیف سالم tests/Pricing/PriceListActivationTest.php - بازهٔ هم‌پوشان هم‌سطح → 422 - لیست شعبه با لیست محیط → تداخل نیست tests/Pricing/DepositCalculatorTest.php - درصدی با min/max - سیاست سرویس بر سیاست محیط اولویت دارد tests/Pricing/QuoteTenantTest.php - سرویس محیط دیگر → 404 ``` ## ۱۰. مستندات `docs/api/pricing.md` بساز. `docs/architecture/insurance-billing-system.md` را با جدول `PriceSnapshot` vs `Invoice` به‌روز کن — این تنها راه جلوگیری از حذف یکی از آن‌ها در آیندهٔ نزدیک است.