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

137 lines
7.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# نکات پیاده‌سازی — تسک ۰۸
## ۱. تاریخ مبنا: تاریخ رزرو، نه تاریخ ثبت
```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` به‌روز کن — این تنها راه جلوگیری از حذف یکی از آن‌ها در
آیندهٔ نزدیک است.