- 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.
7.2 KiB
نکات پیادهسازی — تسک ۰۸
۱. تاریخ مبنا: تاریخ رزرو، نه تاریخ ثبت
$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 بهروز کن — این تنها راه جلوگیری از حذف یکی از آنها در
آیندهٔ نزدیک است.