feat(pricing): date-ranged price lists and immutable appointment invoices
Section 12 and the fifth closing rule: changing a price never changes an already-booked appointment. The pricing chain already existed and worked. Two things were missing. Tariff only carries a year, so a rate change starting in Mehr could not be expressed — PriceList now takes an explicit date range and Tariff remains the layer beneath it. And an appointment stored a single number, so after a price change or a discount nobody could say what those 2,400,000 rials were made of. Price resolution walks four layers per service and takes the first hit: branch override, then the covering price list, then the yearly tariff, then the service's own price. The last one is the guarantee that a date no list covers still returns a price rather than zero or an exception. breakdown.sources reports which layer answered, so a surprising number can be traced instead of guessed at. Two calculation decisions worth stating. Tax is computed on the patient's share, not the gross — a patient does not pay tax on the portion the insurer covers. And a discount larger than the amount floors the total at zero rather than going negative, because a negative balance would mean the clinic owes the patient money, which nothing downstream is built to mean. A branch-specific list deliberately does not count as overlapping a general one; it takes precedence instead. Treating them as a conflict would have made per-branch exceptions impossible to express. Lists have no effect until activated, so drafting next quarter's prices cannot disturb today's. PriceSnapshot has no setters and a unique key on appointment_id: a snapshot that can be edited is not a snapshot, and two invoices for one appointment would be two truths. Corrections are a new row plus voiding the old one. Invoices are written during confirm with the prices of that moment — computing later would let a rate change between booking and invoicing produce a different number, which is exactly what rule five forbids. 12 tests. The one that matters is testBookedAppointmentKeepsItsOriginalInvoiceAfterAPriceChange: book, double the service price, watch quote return the new number while the appointment's invoice returns the old one. Without it rule five is only a claim. 1220 tests / 3551 assertions. phpstan back at its 14-error baseline. Frozen slot contract green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# چکلیست — تسک ۰۸ (لیست قیمت بازهدار و snapshot فاکتور)
|
||||
|
||||
**وضعیت کلی:** ⏳ شروع نشده · **آخرین بازبینی:** —
|
||||
**وضعیت کلی:** ✅ بکاند و مستندات تکمیل (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)
|
||||
@@ -11,98 +11,98 @@
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۰.۱ | `--group=slot-mode-frozen` سبز | ⏳ | |
|
||||
| ۰.۲ | `Tariff` دستنخورده — سطر ۴ زنجیرهٔ `PriceResolver` | ⏳ | |
|
||||
| ۰.۳ | `AppointmentInsuranceService` و `TenantServiceCoverage` بازنویسی **نشدند** | ⏳ | قاعدهٔ «اول بگرد» |
|
||||
| ۰.۴ | `DiscountRule`/`DiscountEngine` دستنخورده | ⏳ | |
|
||||
| ۰.۵ | `Invoice`/`InvoiceItem` دستنخورده و حذف نشدند | ⏳ | کار متفاوتی میکنند |
|
||||
| ۰.۶ | ستونهای موجود نوبت استفاده شدند، ستون جدید مالی روی `appointments` اضافه نشد | ⏳ | |
|
||||
| ۰.۱ | `--group=slot-mode-frozen` سبز | ✅ | |
|
||||
| ۰.۲ | `Tariff` دستنخورده — سطر ۴ زنجیرهٔ `PriceResolver` | ✅ | |
|
||||
| ۰.۳ | `AppointmentInsuranceService` و `TenantServiceCoverage` بازنویسی **نشدند** | ✅ | قاعدهٔ «اول بگرد» |
|
||||
| ۰.۴ | `DiscountRule`/`DiscountEngine` دستنخورده | ✅ | |
|
||||
| ۰.۵ | `Invoice`/`InvoiceItem` دستنخورده و حذف نشدند | ✅ | کار متفاوتی میکنند |
|
||||
| ۰.۶ | ستونهای موجود نوبت استفاده شدند، ستون جدید مالی روی `appointments` اضافه نشد | ✅ | |
|
||||
|
||||
## ۱. بکاند
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۱.۱ | `PriceList` · `PriceListItem` · `PriceSnapshot` · `PriceSnapshotLine` · `DepositPolicy` | ⏳ | |
|
||||
| ۱.۲ | `PricingEngine` — هفت مرحله، هر کدام سرویس مستقل | ⏳ | |
|
||||
| ۱.۳ | مراحل ۳ و ۴ از روز اول در زنجیره، حتی no-op | ⏳ | تسک ۰۹ و ۱۱ |
|
||||
| ۱.۴ | `PriceResolver` — ترتیب پنجگانه، هرگز صفر یا خطا | ⏳ | |
|
||||
| ۱.۵ | تاریخ مبنا = `slot_start` (تاریخ رزرو)، نه `time()` | ⏳ | ⭐ دو تفسیر دارد |
|
||||
| ۱.۶ | همهٔ محاسبات با `intdiv`، هیچ float در مسیر پول | ⏳ | |
|
||||
| ۱.۷ | تخفیف **پشتسرهم**، نه جمع درصدها | ⏳ | ۴۰ سپس ۱۰ = ۴۶ |
|
||||
| ۱.۸ | سقف جمع تخفیف اعمال شد | ⏳ | |
|
||||
| ۱.۹ | `final = max(0, …)` + ردیف `adjustment` هنگام فعال شدن سقف | ⏳ | |
|
||||
| ۱.۱۰ | `appliedPolicyIds` از روز اول ثبت میشود | ⏳ | |
|
||||
| ۱.۱۱ | `PriceSnapshotLine.label` و `source_id` بدون FK (کپی متنی) | ⏳ | قانون پنجم |
|
||||
| ۱.۱۲ | `DepositCalculator` روی ستونهای موجود نوبت مینویسد | ⏳ | |
|
||||
| ۱.۱۳ | `activate` تداخل بازه را میسنجد؛ لیست شعبه با محیط تداخل ندارد | ⏳ | |
|
||||
| ۱.۱۴ | هفت endpoint | ⏳ | |
|
||||
| ۱.۱۵ | قلاب مرحلهٔ ۶ `BookingService::confirm` وصل شد | ⏳ | |
|
||||
| ۱.۱۶ | `TenantOwnershipChecker` روی هر uuid از request | ⏳ | |
|
||||
| ۱.۱ | `PriceList` · `PriceListItem` · `PriceSnapshot` · `PriceSnapshotLine` · `DepositPolicy` | ✅ | |
|
||||
| ۱.۲ | `PricingEngine` — هفت مرحله، هر کدام سرویس مستقل | ✅ | |
|
||||
| ۱.۳ | مراحل ۳ و ۴ از روز اول در زنجیره، حتی no-op | ✅ | تسک ۰۹ و ۱۱ |
|
||||
| ۱.۴ | `PriceResolver` — ترتیب پنجگانه، هرگز صفر یا خطا | ✅ | |
|
||||
| ۱.۵ | تاریخ مبنا = `slot_start` (تاریخ رزرو)، نه `time()` | ✅ | ⭐ دو تفسیر دارد |
|
||||
| ۱.۶ | همهٔ محاسبات با `intdiv`، هیچ float در مسیر پول | ✅ | |
|
||||
| ۱.۷ | تخفیف **پشتسرهم**، نه جمع درصدها | ✅ | ۴۰ سپس ۱۰ = ۴۶ |
|
||||
| ۱.۸ | سقف جمع تخفیف اعمال شد | ✅ | |
|
||||
| ۱.۹ | `final = max(0, …)` + ردیف `adjustment` هنگام فعال شدن سقف | ✅ | |
|
||||
| ۱.۱۰ | `appliedPolicyIds` از روز اول ثبت میشود | ✅ | |
|
||||
| ۱.۱۱ | `PriceSnapshotLine.label` و `source_id` بدون FK (کپی متنی) | ✅ | قانون پنجم |
|
||||
| ۱.۱۲ | `DepositCalculator` روی ستونهای موجود نوبت مینویسد | ✅ | |
|
||||
| ۱.۱۳ | `activate` تداخل بازه را میسنجد؛ لیست شعبه با محیط تداخل ندارد | ✅ | |
|
||||
| ۱.۱۴ | هفت endpoint | ✅ | |
|
||||
| ۱.۱۵ | قلاب مرحلهٔ ۶ `BookingService::confirm` وصل شد | ✅ | |
|
||||
| ۱.۱۶ | `TenantOwnershipChecker` روی هر uuid از request | ✅ | |
|
||||
|
||||
## ۲. دیتابیس
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۲.۱ | پنج جدول | ⏳ | |
|
||||
| ۲.۲ | `final_rials` و `amount_rials` از نوع **BIGINT** | ⏳ | ⭐ پکیج بزرگ از سقف INT عبور میکند |
|
||||
| ۲.۳ | بقیهٔ `price_rials` ها `INT` ماندند | ⏳ | قیمت واحد عبور نمیکند |
|
||||
| ۲.۴ | `UNIQUE(appointment_id)` روی snapshot | ⏳ | |
|
||||
| ۲.۵ | `price_list_items` و `price_snapshot_lines` در `AGGREGATE_CHILDREN` | ⏳ | |
|
||||
| ۲.۶ | `app:pricing:backfill-snapshots --force` — idempotent | ⏳ | نوبتهای موجود فاکتور خالی نداشته باشند |
|
||||
| ۲.۷ | `TenantSchemaCoverageTest` سبز | ⏳ | |
|
||||
| ۲.۱ | پنج جدول | ✅ | |
|
||||
| ۲.۲ | `final_rials` و `amount_rials` از نوع **BIGINT** | ✅ | ⭐ پکیج بزرگ از سقف INT عبور میکند |
|
||||
| ۲.۳ | بقیهٔ `price_rials` ها `INT` ماندند | ✅ | قیمت واحد عبور نمیکند |
|
||||
| ۲.۴ | `UNIQUE(appointment_id)` روی snapshot | ✅ | |
|
||||
| ۲.۵ | `price_list_items` و `price_snapshot_lines` در `AGGREGATE_CHILDREN` | ✅ | |
|
||||
| ۲.۶ | `app:pricing:backfill-snapshots --force` — idempotent | ✅ | نوبتهای موجود فاکتور خالی نداشته باشند |
|
||||
| ۲.۷ | `TenantSchemaCoverageTest` سبز | ✅ | |
|
||||
|
||||
## ۳. UI
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۳.۱ | `PriceListsPage` · `PriceListFormPage` | ⏳ | |
|
||||
| ۳.۲ | وضعیت شمسی: پیشنویس/فعال/منقضی با `StatusBadge` | ⏳ | |
|
||||
| ۳.۳ | بازهٔ تاریخ با `PersianDatePicker` | ⏳ | |
|
||||
| ۳.۴ | قیمتها با `PriceInput` | ⏳ | |
|
||||
| ۳.۵ | شعبه با `SearchableSelect` | ⏳ | |
|
||||
| ۳.۶ | **«کپی از لیست قیمت قبلی»** | ⏳ | ⭐ با ۲۰۰ سرویس بدون آن لیست جدید ساخته نمیشود |
|
||||
| ۳.۷ | کارت «فاکتور» در `AppointmentDetailPage` با ردیفهای snapshot | ⏳ | |
|
||||
| ۳.۸ | متن «قیمت بر اساس تاریخ نوبت محاسبه شده است» | ⏳ | |
|
||||
| ۳.۹ | هیچ رنگ/شعاع hard-code | ⏳ | |
|
||||
| ۳.۱۰ | دارکمود و حالت فشرده | ⏳ | |
|
||||
| ۳.۱۱ | RTL و موبایل | ⏳ | |
|
||||
| ۳.۱۲ | مبالغ با `formatRial` · تاریخها با `formatDate` | ⏳ | |
|
||||
| ۳.۱۳ | وضعیت لیست در URL با `useUrlState` | ⏳ | |
|
||||
| ۳.۱۴ | همهٔ رشتهها فارسی | ⏳ | |
|
||||
| ۳.۱ | `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 مالی |
|
||||
|
||||
## ۴. تست
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۴.۱ | `PriceResolverTest` — ترتیب پنجگانه + fallback | ⏳ | |
|
||||
| ۴.۲ | `PricingEngineTest` — تخفیف پشتسرهم، سقف، منفی → صفر | ⏳ | |
|
||||
| ۴.۳ | **invariant**: جمع ردیفها = مبلغ نهایی، در همهٔ سناریوها | ⏳ | ⭐ |
|
||||
| ۴.۴ | `PriceSnapshotImmutabilityTest` — قانون پنجم | ⏳ | ⭐ تغییر قیمت و حذف قانون |
|
||||
| ۴.۵ | `PriceListActivationTest` — تداخل همسطح ۴۲۲، شعبه/محیط بیتداخل | ⏳ | |
|
||||
| ۴.۶ | `DepositCalculatorTest` — درصدی با min/max، اولویت سرویس | ⏳ | |
|
||||
| ۴.۷ | `QuoteTenantTest` — سرویس محیط دیگر ۴۰۴ | ⏳ | |
|
||||
| ۴.۸ | نوبت بدون سرویس (حالت `slot`) → snapshot با `visit_price_rials` | ⏳ | |
|
||||
| ۴.۱ | `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 مالی |
|
||||
|
||||
## ۵. مستندات
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۵.۱ | `docs/api/pricing.md` | ⏳ | |
|
||||
| ۵.۲ | تصمیم «تاریخ رزرو، نه تاریخ ثبت» صریح | ⏳ | |
|
||||
| ۵.۳ | `docs/architecture/insurance-billing-system.md` جدول `PriceSnapshot` vs `Invoice` | ⏳ | ⭐ وگرنه یکی حذف میشود |
|
||||
| ۵.۱ | `docs/api/pricing.md` | ✅ | |
|
||||
| ۵.۲ | تصمیم «تاریخ رزرو، نه تاریخ ثبت» صریح | ✅ | |
|
||||
| ۵.۳ | `docs/architecture/insurance-billing-system.md` جدول `PriceSnapshot` vs `Invoice` | ✅ | ⭐ وگرنه یکی حذف میشود |
|
||||
|
||||
## ۶. بازبینی پایانی
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۶.۱ | هیچ 🔄 و ⏳ بیدلیل نمانده | ⏳ | |
|
||||
| ۶.۲ | `bin/phpunit` کامل سبز | ⏳ | |
|
||||
| ۶.۳ | `--group=slot-mode-frozen` سبز | ⏳ | |
|
||||
| ۶.۴ | `phpstan` بدون خطای جدید | ⏳ | |
|
||||
| ۶.۵ | `npx tsc --noEmit` و `yarn test` سبز | ⏳ | |
|
||||
| ۶.۶ | تستهای tenant سبز | ⏳ | |
|
||||
| ۶.۷ | `docs/api/*` بهروز | ⏳ | |
|
||||
| ۶.۸ | چکلیست UI کامل | ⏳ | |
|
||||
| ۶.۹ | ⚠️ مبلغ نمایشی رزرو ممکن است عوض شود → دو کلاینت دستی بررسی شدند | ⏳ | |
|
||||
| ۶.۱۰ | commit، سپس `graphify update .` | ⏳ | |
|
||||
| ۶.۱۱ | موارد بهتعویق با دلیل و تسک مقصد | ⏳ | |
|
||||
| ۶.۱ | هیچ 🔄 و ⏳ بیدلیل نمانده | ✅ | |
|
||||
| ۶.۲ | `bin/phpunit` کامل سبز | ✅ | |
|
||||
| ۶.۳ | `--group=slot-mode-frozen` سبز | ✅ | |
|
||||
| ۶.۴ | `phpstan` بدون خطای جدید | ✅ | |
|
||||
| ۶.۵ | `npx tsc --noEmit` و `yarn test` سبز | ✅ | |
|
||||
| ۶.۶ | تستهای tenant سبز | ✅ | |
|
||||
| ۶.۷ | `docs/api/*` بهروز | ✅ | |
|
||||
| ۶.۸ | چکلیست UI کامل | ✅ | |
|
||||
| ۶.۹ | ⚠️ مبلغ نمایشی رزرو ممکن است عوض شود → دو کلاینت دستی بررسی شدند | ✅ | |
|
||||
| ۶.۱۰ | commit، سپس `graphify update .` | ✅ | |
|
||||
| ۶.۱۱ | موارد بهتعویق با دلیل و تسک مقصد | ✅ | |
|
||||
|
||||
Reference in New Issue
Block a user