diff --git a/docs/api/README.md b/docs/api/README.md index e11f2e35..0c874b7d 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -88,6 +88,7 @@ Only **digits** are translated — no characters are stripped, so `IR` in a sheb | [appointment-plan.md](appointment-plan.md) | Appointment segments and plan preview | 3 | | [appointment-availability.md](appointment-availability.md) | Multi-resource availability search | 2 | | [appointment-booking.md](appointment-booking.md) | Holds, confirmation and multi-resource occupancy | 4 | +| [pricing.md](pricing.md) | Date-ranged price lists and appointment invoices | 8 | | [appointment.md](appointment.md) | Appointments & slot booking | 6 | | [appointment-settings.md](appointment-settings.md) | Weekly schedule, date overrides, holidays | 14 | | [payment.md](payment.md) | Payments (Mellat / Sep) | 5 | diff --git a/docs/api/pricing.md b/docs/api/pricing.md new file mode 100644 index 00000000..2e160a82 --- /dev/null +++ b/docs/api/pricing.md @@ -0,0 +1,137 @@ +# Pricing API — لیست قیمت بازه‌دار و فاکتور تفکیک‌شده + +> **Base:** `/api/v1` · **Auth:** JWT +> مکمل [clinic-services.md](clinic-services.md) و [appointment-booking.md](appointment-booking.md). + +--- + +## دو شکافی که پر شد + +زنجیرهٔ قیمت از قبل وجود داشت و کار می‌کرد +(`ServiceItem → Tariff → بیمه → DiscountRule → Invoice → Payment`). دو چیز کم بود: + +۱. **`Tariff` فقط سال دارد.** تغییر تعرفه از اول مهر قابل بیان نبود. حالا `PriceList` + بازهٔ دقیق می‌گیرد و `Tariff` لایهٔ پشتیبان می‌ماند. +۲. **روی نوبت فقط یک عدد بود.** بعد از تغییر قیمت یا تخفیف نمی‌شد گفت آن ۲٬۴۰۰٬۰۰۰ + ریال از چه تشکیل شده بود. حالا `PriceSnapshot` فاکتور تفکیک‌شدهٔ لحظهٔ ثبت را + نگه می‌دارد. + +## زنجیرهٔ قیمت‌گذاری + +``` +قیمت پایه → + آیتم‌ها → − تخفیف → − بیمهٔ پایه → − تکمیلی → + مالیات → بیعانه +``` + +برای **هر** سرویس، اولین منبعی که پیدا شود برنده است: + +| اولویت | منبع | از کجا | +|---|---|---| +| ۱ | override شعبه | تسک ۰۴ | +| ۲ | لیست قیمتِ حاکم بر آن تاریخ | همین تسک | +| ۳ | `Tariff` سال | لایهٔ موجود | +| ۴ | `ServiceItem.price_rials` | همیشه هست | + +مرحلهٔ چهارم ضامن است که **هرگز صفر یا خطا** برنگردد — تاریخی که هیچ لیستی نمی‌پوشاند +باید قیمت بدهد. `breakdown.sources` می‌گوید هر قیمت از کدام لایه آمده. + +### دو تصمیم محاسباتی + +**مالیات روی سهم بیمار حساب می‌شود، نه روی کل.** بیمار مالیاتِ سهمی که بیمه می‌دهد را +نمی‌پردازد. + +**تخفیف بیشتر از مبلغ، مبلغ را صفر می‌کند نه منفی.** بدهی منفی یعنی کلینیک به بیمار +پول بدهکار شود، که هیچ‌جای این جریان معنا ندارد. + +`max_total_discount_percent` سقف جمع تخفیف‌هاست: چند تخفیفِ جداگانه که هرکدام منطقی‌اند، +با هم می‌توانند مبلغ را بی‌معنا کنند. + +--- + +## `POST /api/v1/pricing/quote` + +```json +{ + "service_uuid": "…", + "branch_uuid": "…", + "item_uuids": ["…"], + "at": 1785562200, + "policy": { + "discount_percent": 10, + "max_total_discount_percent": 25, + "insurance_base_percent": 20, + "insurance_supplementary_percent": 50, + "tax_percent": 10, + "deposit_percent": 30 + } +} +``` + +`at` اختیاری است (پیش‌فرض الان) و تعیین می‌کند کدام لیست قیمت حاکم است. + +**۲۰۰:** همان شکلی که `price_snapshot` دارد — عمداً یکی، تا «قیمتی که نشان دادیم» و +«قیمتی که ثبت کردیم» نتوانند واگرا شوند. + +```json +{ + "base_rials": 10000000, "items_rials": 2000000, "discount_rials": 1200000, + "insurance_base_rials": 2160000, "insurance_supplementary_rials": 4320000, + "tax_rials": 432000, "final_rials": 4752000, "deposit_rials": 1425600, + "breakdown": { "discounts": [ … ], "sources": { "": "price_list" } } +} +``` + +--- + +## لیست قیمت + +| متد | مسیر | +|---|---| +| GET/POST | `/api/v1/price-lists` | +| GET/PATCH/DELETE | `/api/v1/price-list/{uuid}` | +| PUT | `/api/v1/price-list/{uuid}/items` | +| POST | `/api/v1/price-list/{uuid}/activate` | + +`address_uuid` تهی‌پذیر است: `null` یعنی «همهٔ شعبه‌های این محیط». لیستِ مخصوصِ یک شعبه +بر لیست عمومی **مقدم** است و با آن **تداخل حساب نمی‌شود** — وگرنه تعریف استثنا برای یک +شعبه ناممکن می‌شد. + +**لیست تا فعال نشده هیچ اثری ندارد.** ساختن پیش‌نویس نباید قیمت امروز را عوض کند. + +`activate` بازهٔ هم‌پوشان با لیست فعالِ **هم‌دامنه** را `422` می‌کند: یک تاریخ نباید دو +قیمت داشته باشد. + +--- + +## فاکتور نوبت + +`GET /api/v1/appointment/{uuid}/price-snapshot` + +فاکتور هنگام `POST /appointment-confirm` و با قیمت‌های **همان لحظه** ثبت می‌شود. اگر +بعداً محاسبه می‌شد، تغییر تعرفه بین ثبت و صدور فاکتور عدد دیگری می‌داد. + +> **قانون پنجم مستند:** «تغییر قیمت هرگز نوبت‌های ثبت‌شده را عوض نمی‌کند.» +> `PriceSnapshot` هیچ setter ای ندارد و کلید یکتای `appointment_id` دو فاکتور برای یک +> نوبت را در سطح دیتابیس غیرممکن می‌کند. اصلاح قیمت با ردیف تازه و ابطال قبلی انجام +> می‌شود، نه با بازنویسی. + +نوبتِ بدون سرویس (ویزیت سادهٔ حالت اسلاتی) هم فاکتور می‌گیرد، با همان +`visit_price_rials` موجود — خالی گذاشتنش یعنی گزارش مالی یک ردیف کم دارد. + +--- + +## طبقه‌بندی محیط + +| جدول | وضعیت | +|---|---| +| `price_lists` · `price_snapshots` | جفت محیط | +| `price_list_items` | `AGGREGATE_CHILDREN` — ریشه `PriceList` | + +## تست‌ها + +```bash +ddev exec php bin/phpunit tests/Pricing # ۱۲ تست +``` + +مهم‌ترینش `testBookedAppointmentKeepsItsOriginalInvoiceAfterAPriceChange` است: نوبت ثبت +می‌شود، قیمت سرویس دو برابر می‌شود، `quote` عدد جدید می‌دهد و فاکتور نوبت **همان عدد +قبلی** را. بدون آن، قانون پنجم فقط یک ادعاست. diff --git a/docs/new_feture/taskes/task-08-pricing-snapshot/checklist.md b/docs/new_feture/taskes/task-08-pricing-snapshot/checklist.md index 89d32dba..13848415 100644 --- a/docs/new_feture/taskes/task-08-pricing-snapshot/checklist.md +++ b/docs/new_feture/taskes/task-08-pricing-snapshot/checklist.md @@ -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 .` | ✅ | | +| ۶.۱۱ | موارد به‌تعویق با دلیل و تسک مقصد | ✅ | | diff --git a/migrations/Version20260731060549.php b/migrations/Version20260731060549.php new file mode 100644 index 00000000..02aade37 --- /dev/null +++ b/migrations/Version20260731060549.php @@ -0,0 +1,46 @@ +addSql('CREATE TABLE price_list_items (id INT AUTO_INCREMENT NOT NULL, price_rials BIGINT NOT NULL, price_list_id INT NOT NULL, service_item_id INT NOT NULL, INDEX IDX_8C05724A5688DED7 (price_list_id), INDEX IDX_8C05724ADDEB00C2 (service_item_id), UNIQUE INDEX uniq_price_list_service (price_list_id, service_item_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE price_lists (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, name VARCHAR(150) NOT NULL, starts_at INT NOT NULL, ends_at INT NOT NULL, active TINYINT DEFAULT 0 NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, address_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_23EF97C5D17F50A6 (uuid), INDEX IDX_23EF97C5F5B7AF75 (address_id), INDEX idx_price_list_tenant (entity_type, entity_id, active), INDEX idx_price_list_range (starts_at, ends_at), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE price_snapshots (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, base_rials BIGINT NOT NULL, items_rials BIGINT NOT NULL, discount_rials BIGINT NOT NULL, insurance_base_rials BIGINT NOT NULL, insurance_supplementary_rials BIGINT NOT NULL, tax_rials BIGINT NOT NULL, final_rials BIGINT NOT NULL, deposit_rials BIGINT NOT NULL, breakdown JSON DEFAULT NULL, computed_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, appointment_id INT NOT NULL, UNIQUE INDEX UNIQ_CE2075C1D17F50A6 (uuid), INDEX idx_snapshot_tenant (entity_type, entity_id), UNIQUE INDEX uniq_snapshot_appointment (appointment_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE price_list_items ADD CONSTRAINT FK_8C05724A5688DED7 FOREIGN KEY (price_list_id) REFERENCES price_lists (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE price_list_items ADD CONSTRAINT FK_8C05724ADDEB00C2 FOREIGN KEY (service_item_id) REFERENCES service_items (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE price_lists ADD CONSTRAINT FK_23EF97C5F5B7AF75 FOREIGN KEY (address_id) REFERENCES doctor_addresses (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE price_snapshots ADD CONSTRAINT FK_CE2075C1E5B533F9 FOREIGN KEY (appointment_id) REFERENCES appointments (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE price_list_items DROP FOREIGN KEY FK_8C05724A5688DED7'); + $this->addSql('ALTER TABLE price_list_items DROP FOREIGN KEY FK_8C05724ADDEB00C2'); + $this->addSql('ALTER TABLE price_lists DROP FOREIGN KEY FK_23EF97C5F5B7AF75'); + $this->addSql('ALTER TABLE price_snapshots DROP FOREIGN KEY FK_CE2075C1E5B533F9'); + $this->addSql('DROP TABLE price_list_items'); + $this->addSql('DROP TABLE price_lists'); + $this->addSql('DROP TABLE price_snapshots'); + } +} diff --git a/src/Appointment/Booking/Controller/BookingController.php b/src/Appointment/Booking/Controller/BookingController.php index f4cd0431..d3434be7 100644 --- a/src/Appointment/Booking/Controller/BookingController.php +++ b/src/Appointment/Booking/Controller/BookingController.php @@ -9,6 +9,9 @@ use App\Appointment\Booking\Service\HoldService; use App\Appointment\Entity\Appointment; use App\Auth\Repository\UserRepository; use App\Doctor\Repository\DoctorRepository; +use App\Pricing\Entity\PriceSnapshot; +use App\Pricing\Service\PriceSnapshotService; +use App\Pricing\Service\PricingEngine; use App\Appointment\Plan\Service\AppointmentPlanBuilder; use App\Auth\Entity\User; use App\Branch\Service\BranchResolver; @@ -44,6 +47,8 @@ class BookingController extends BaseController private readonly ClinicResourceRepository $resources, private readonly DoctorRepository $doctors, private readonly UserRepository $users, + private readonly PricingEngine $pricing, + private readonly PriceSnapshotService $snapshots, private readonly BranchResolver $branches, private readonly TenantOwnershipChecker $ownership, private readonly EntityManagerInterface $em, @@ -143,8 +148,14 @@ class BookingController extends BaseController $this->booking->confirm($hold, $appointment); + // فاکتور همین‌جا و با قیمت‌های همین لحظه ثبت می‌شود. اگر بعداً محاسبه می‌شد، + // تغییر تعرفه بین ثبت و صدور فاکتور، عدد دیگری می‌داد — دقیقاً چیزی که قانون + // پنجم مستند ممنوع کرده است. + $snapshot = $this->recordPrice($user, $hold, $appointment, $data); + return $this->success([ 'appointment_uuid' => $appointment->getUuid(), + 'price_snapshot' => $snapshot->toArray(), 'starts_at' => $hold->getStartsAt(), 'ends_at' => $hold->getEndsAt(), 'assignment' => $hold->getPayload()['assignment'] ?? [], @@ -188,6 +199,40 @@ class BookingController extends BaseController ]); } + /** + * فاکتور تفکیک‌شده. اگر سرویس پیدا نشد (نوبت ویزیت ساده)، فاکتور با همان + * `visit_price_rials` موجود ساخته می‌شود؛ خالی گذاشتنش یعنی گزارش مالی یک ردیف + * کم دارد. + * + * @param array $data + */ + private function recordPrice(User $user, AppointmentHold $hold, Appointment $appointment, array $data): PriceSnapshot + { + if (!is_string($data['service_uuid'] ?? null) || !is_string($data['branch_uuid'] ?? null)) { + return $this->snapshots->recordFlatVisit($appointment, (int) $appointment->getVisitPriceRials()); + } + + $service = $this->requireItem($user, $data['service_uuid']); + $address = $this->branches->resolve($user, $data['branch_uuid']); + + $items = []; + foreach (($data['item_uuids'] ?? []) as $itemUuid) { + if (is_string($itemUuid)) { + $items[] = $this->requireItem($user, $itemUuid); + } + } + + $quote = $this->pricing->quote( + $service, + $items, + $address, + $hold->getStartsAt(), + is_array($data['policy'] ?? null) ? $data['policy'] : [], + ); + + return $this->snapshots->record($appointment, $quote); + } + /** * هر نیازمندی باید در `assignment` منبع داشته باشد. بدون این، رزرو موقت * می‌توانست نصفِ منابع لازم را بگیرد و بقیه هنگام حضور بیمار کم بیاید. diff --git a/src/Pricing/Controller/PricingController.php b/src/Pricing/Controller/PricingController.php new file mode 100644 index 00000000..4c6e541c --- /dev/null +++ b/src/Pricing/Controller/PricingController.php @@ -0,0 +1,264 @@ +branches->pair($user); + + return $this->success(array_map( + static fn (PriceList $l): array => $l->toArray(), + $this->lists->findForPair($entityType, $entityId), + )); + } + + #[Route('/api/v1/price-lists', name: 'price_list_create', methods: ['POST'])] + public function create(#[CurrentUser] User $user, Request $request): JsonResponse + { + $data = json_decode($request->getContent(), true); + + if (!is_array($data) || !is_string($data['name'] ?? null) || trim($data['name']) === '') { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نام لیست قیمت الزامی است', 422, 'name'); + } + + if (!is_numeric($data['starts_at'] ?? null) || !is_numeric($data['ends_at'] ?? null)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بازهٔ تاریخ الزامی است', 422, 'starts_at'); + } + + [$entityType, $entityId] = $this->branches->pair($user); + + try { + $list = new PriceList($entityType, $entityId, trim($data['name']), (int) $data['starts_at'], (int) $data['ends_at']); + } catch (\InvalidArgumentException) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'پایان بازه باید بعد از شروع آن باشد', 422, 'ends_at'); + } + + if (is_string($data['address_uuid'] ?? null)) { + $list->setAddress($this->branches->resolve($user, $data['address_uuid'])); + } + + $this->em->persist($list); + $this->em->flush(); + + return $this->success($list->toArray(), 201); + } + + #[Route('/api/v1/price-list/{uuid}', name: 'price_list_show', methods: ['GET'])] + public function show(#[CurrentUser] User $user, string $uuid): JsonResponse + { + return $this->success($this->requireList($user, $uuid)->toArray()); + } + + #[Route('/api/v1/price-list/{uuid}', name: 'price_list_update', methods: ['PATCH'])] + public function update(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse + { + $data = json_decode($request->getContent(), true); + $list = $this->requireList($user, $uuid); + + if (!is_array($data)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422); + } + + if (is_string($data['name'] ?? null) && trim($data['name']) !== '') { + $list->setName(trim($data['name'])); + } + + if (array_key_exists('active', $data)) { + $list->setActive((bool) $data['active']); + } + + $this->em->flush(); + + return $this->success($list->toArray()); + } + + #[Route('/api/v1/price-list/{uuid}', name: 'price_list_delete', methods: ['DELETE'])] + public function delete(#[CurrentUser] User $user, string $uuid): JsonResponse + { + $this->em->remove($this->requireList($user, $uuid)); + $this->em->flush(); + + return $this->success(null); + } + + #[Route('/api/v1/price-list/{uuid}/items', name: 'price_list_items_replace', methods: ['PUT'])] + public function replaceItems(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse + { + $data = json_decode($request->getContent(), true); + + if (!is_array($data) || !is_array($data['items'] ?? null)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد items الزامی است', 422, 'items'); + } + + $list = $this->requireList($user, $uuid); + $resolved = []; + + foreach ($data['items'] as $row) { + if (!is_array($row) || !is_string($row['service_uuid'] ?? null) || !is_numeric($row['price_rials'] ?? null)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'service_uuid و price_rials الزامی‌اند', 422, 'items'); + } + + if ((int) $row['price_rials'] < 0) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'قیمت نمی‌تواند منفی باشد', 422, 'price_rials'); + } + + $resolved[] = [$this->requireItem($user, $row['service_uuid']), (int) $row['price_rials']]; + } + + $this->listItems->deleteForList($list); + $list->getItems()->clear(); + + foreach ($resolved as [$service, $price]) { + $item = new PriceListItem($list, $service, $price); + $this->em->persist($item); + $list->getItems()->add($item); + } + + $list->touch(); + $this->em->flush(); + + return $this->success($list->toArray()); + } + + /** + * فعال‌سازی با بررسی تداخل: دو لیستِ فعالِ هم‌پوشان یعنی یک تاریخ دو قیمت دارد و + * هیچ‌کس نمی‌تواند بگوید کدام درست است. + */ + #[Route('/api/v1/price-list/{uuid}/activate', name: 'price_list_activate', methods: ['POST'])] + public function activate(#[CurrentUser] User $user, string $uuid): JsonResponse + { + $list = $this->requireList($user, $uuid); + $conflicts = $this->lists->findOverlapping($list); + + if ($conflicts !== []) { + return $this->error( + ErrorCodes::ERR_VALIDATION_001, + sprintf('بازهٔ این لیست با «%s» هم‌پوشانی دارد', $conflicts[0]->getName()), + 422, + 'starts_at', + ); + } + + $list->setActive(true); + $this->em->flush(); + + return $this->success($list->toArray()); + } + + #[Route('/api/v1/pricing/quote', name: 'pricing_quote', methods: ['POST'])] + public function quote(#[CurrentUser] User $user, Request $request): JsonResponse + { + $data = json_decode($request->getContent(), true); + + if (!is_array($data) || !is_string($data['service_uuid'] ?? null)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد service_uuid الزامی است', 422, 'service_uuid'); + } + + if (!is_string($data['branch_uuid'] ?? null)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد branch_uuid الزامی است', 422, 'branch_uuid'); + } + + $service = $this->requireItem($user, $data['service_uuid']); + $address = $this->branches->resolve($user, $data['branch_uuid']); + + $items = []; + foreach (($data['item_uuids'] ?? []) as $itemUuid) { + if (is_string($itemUuid)) { + $items[] = $this->requireItem($user, $itemUuid); + } + } + + $at = is_numeric($data['at'] ?? null) ? (int) $data['at'] : time(); + $policy = is_array($data['policy'] ?? null) ? $data['policy'] : []; + + return $this->success($this->engine->quote($service, $items, $address, $at, $policy)->toArray()); + } + + /** + * فاکتور تفکیک‌شدهٔ نوبت — همان اعدادِ لحظهٔ ثبت، حتی اگر قیمت‌ها بعداً عوض شده باشند. + */ + #[Route('/api/v1/appointment/{uuid}/price-snapshot', name: 'appointment_price_snapshot', methods: ['GET'])] + public function snapshot(#[CurrentUser] User $user, string $uuid): JsonResponse + { + $appointment = $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $uuid]); + [$entityType, $entityId] = $this->branches->pair($user); + + if ($appointment === null || !$this->ownership->belongsToPair($entityType, $entityId, $appointment)) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نوبت یافت نشد', 404); + } + + $snapshot = $this->snapshots->findForAppointment($appointment); + + if ($snapshot === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'برای این نوبت فاکتوری ثبت نشده است', 404); + } + + return $this->success($snapshot->toArray()); + } + + private function requireList(User $user, string $uuid): PriceList + { + $list = $this->lists->findByUuid($uuid); + [$entityType, $entityId] = $this->branches->pair($user); + + if ($list === null || !$this->ownership->belongsToPair($entityType, $entityId, $list)) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'لیست قیمت یافت نشد', 404); + } + + return $list; + } + + private function requireItem(User $user, string $uuid): ServiceItem + { + $item = $this->items->findByUuid($uuid); + [$entityType, $entityId] = $this->branches->pair($user); + + if ($item === null + || $item->getSection()->getEntityType() !== $entityType + || $item->getSection()->getEntityId() !== $entityId + ) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'سرویس یافت نشد', 404); + } + + return $item; + } +} diff --git a/src/Pricing/Entity/PriceList.php b/src/Pricing/Entity/PriceList.php new file mode 100644 index 00000000..7ec6c138 --- /dev/null +++ b/src/Pricing/Entity/PriceList.php @@ -0,0 +1,125 @@ + false])] + private bool $active = false; + + #[ORM\Column(name: 'created_at', type: 'integer')] + private int $createdAt; + + #[ORM\Column(name: 'updated_at', type: 'integer')] + private int $updatedAt; + + /** @var Collection */ + #[ORM\OneToMany(targetEntity: PriceListItem::class, mappedBy: 'priceList', cascade: ['persist', 'remove'], orphanRemoval: true)] + private Collection $items; + + public function __construct(string $entityType, int $entityId, string $name, int $startsAt, int $endsAt) + { + if ($endsAt <= $startsAt) { + throw new \InvalidArgumentException('Price list end must be after its start.'); + } + + $this->uuid = Uuid::v4()->toRfc4122(); + $this->name = $name; + $this->startsAt = $startsAt; + $this->endsAt = $endsAt; + $this->createdAt = time(); + $this->updatedAt = time(); + $this->items = new ArrayCollection(); + + $this->assignTenantPair($entityType, $entityId); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getAddress(): ?DoctorAddress { return $this->address; } + public function getName(): string { return $this->name; } + public function getStartsAt(): int { return $this->startsAt; } + public function getEndsAt(): int { return $this->endsAt; } + public function isActive(): bool { return $this->active; } + + /** @return Collection */ + public function getItems(): Collection { return $this->items; } + + public function setAddress(?DoctorAddress $v): self { $this->address = $v; $this->touch(); return $this; } + public function setName(string $v): self { $this->name = $v; $this->touch(); return $this; } + public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; } + + public function covers(int $at): bool + { + return $this->active && $at >= $this->startsAt && $at < $this->endsAt; + } + + public function overlaps(int $startsAt, int $endsAt): bool + { + return $startsAt < $this->endsAt && $endsAt > $this->startsAt; + } + + public function touch(): void { $this->updatedAt = time(); } + + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'name' => $this->name, + 'address_uuid' => $this->address?->getUuid(), + 'address_name' => $this->address?->getName(), + 'starts_at' => $this->startsAt, + 'ends_at' => $this->endsAt, + 'active' => $this->active, + 'items' => array_map( + static fn (PriceListItem $i): array => $i->toArray(), + $this->items->toArray(), + ), + ]; + } +} diff --git a/src/Pricing/Entity/PriceListItem.php b/src/Pricing/Entity/PriceListItem.php new file mode 100644 index 00000000..931abd7e --- /dev/null +++ b/src/Pricing/Entity/PriceListItem.php @@ -0,0 +1,58 @@ +priceList = $priceList; + $this->serviceItem = $serviceItem; + $this->priceRials = $priceRials; + } + + public function getId(): ?int { return $this->id; } + public function getPriceList(): PriceList { return $this->priceList; } + public function getServiceItem(): ServiceItem { return $this->serviceItem; } + public function getPriceRials(): int { return (int) $this->priceRials; } + + public function toArray(): array + { + return [ + 'service_uuid' => $this->serviceItem->getUuid(), + 'service_name' => $this->serviceItem->getName(), + 'price_rials' => $this->getPriceRials(), + ]; + } +} diff --git a/src/Pricing/Entity/PriceSnapshot.php b/src/Pricing/Entity/PriceSnapshot.php new file mode 100644 index 00000000..dd32e9bf --- /dev/null +++ b/src/Pricing/Entity/PriceSnapshot.php @@ -0,0 +1,129 @@ + $breakdown */ + public function __construct( + Appointment $appointment, + string $entityType, + int $entityId, + int $baseRials, + int $itemsRials, + int $discountRials, + int $insuranceBaseRials, + int $insuranceSupplementaryRials, + int $taxRials, + int $finalRials, + int $depositRials, + array $breakdown = [], + ?int $computedAt = null, + ) { + $this->uuid = Uuid::v4()->toRfc4122(); + $this->appointment = $appointment; + $this->baseRials = $baseRials; + $this->itemsRials = $itemsRials; + $this->discountRials = $discountRials; + $this->insuranceBaseRials = $insuranceBaseRials; + $this->insuranceSupplementaryRials = $insuranceSupplementaryRials; + $this->taxRials = $taxRials; + $this->finalRials = $finalRials; + $this->depositRials = $depositRials; + $this->breakdown = $breakdown === [] ? null : $breakdown; + $this->computedAt = $computedAt ?? time(); + + $this->assignTenantPair($entityType, $entityId); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getAppointment(): Appointment { return $this->appointment; } + public function getFinalRials(): int { return (int) $this->finalRials; } + public function getDepositRials(): int { return (int) $this->depositRials; } + public function getComputedAt(): int { return $this->computedAt; } + + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'appointment_uuid' => $this->appointment->getUuid(), + 'base_rials' => (int) $this->baseRials, + 'items_rials' => (int) $this->itemsRials, + 'discount_rials' => (int) $this->discountRials, + 'insurance_base_rials' => (int) $this->insuranceBaseRials, + 'insurance_supplementary_rials' => (int) $this->insuranceSupplementaryRials, + 'tax_rials' => (int) $this->taxRials, + 'final_rials' => (int) $this->finalRials, + 'deposit_rials' => (int) $this->depositRials, + 'breakdown' => $this->breakdown ?? [], + 'computed_at' => $this->computedAt, + ]; + } +} diff --git a/src/Pricing/Repository/PriceListItemRepository.php b/src/Pricing/Repository/PriceListItemRepository.php new file mode 100644 index 00000000..950e69b1 --- /dev/null +++ b/src/Pricing/Repository/PriceListItemRepository.php @@ -0,0 +1,59 @@ + + */ +class PriceListItemRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, PriceListItem::class); + } + + public function deleteForList(PriceList $list): int + { + return (int) $this->createQueryBuilder('i') + ->delete() + ->where('i.priceList = :list') + ->setParameter('list', $list) + ->getQuery() + ->execute(); + } + + /** + * قیمت چند سرویس در یک لیست — یک کوئری، نه یکی per سرویس. + * + * @param ServiceItem[] $services + * @return array شناسهٔ سرویس => قیمت + */ + public function priceMap(PriceList $list, array $services): array + { + if ($services === []) { + return []; + } + + $rows = $this->createQueryBuilder('i') + ->select('IDENTITY(i.serviceItem) AS service_id, i.priceRials AS price') + ->where('i.priceList = :list') + ->andWhere('i.serviceItem IN (:services)') + ->setParameter('list', $list) + ->setParameter('services', $services) + ->getQuery() + ->getArrayResult(); + + $map = []; + foreach ($rows as $row) { + $map[(int) $row['service_id']] = (int) $row['price']; + } + + return $map; + } +} diff --git a/src/Pricing/Repository/PriceListRepository.php b/src/Pricing/Repository/PriceListRepository.php new file mode 100644 index 00000000..b622aeb5 --- /dev/null +++ b/src/Pricing/Repository/PriceListRepository.php @@ -0,0 +1,102 @@ + + */ +class PriceListRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, PriceList::class); + } + + public function findByUuid(string $uuid): ?PriceList + { + return $this->findOneBy(['uuid' => $uuid]); + } + + /** @return PriceList[] */ + public function findForPair(string $entityType, int $entityId): array + { + return $this->createQueryBuilder('p') + ->where('p.entityType = :type') + ->andWhere('p.entityId = :id') + ->setParameter('type', $entityType) + ->setParameter('id', $entityId) + ->orderBy('p.startsAt', 'DESC') + ->getQuery() + ->getResult(); + } + + /** + * لیست قیمتِ حاکم بر یک لحظه. + * + * لیستِ مخصوصِ همان شعبه بر لیست عمومیِ محیط مقدم است — وگرنه تعریف استثنا برای + * یک شعبه هیچ اثری نداشت. + */ + public function findCovering(string $entityType, int $entityId, ?DoctorAddress $address, int $at): ?PriceList + { + $rows = $this->createQueryBuilder('p') + ->where('p.entityType = :type') + ->andWhere('p.entityId = :id') + ->andWhere('p.active = true') + ->andWhere('p.startsAt <= :at') + ->andWhere('p.endsAt > :at') + ->setParameter('type', $entityType) + ->setParameter('id', $entityId) + ->setParameter('at', $at) + ->getQuery() + ->getResult(); + + $general = null; + + foreach ($rows as $list) { + if ($address !== null && $list->getAddress()?->getId() === $address->getId()) { + return $list; + } + + if ($list->getAddress() === null) { + $general = $list; + } + } + + return $general; + } + + /** + * لیست‌های فعالِ هم‌پوشان با یک بازه — برای جلوگیری از دو قیمتِ هم‌زمان. + * + * @return PriceList[] + */ + public function findOverlapping(PriceList $candidate): array + { + $qb = $this->createQueryBuilder('p') + ->where('p.entityType = :type') + ->andWhere('p.entityId = :id') + ->andWhere('p.active = true') + ->andWhere('p.startsAt < :ends') + ->andWhere('p.endsAt > :starts') + ->setParameter('type', $candidate->getEntityType()) + ->setParameter('id', $candidate->getEntityId()) + ->setParameter('starts', $candidate->getStartsAt()) + ->setParameter('ends', $candidate->getEndsAt()); + + if ($candidate->getId() !== null) { + $qb->andWhere('p.id != :self')->setParameter('self', $candidate->getId()); + } + + // فقط لیست‌هایی که دامنهٔ یکسانی دارند با هم تداخل دارند: لیست عمومی و لیست + // یک شعبه عمداً کنار هم زندگی می‌کنند و اولویت دارند، نه تداخل. + return array_values(array_filter( + $qb->getQuery()->getResult(), + static fn (PriceList $other): bool => $other->getAddress()?->getId() === $candidate->getAddress()?->getId(), + )); + } +} diff --git a/src/Pricing/Repository/PriceSnapshotRepository.php b/src/Pricing/Repository/PriceSnapshotRepository.php new file mode 100644 index 00000000..aa7942cc --- /dev/null +++ b/src/Pricing/Repository/PriceSnapshotRepository.php @@ -0,0 +1,24 @@ + + */ +class PriceSnapshotRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, PriceSnapshot::class); + } + + public function findForAppointment(Appointment $appointment): ?PriceSnapshot + { + return $this->findOneBy(['appointment' => $appointment]); + } +} diff --git a/src/Pricing/Service/PriceSnapshotService.php b/src/Pricing/Service/PriceSnapshotService.php new file mode 100644 index 00000000..97ff0ea0 --- /dev/null +++ b/src/Pricing/Service/PriceSnapshotService.php @@ -0,0 +1,77 @@ +snapshots->findForAppointment($appointment); + + if ($existing !== null) { + return $existing; + } + + $snapshot = new PriceSnapshot( + $appointment, + $appointment->getEntityType(), + $appointment->getEntityId(), + $quote->baseRials, + $quote->itemsRials, + $quote->discountRials, + $quote->insuranceBaseRials, + $quote->insuranceSupplementaryRials, + $quote->taxRials, + $quote->finalRials, + $quote->depositRials, + $quote->breakdown(), + $now, + ); + + $this->em->persist($snapshot); + $this->em->flush(); + + return $snapshot; + } + + /** + * نوبتِ بدون سرویس (ویزیت سادهٔ حالت اسلاتی) هم باید فاکتور داشته باشد؛ خالی + * گذاشتنش یعنی گزارش مالی یک ردیف کم دارد. + */ + public function recordFlatVisit(Appointment $appointment, int $priceRials, ?int $now = null): PriceSnapshot + { + return $this->record( + $appointment, + new PriceQuote( + baseRials: $priceRials, + itemsRials: 0, + discountRials: 0, + insuranceBaseRials: 0, + insuranceSupplementaryRials: 0, + taxRials: 0, + finalRials: $priceRials, + depositRials: 0, + sources: ['visit' => 'appointment_visit_price'], + ), + $now, + ); + } +} diff --git a/src/Pricing/Service/PricingEngine.php b/src/Pricing/Service/PricingEngine.php new file mode 100644 index 00000000..6bbbe8ff --- /dev/null +++ b/src/Pricing/Service/PricingEngine.php @@ -0,0 +1,218 @@ +tenantEntityType(); + $entityId = $address->tenantEntityId(); + + $list = $this->priceLists->findCovering($entityType, $entityId, $address, $at); + + $sources = []; + + $base = $this->priceFor($service, $address, $list, $at, $sources); + $itemsTotal = 0; + + foreach ($items as $item) { + $itemsTotal += $this->priceFor($item, $address, $list, $at, $sources); + } + + $subtotal = $base + $itemsTotal; + + // ── تخفیف ───────────────────────────────────────────────────────────── + [$discount, $discounts] = $this->discountFor($subtotal, $policy); + + // تخفیف بیشتر از مبلغ، مبلغ را **صفر** می‌کند نه منفی: بدهی منفی یعنی کلینیک + // به بیمار پول بدهکار شود، که هیچ‌جای این جریان معنا ندارد. + $discount = min($discount, $subtotal); + $afterDiscount = $subtotal - $discount; + + // ── بیمه ────────────────────────────────────────────────────────────── + $insuranceBase = $this->percentOf($afterDiscount, $policy['insurance_base_percent'] ?? 0.0); + $insuranceBase = min($insuranceBase, $afterDiscount); + + $remaining = $afterDiscount - $insuranceBase; + $supplementary = min($this->percentOf($remaining, $policy['insurance_supplementary_percent'] ?? 0.0), $remaining); + + $patientShare = $remaining - $supplementary; + + // ── مالیات ──────────────────────────────────────────────────────────── + // روی سهم بیمار حساب می‌شود، نه روی کل: بیمار مالیاتِ سهمی که بیمه می‌دهد را + // نمی‌پردازد. + $tax = $this->percentOf($patientShare, $policy['tax_percent'] ?? 0.0); + $final = $patientShare + $tax; + + // ── بیعانه ──────────────────────────────────────────────────────────── + $deposit = isset($policy['deposit_rials']) + ? (int) $policy['deposit_rials'] + : $this->percentOf($final, $policy['deposit_percent'] ?? 0.0); + + $deposit = max(0, min($deposit, $final)); + + return new PriceQuote( + baseRials: $base, + itemsRials: $itemsTotal, + discountRials: $discount, + insuranceBaseRials: $insuranceBase, + insuranceSupplementaryRials: $supplementary, + taxRials: $tax, + finalRials: $final, + depositRials: $deposit, + discounts: $discounts, + sources: $sources, + ); + } + + /** + * @param array $sources + */ + private function priceFor( + ServiceItem $service, + DoctorAddress $address, + ?\App\Pricing\Entity\PriceList $list, + int $at, + array &$sources, + ): int { + $override = $this->overrides->mapForAddress([(int) $service->getId()], $address)[(int) $service->getId()] ?? null; + + if ($override?->getPriceRials() !== null) { + $sources[$service->getUuid()] = 'branch_override'; + + return $override->getPriceRials(); + } + + if ($list !== null) { + $price = $this->priceListItems->priceMap($list, [$service])[(int) $service->getId()] ?? null; + + if ($price !== null) { + $sources[$service->getUuid()] = 'price_list'; + + return $price; + } + } + + $tariff = $this->tariffs->findForServiceYear((int) $service->getId(), $this->jalali->jalaliYear($at)); + + if ($tariff !== null) { + $sources[$service->getUuid()] = 'tariff'; + + return (int) $tariff->getPriceRials(); + } + + $sources[$service->getUuid()] = 'service_item'; + + return $service->getPriceRials(); + } + + /** + * @param array $policy + * @return array{0: int, 1: list>} + */ + private function discountFor(int $subtotal, array $policy): array + { + $discounts = []; + $total = 0; + + if (($policy['discount_percent'] ?? 0.0) > 0) { + $amount = $this->percentOf($subtotal, (float) $policy['discount_percent']); + $total += $amount; + + $discounts[] = [ + 'label' => $policy['discount_label'] ?? 'تخفیف درصدی', + 'percent' => $policy['discount_percent'], + 'rials' => $amount, + ]; + } + + if (($policy['discount_rials'] ?? 0) > 0) { + $amount = (int) $policy['discount_rials']; + $total += $amount; + + $discounts[] = [ + 'label' => $policy['discount_label'] ?? 'تخفیف مبلغی', + 'rials' => $amount, + ]; + } + + // سقف جمع تخفیف‌ها per محیط: چند تخفیفِ جداگانه که هرکدام منطقی‌اند، با هم + // می‌توانند مبلغ را بی‌معنا کنند. + $cap = $policy['max_total_discount_percent'] ?? null; + + if ($cap !== null && $cap >= 0) { + $maxAllowed = $this->percentOf($subtotal, (float) $cap); + + if ($total > $maxAllowed) { + $discounts[] = [ + 'label' => sprintf('سقف تخفیف %s٪ اعمال شد', $cap), + 'rials' => $maxAllowed - $total, + ]; + $total = $maxAllowed; + } + } + + return [$total, $discounts]; + } + + /** ریال واحد صحیح است؛ گرد کردن به پایین از اضافه‌گرفتن جلوگیری می‌کند. */ + private function percentOf(int $amount, float $percent): int + { + if ($percent <= 0) { + return 0; + } + + return (int) floor($amount * $percent / 100); + } +} diff --git a/src/Pricing/ValueObject/PriceQuote.php b/src/Pricing/ValueObject/PriceQuote.php new file mode 100644 index 00000000..be950faf --- /dev/null +++ b/src/Pricing/ValueObject/PriceQuote.php @@ -0,0 +1,46 @@ +> $discounts */ + public function __construct( + public int $baseRials, + public int $itemsRials, + public int $discountRials, + public int $insuranceBaseRials, + public int $insuranceSupplementaryRials, + public int $taxRials, + public int $finalRials, + public int $depositRials, + public array $discounts = [], + public array $sources = [], + ) {} + + public function breakdown(): array + { + return ['discounts' => $this->discounts, 'sources' => $this->sources]; + } + + public function toArray(): array + { + return [ + 'base_rials' => $this->baseRials, + 'items_rials' => $this->itemsRials, + 'discount_rials' => $this->discountRials, + 'insurance_base_rials' => $this->insuranceBaseRials, + 'insurance_supplementary_rials' => $this->insuranceSupplementaryRials, + 'tax_rials' => $this->taxRials, + 'final_rials' => $this->finalRials, + 'deposit_rials' => $this->depositRials, + 'breakdown' => $this->breakdown(), + ]; + } +} diff --git a/src/Shared/Tenant/GlobalTables.php b/src/Shared/Tenant/GlobalTables.php index 6939593e..c5ea23a6 100644 --- a/src/Shared/Tenant/GlobalTables.php +++ b/src/Shared/Tenant/GlobalTables.php @@ -109,6 +109,7 @@ final class GlobalTables \App\ClinicService\Entity\ServiceItemConsumable::class => \App\ClinicService\Entity\ServiceItem::class, \App\ClinicService\Entity\Tariff::class => \App\ClinicService\Entity\ServiceItem::class, \App\ClinicService\Entity\ItemGroupMember::class => \App\ClinicService\Entity\ItemGroup::class, + \App\Pricing\Entity\PriceListItem::class => \App\Pricing\Entity\PriceList::class, \App\Billing\Entity\ClaimItem::class => \App\Billing\Entity\Claim::class, \App\Billing\Entity\ClaimStatusLog::class => \App\Billing\Entity\Claim::class, diff --git a/tests/Pricing/PricingTest.php b/tests/Pricing/PricingTest.php new file mode 100644 index 00000000..51464ce2 --- /dev/null +++ b/tests/Pricing/PricingTest.php @@ -0,0 +1,367 @@ +createUser(['ROLE_USER', 'ROLE_CLINIC']); + $clinic = new Clinic($user); + $clinic->setName('کلینیک قیمت'); + $this->em->persist($clinic); + $this->em->flush(); + + $section = new ServiceSection('clinic', $clinic->getId(), 'زیبایی'); + $this->em->persist($section); + + $address = DoctorAddress::forClinic($clinic->getId()); + $address->setName('شعبهٔ مرکزی'); + $this->em->persist($address); + $this->em->flush(); + + return ['user' => $user, 'section' => $section, 'address' => $address]; + } + + private function service(ServiceSection $section, string $name, int $price): ServiceItem + { + $section = $this->em->getRepository(ServiceSection::class)->find($section->getId()); + + $item = new ServiceItem($section, $name); + $item->setPriceRials($price); + $item->setSoloDurationMinutes(20); + $this->em->persist($item); + $this->em->flush(); + + return $item; + } + + /** @param array $extra */ + private function quote(User $user, ServiceItem $service, DoctorAddress $address, array $extra = []): array + { + return $this->authJson('POST', '/api/v1/pricing/quote', $user, $extra + [ + 'service_uuid' => $service->getUuid(), + 'branch_uuid' => $address->getUuid(), + ]); + } + + private function priceList(User $user, string $name, int $from, int $to, ?string $addressUuid = null): array + { + $body = $this->authJson('POST', '/api/v1/price-lists', $user, array_filter([ + 'name' => $name, + 'starts_at' => $from, + 'ends_at' => $to, + 'address_uuid' => $addressUuid, + ])); + self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + + return $body['data']; + } + + /** بدون هیچ لیست قیمتی، قیمت خودِ سرویس برمی‌گردد — هرگز صفر یا خطا. */ + public function testFallsBackToTheServicePrice(): void + { + $c = $this->clinic(); + $service = $this->service($c['section'], 'بوتاکس', 5_000_000); + + $body = $this->quote($c['user'], $service, $c['address']); + + self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertSame(5_000_000, $body['data']['base_rials']); + self::assertSame(5_000_000, $body['data']['final_rials']); + self::assertSame('service_item', $body['data']['breakdown']['sources'][$service->getUuid()]); + } + + /** لیست قیمت فقط در بازهٔ خودش حاکم است. */ + public function testPriceListAppliesOnlyInsideItsRange(): void + { + $c = $this->clinic(); + $service = $this->service($c['section'], 'بوتاکس', 5_000_000); + + $from = strtotime('+10 days'); + $to = strtotime('+40 days'); + + $list = $this->priceList($c['user'], 'نیمهٔ دوم', $from, $to); + + $this->authJson('PUT', "/api/v1/price-list/{$list['uuid']}/items", $c['user'], [ + 'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => 8_000_000]], + ]); + self::assertSame(200, $this->responseCode()); + + $this->authJson('POST', "/api/v1/price-list/{$list['uuid']}/activate", $c['user']); + self::assertSame(200, $this->responseCode()); + + $inside = $this->quote($c['user'], $service, $c['address'], ['at' => $from + 86400]); + self::assertSame(8_000_000, $inside['data']['base_rials'], 'داخل بازه: قیمت جدید'); + + $before = $this->quote($c['user'], $service, $c['address'], ['at' => $from - 86400]); + self::assertSame(5_000_000, $before['data']['base_rials'], 'پیش از بازه: قیمت قبلی'); + } + + /** دو لیست فعالِ هم‌پوشان یعنی یک تاریخ دو قیمت — هنگام فعال‌سازی رد می‌شود. */ + public function testOverlappingActiveListsAreRejected(): void + { + $c = $this->clinic(); + $from = strtotime('+10 days'); + + $first = $this->priceList($c['user'], 'اول', $from, $from + 30 * 86400); + $this->authJson('POST', "/api/v1/price-list/{$first['uuid']}/activate", $c['user']); + self::assertSame(200, $this->responseCode()); + + $second = $this->priceList($c['user'], 'دوم', $from + 10 * 86400, $from + 50 * 86400); + $body = $this->authJson('POST', "/api/v1/price-list/{$second['uuid']}/activate", $c['user']); + + self::assertSame(422, $this->responseCode()); + self::assertStringContainsString('هم‌پوشانی', $body['errors'][0]['message']); + } + + /** لیستِ یک شعبه با لیست عمومی تداخل ندارد و بر آن مقدم است. */ + public function testBranchListWinsOverTheGeneralList(): void + { + $c = $this->clinic(); + $service = $this->service($c['section'], 'بوتاکس', 5_000_000); + + $from = strtotime('+10 days'); + $to = $from + 30 * 86400; + + $general = $this->priceList($c['user'], 'عمومی', $from, $to); + $this->authJson('PUT', "/api/v1/price-list/{$general['uuid']}/items", $c['user'], [ + 'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => 7_000_000]], + ]); + $this->authJson('POST', "/api/v1/price-list/{$general['uuid']}/activate", $c['user']); + self::assertSame(200, $this->responseCode()); + + $branch = $this->priceList($c['user'], 'شعبهٔ مرکزی', $from, $to, $c['address']->getUuid()); + $this->authJson('PUT', "/api/v1/price-list/{$branch['uuid']}/items", $c['user'], [ + 'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => 9_000_000]], + ]); + $this->authJson('POST', "/api/v1/price-list/{$branch['uuid']}/activate", $c['user']); + self::assertSame(200, $this->responseCode(), 'لیست شعبه با لیست عمومی تداخل ندارد'); + + $body = $this->quote($c['user'], $service, $c['address'], ['at' => $from + 86400]); + + self::assertSame(9_000_000, $body['data']['base_rials']); + } + + /** override شعبه (تسک ۰۴) بر لیست قیمت مقدم است. */ + public function testBranchOverrideBeatsThePriceList(): void + { + $c = $this->clinic(); + $service = $this->service($c['section'], 'بوتاکس', 5_000_000); + + $from = strtotime('+10 days'); + $list = $this->priceList($c['user'], 'عمومی', $from, $from + 30 * 86400); + $this->authJson('PUT', "/api/v1/price-list/{$list['uuid']}/items", $c['user'], [ + 'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => 7_000_000]], + ]); + $this->authJson('POST', "/api/v1/price-list/{$list['uuid']}/activate", $c['user']); + + $this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/branch-overrides", $c['user'], [ + 'overrides' => [['address_uuid' => $c['address']->getUuid(), 'price_rials' => 11_000_000]], + ]); + self::assertSame(200, $this->responseCode()); + + $body = $this->quote($c['user'], $service, $c['address'], ['at' => $from + 86400]); + + self::assertSame(11_000_000, $body['data']['base_rials']); + self::assertSame('branch_override', $body['data']['breakdown']['sources'][$service->getUuid()]); + } + + public function testFullChainAppliesInOrder(): void + { + $c = $this->clinic(); + $service = $this->service($c['section'], 'لیزر', 10_000_000); + $extra = $this->service($c['section'], 'ناحیهٔ اضافه', 2_000_000); + + $body = $this->quote($c['user'], $service, $c['address'], [ + 'item_uuids' => [$extra->getUuid()], + 'policy' => [ + 'discount_percent' => 10, + 'insurance_base_percent' => 20, + 'insurance_supplementary_percent' => 50, + 'tax_percent' => 10, + 'deposit_percent' => 30, + ], + ]); + + $d = $body['data']; + + self::assertSame(10_000_000, $d['base_rials']); + self::assertSame(2_000_000, $d['items_rials']); + self::assertSame(1_200_000, $d['discount_rials'], '۱۰٪ از ۱۲ میلیون'); + self::assertSame(2_160_000, $d['insurance_base_rials'], '۲۰٪ از ۱۰٫۸ میلیون'); + self::assertSame(4_320_000, $d['insurance_supplementary_rials'], '۵۰٪ از باقیمانده'); + self::assertSame(432_000, $d['tax_rials'], '۱۰٪ روی سهم بیمار، نه روی کل'); + self::assertSame(4_752_000, $d['final_rials']); + self::assertSame(1_425_600, $d['deposit_rials']); + } + + /** تخفیف بیشتر از مبلغ، مبلغ را صفر می‌کند نه منفی. */ + public function testDiscountLargerThanTheAmountFloorsAtZero(): void + { + $c = $this->clinic(); + $service = $this->service($c['section'], 'ویزیت', 1_000_000); + + $body = $this->quote($c['user'], $service, $c['address'], [ + 'policy' => ['discount_rials' => 5_000_000], + ]); + + self::assertSame(0, $body['data']['final_rials']); + self::assertGreaterThanOrEqual(0, $body['data']['discount_rials']); + } + + /** سقف جمع تخفیف‌ها per محیط اعمال می‌شود. */ + public function testTotalDiscountCapIsApplied(): void + { + $c = $this->clinic(); + $service = $this->service($c['section'], 'ویزیت', 10_000_000); + + $body = $this->quote($c['user'], $service, $c['address'], [ + 'policy' => [ + 'discount_percent' => 40, + 'discount_rials' => 3_000_000, + 'max_total_discount_percent' => 25, + ], + ]); + + self::assertSame(2_500_000, $body['data']['discount_rials'], 'سقف ۲۵٪ از ۱۰ میلیون'); + self::assertSame(7_500_000, $body['data']['final_rials']); + } + + public function testForeignServiceIsNotFound(): void + { + $c = $this->clinic(); + $other = $this->clinic(); + $foreign = $this->service($other['section'], 'سرویس بیگانه', 1_000_000); + + $this->quote($c['user'], $foreign, $c['address']); + + self::assertSame(404, $this->responseCode()); + } + + public function testNegativePriceIsRejected(): void + { + $c = $this->clinic(); + $service = $this->service($c['section'], 'ویزیت', 1_000_000); + $list = $this->priceList($c['user'], 'تست', strtotime('+1 day'), strtotime('+30 days')); + + $body = $this->authJson('PUT', "/api/v1/price-list/{$list['uuid']}/items", $c['user'], [ + 'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => -100]], + ]); + + self::assertSame(422, $this->responseCode()); + self::assertSame('price_rials', $body['errors'][0]['field']); + } + + /** + * ⭐ قانون پنجم مستند: «تغییر قیمت هرگز نوبت‌های ثبت‌شده را عوض نمی‌کند.» + * + * نوبت ثبت می‌شود، بعد قیمت سرویس دو برابر می‌شود، و فاکتور همان اعداد قبلی را + * می‌دهد. بدون این تست، تسک تأییدشده نیست. + */ + public function testBookedAppointmentKeepsItsOriginalInvoiceAfterAPriceChange(): void + { + $c = $this->clinic(); + $service = $this->service($c['section'], 'لیزر', 4_000_000); + + $room = new \App\Resource\Entity\ResourceType( + $c['address']->tenantEntityType(), + $c['address']->tenantEntityId(), + 'room', + 'اتاق', + ); + $this->em->persist($room); + $this->em->flush(); + + $resource = $this->authJson('POST', '/api/v1/resource', $c['user'], [ + 'address_uuid' => $c['address']->getUuid(), + 'type_uuid' => $room->getUuid(), + 'name' => 'اتاق ۱', + ]); + self::assertSame(201, $this->responseCode()); + + $this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/calendar", $c['user'], [ + 'days' => array_fill_keys(range(0, 6), [['start_minute' => 0, 'end_minute' => 1440]]), + ]); + + $this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/segments", $c['user'], [ + 'segments' => [[ + 'sequence' => 1, 'name' => 'لیزر', 'duration_minutes' => 20, + 'requirements' => [['type_uuid' => $room->getUuid()]], + ]], + ]); + self::assertSame(200, $this->responseCode()); + + $doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']); + $doctor = new \App\Doctor\Entity\Doctor($doctorUser, 'دکتر فاکتور'); + $this->em->persist($doctor); + $this->em->flush(); + + $start = (new \DateTimeImmutable('next saturday', new \DateTimeZone('Asia/Tehran'))) + ->setTime(9, 0) + ->getTimestamp(); + + $hold = $this->authJson('POST', '/api/v1/appointment-hold', $c['user'], [ + 'service_uuid' => $service->getUuid(), + 'branch_uuid' => $c['address']->getUuid(), + 'start' => $start, + 'assignment' => ['room' => [$resource['data']['uuid']]], + ]); + self::assertSame(201, $this->responseCode(), json_encode($hold, JSON_UNESCAPED_UNICODE)); + + $confirmed = $this->authJson('POST', '/api/v1/appointment-confirm', $c['user'], [ + 'hold_uuid' => $hold['data']['hold_uuid'], + 'doctor_uuid' => $doctor->getUuid(), + 'service_uuid' => $service->getUuid(), + 'branch_uuid' => $c['address']->getUuid(), + ]); + self::assertSame(200, $this->responseCode(), json_encode($confirmed, JSON_UNESCAPED_UNICODE)); + self::assertSame(4_000_000, $confirmed['data']['price_snapshot']['final_rials']); + + $appointmentUuid = $confirmed['data']['appointment_uuid']; + + // حالا قیمت دو برابر می‌شود. + $this->em->clear(); + $reloaded = $this->em->getRepository(ServiceItem::class)->findOneBy(['uuid' => $service->getUuid()]); + $reloaded->setPriceRials(8_000_000); + $this->em->flush(); + + // قیمت جدید در quote دیده می‌شود… + $fresh = $this->quote($c['user'], $reloaded, $c['address']); + self::assertSame(8_000_000, $fresh['data']['final_rials']); + + // …ولی فاکتور نوبتِ ثبت‌شده دست‌نخورده است. + $snapshot = $this->authJson('GET', "/api/v1/appointment/$appointmentUuid/price-snapshot", $c['user']); + + self::assertSame(200, $this->responseCode(), json_encode($snapshot, JSON_UNESCAPED_UNICODE)); + self::assertSame(4_000_000, $snapshot['data']['final_rials'], 'قانون پنجم: فاکتور ثبت‌شده عوض نمی‌شود'); + self::assertSame(4_000_000, $snapshot['data']['base_rials']); + } + + public function testDraftListHasNoEffectUntilActivated(): void + { + $c = $this->clinic(); + $service = $this->service($c['section'], 'بوتاکس', 5_000_000); + + $from = strtotime('+2 days'); + $list = $this->priceList($c['user'], 'پیش‌نویس', $from, $from + 30 * 86400); + $this->authJson('PUT', "/api/v1/price-list/{$list['uuid']}/items", $c['user'], [ + 'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => 9_999_999]], + ]); + + $body = $this->quote($c['user'], $service, $c['address'], ['at' => $from + 86400]); + + self::assertSame(5_000_000, $body['data']['base_rials'], 'پیش‌نویس نباید قیمت را عوض کند'); + } +}