Files
clinicpro/docs/new_feture/taskes/task-08-pricing-snapshot/implementation_notes.md
T
hamed 021d0eb6b2 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.
2026-07-30 11:43:58 +03:30

7.2 KiB
Raw Blame History

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

۱. تاریخ مبنا: تاریخ رزرو، نه تاریخ ثبت

$at = $appointment->getSlotStart();     // ✅
// نه: time()

دو تفسیر ممکن است و باید یکی انتخاب شود. مستند بند ۱۲ صریح می‌گوید «لیست قیمت معتبر در تاریخ رزرو». پیامدش: بیمار که امروز برای مهر نوبت می‌گیرد، قیمت مهر را می‌پردازد.

این را در docs/api/pricing.md و در UI («قیمت بر اساس تاریخ نوبت محاسبه شده است») بنویس.

۲. عدد صحیح ریال، همه‌جا

// درصد تخفیف روی مبلغ صحیح
$discount = intdiv($amount * $percent, 100);   // ✅ گردکردن به پایین، قطعی
// نه: (int) round($amount * $percent / 100)   // ❌ float در مسیر پول

intdiv قطعی است و در همهٔ پلتفرم‌ها یکسان. یک ریال اختلاف در جمع فاکتور، ساعت‌ها دیباگ حسابداری می‌آورد.

۳. سقف تخفیف

مستند بند ۸: «تخفیف درصدی: به ترتیب اولویت پشت سر هم، با یک سقف قابل تنظیم».

// 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. این تفاوت باید در تست باشد.

۴. مبلغ نهایی هرگز منفی نیست

$final = max(0, $subtotal - $discount - $insuranceBase - $insuranceSupplementary + $tax);

و اگر max(0, …) فعال شد، یک ردیف price_snapshot_lines با kind='adjustment' و مبلغ اصلاحی ثبت شود — وگرنه جمع ردیف‌ها با final_rials نمی‌خواند و اولین کسی که فاکتور را audit کند فکر می‌کند باگ محاسباتی است.

۵. جمع ردیف‌ها باید با مبلغ نهایی بخواند

تست ثابت (invariant):

$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 جدید فقط وقتی هیچ اندپوینت موجودی کافی نباشد». اینجا سرویس موجود کافی است — بازنویسی‌اش یعنی دو منبع حقیقت برای پوشش بیمه.

۷. تداخل بازهٔ لیست قیمت

// 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 به‌روز کن — این تنها راه جلوگیری از حذف یکی از آن‌ها در آیندهٔ نزدیک است.