feat(admin): price lists and the appointment invoice card

Task 08's pricing chain was reachable only through the API, so a clinic could
not define a price list or see what a booked appointment was actually charged.

Price lists
- Draft / active / expired are shown as three states because they mean three
  different things operationally: a draft has no effect on today's price at all
- Activation is a separate action rather than a checkbox in the form, matching
  the backend rule that creating a list must not change anything
- "Copy" seeds a new list from an existing one starting the day the old one
  ends, since most lists are last quarter's with a few numbers moved
- "All branches" is an explicit option, not an empty field

Invoice card
- Renders the recorded chain down to the final amount, hiding zero rows so the
  card stays readable
- A missing invoice renders as a normal state, not an error: an appointment
  that was never confirmed has no invoice
- Says outright that the numbers are from the appointment's own date and later
  tariff changes do not move them — otherwise someone who edited a price
  yesterday reads today's older number as a bug

Also corrects task 08's checklist: its test section carried a copy-pasted "no
UI was built" note against rows whose tests have existed since the task
shipped. Replaced with the real test names and the two that genuinely are not
covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-31 19:58:29 +03:30
co-authored by Claude Opus 5
parent 1559a60994
commit 4bca659939
8 changed files with 700 additions and 23 deletions
@@ -1,6 +1,6 @@
# چک‌لیست — تسک ۰۸ (لیست قیمت بازه‌دار و snapshot فاکتور)
**وضعیت کلی:** ✅ بک‌اند و مستندات تکمیل (UI ⏳) · **آخرین بازبینی:**
**وضعیت کلی:** تمام‌شده — بک‌اند، مستندات و UI · **آخرین بازبینی:**
قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) ·
[red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md)
@@ -55,33 +55,38 @@
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۳.۱ | `PriceListsPage` · `PriceListFormPage` | | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی |
| ۳.۲ | وضعیت شمسی: پیش‌نویس/فعال/منقضی با `StatusBadge` | | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی |
| ۳.۳ | بازهٔ تاریخ با `PersianDatePicker` | | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی |
| ۳.۴ | قیمت‌ها با `PriceInput` | | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی |
| ۳.۵ | شعبه با `SearchableSelect` | | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی |
| ۳.۶ | **«کپی از لیست قیمت قبلی»** | | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی |
| ۳.۷ | کارت «فاکتور» در `AppointmentDetailPage` با ردیف‌های snapshot | ⏳ | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی |
| ۳.۸ | متن «قیمت بر اساس تاریخ نوبت محاسبه شده است» | | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی |
| ۳.۹ | هیچ رنگ/شعاع hard-code | | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی |
| ۳.۱۰ | دارک‌مود و حالت فشرده | | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی |
| ۳.۱۱ | RTL و موبایل | | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی |
| ۳.۱۲ | مبالغ با `formatRial` · تاریخ‌ها با `formatDate` | | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی |
| ۳.۱۳ | وضعیت لیست در URL با `useUrlState` | ⏳ | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی |
| ۳.۱۴ | همهٔ رشته‌ها فارسی | | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی |
| ۳.۱ | `PriceListsPage` | | لیست + مودال ویرایش با ردیف‌های قیمت |
| ۳.۲ | وضعیت شمسی: پیش‌نویس/فعال/منقضی | | سه حالت با معنای عملیاتی متفاوت؛ فعال‌سازی عمل جداست، نه تیک داخل فرم |
| ۳.۳ | بازهٔ تاریخ با `PersianDateInput` | | تبدیل ISO↔Unix در همان صفحه |
| ۳.۴ | قیمت‌ها با `PriceInput` | | |
| ۳.۵ | شعبه با `SearchableSelect` | | «همهٔ شعبه‌ها» گزینهٔ صریح است، نه خالی‌گذاشتن |
| ۳.۶ | «کپی از لیست قیمت قبلی» | | ⭐ بازه از پایان لیست قبلی شروع می‌شود |
| ۳.۷ | کارت فاکتور در `AppointmentDetailPage` | ✅ | `AppointmentInvoiceCard` — ردیف‌های صفر پنهان می‌شوند |
| ۳.۸ | متن «قیمت بر اساس تاریخ نوبت محاسبه شده» | | ⭐ وگرنه کاربری که دیروز تعرفه را عوض کرده فکر می‌کند سیستم خراب است |
| ۳.۹ | هیچ رنگ/شعاع hard-code | | |
| ۳.۱۰ | دارک‌مود و حالت فشرده | ⚠️ | فقط توکن‌ها؛ بازبینی چشمی انجام نشد |
| ۳.۱۱ | RTL و موبایل | | جدول لیست‌ها اسکرول افقی داخلی دارد |
| ۳.۱۲ | مبالغ با `formatRial` · تاریخ با `formatDate` | | |
| ۳.۱۳ | وضعیت لیست در URL | ✅ | `useUrlState` |
| ۳.۱۴ | همهٔ رشته‌ها فارسی | | |
| ۳.۱۵ | تست فرانت کارت فاکتور | ✅ | چهار تست، شامل «نبودِ فاکتور خطا نیست» |
## ۴. تست
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۴.۱ | `PriceResolverTest` — ترتیب پنج‌گانه + fallback | | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی |
| ۴.۲ | `PricingEngineTest` تخفیف پشت‌سرهم، سقف، منفی → صفر | | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی |
| ۴.۳ | **invariant**: جمع ردیف‌ها = مبلغ نهایی، در همهٔ سناریوها | ⏳ | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی |
| ۴.۴ | `PriceSnapshotImmutabilityTest`قانون پنجم | | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی |
| ۴.۵ | `PriceListActivationTest` — تداخل هم‌سطح ۴۲۲، شعبه/محیط بی‌تداخل | ⏳ | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی |
| ۴.۶ | `DepositCalculatorTest` — درصدی با min/max، اولویت سرویس | ⏳ | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی |
| ۴.۷ | `QuoteTenantTest` سرویس محیط دیگر ۴۰۴ | | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی |
| ۴.۸ | نوبت بدون سرویس (حالت `slot`) → snapshot با `visit_price_rials` | | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی |
| ۴.۱ | ترتیب لایه‌های قیمت + fallback | | `testFullChainAppliesInOrder` · `testFallsBackToTheServicePrice` · `testBranchOverrideBeatsThePriceList` |
| ۴.۲ | تخفیف پشت‌سرهم، سقف، منفی → صفر | | `testDiscountLargerThanTheAmountFloorsAtZero` · `testTotalDiscountCapIsApplied` |
| ۴.۳ | invariant جمع ردیف‌ها = مبلغ نهایی | ⚠️ | زنجیره در `testFullChainAppliesInOrder` عدد‌به‌عدد سنجیده می‌شود؛ invariant به‌صورت property-based روی سناریوهای تصادفی نوشته نشد |
| ۴.۴ | تغییرناپذیری فاکتور (قانون پنجم) | | `testBookedAppointmentKeepsItsOriginalInvoiceAfterAPriceChange` |
| ۴.۵ | فعال‌سازی و تداخل بازه | ✅ | `testOverlappingActiveListsAreRejected` · `testBranchListWinsOverTheGeneralList` · `testDraftListHasNoEffectUntilActivated` |
| ۴.۶ | محاسبهٔ بیعانه | ⚠️ | درصدی و مبلغی هر دو در `PricingEngine` هست و در زنجیرهٔ کامل تست می‌شود؛ تست اختصاصی با min/max ندارد |
| ۴.۷ | سرویس محیط دیگر ۴۰۴ | | `testForeignServiceIsNotFound` |
| ۴.۸ | نوبت بدون سرویس → فاکتور با `visit_price_rials` | ⚠️ | مسیرش هست (`recordFlatVisit`)؛ تست اختصاصی ندارد |
| ۴.۹ | قیمت منفی رد می‌شود | ✅ | `testNegativePriceIsRejected` |
| ۴.۱۰ | تست فرانت کارت فاکتور | ✅ | چهار تست |
**اجرا:** `ddev exec php bin/phpunit tests/Pricing` → ۱۲ تست.
## ۵. مستندات