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.
This commit is contained in:
hamed
2026-07-30 11:43:58 +03:30
parent 1d338503c8
commit 021d0eb6b2
62 changed files with 8098 additions and 0 deletions
@@ -0,0 +1,145 @@
# معماری — تسک ۰۸
## ساختار فایل
```
src/Pricing/
├── Entity/
│ ├── PriceList.php
│ ├── PriceListItem.php
│ ├── PriceSnapshot.php
│ ├── PriceSnapshotLine.php
│ └── DepositPolicy.php
├── Service/
│ ├── PricingEngine.php # ارکستراتور هفت‌مرحله‌ای
│ ├── PriceResolver.php # قیمت پایه: لیست → تعرفه → سرویس
│ ├── SnapshotWriter.php
│ └── DepositCalculator.php
├── Dto/{PriceQuote, PriceLine}.php
├── Controller/{PriceListController, PricingController}.php
└── Repository/…
```
## `PricingEngine` — هفت مرحلهٔ مستند
```php
public function quote(QuoteRequest $req): PriceQuote
{
$lines = [];
// ۱ قیمت پایهٔ سرویس (از لیست قیمت معتبر در تاریخ رزرو)
$lines[] = PriceLine::base($this->resolver->servicePrice($req->service, $req->branch, $req->at));
// ۲ جمع قیمت آیتم‌های انتخابی
foreach ($req->options as $option) {
$lines[] = PriceLine::option($option, $this->resolver->optionPrice($option, $req->branch, $req->at));
}
// ۳ قوانین قیمت به ترتیب اولویت ← DiscountEngine موجود، بعداً تسک ۰۹
$lines = $this->discounts->apply($lines, $req);
// ۴ کسر از اعتبار پکیج ← قلاب تسک ۱۱ (فعلاً no-op)
$lines = $this->packages->consume($lines, $req);
// ۵ مالیات و سهم بیمه ← AppointmentInsuranceService موجود
$lines = $this->insurance->apply($lines, $req);
$lines = $this->tax->apply($lines, $req);
// ۶ بیعانه
$deposit = $this->depositCalculator->forQuote($lines, $req);
// ۷ خروجی تفکیک‌شده (ذخیره فقط در confirm انجام می‌شود)
return new PriceQuote($lines, $deposit);
}
```
هر مرحله یک سرویس مستقل با اینترفیس خودش. مرحلهٔ ۳ و ۴ از روز اول در زنجیره هستند حتی
وقتی خالی‌اند — همان دلیل تسک ۰۵: امضای عمومی بعداً عوض نشود.
## `PriceResolver` — ترتیب اولویت
```
۱. ServiceBranchOverride.price_rials (تسک ۰۴ — اختصاصی‌ترین)
۲. PriceListItem از PriceList فعالی که تاریخ رزرو را می‌پوشاند و شعبه‌اش مطابق است
۳. PriceListItem از PriceList فعال محیط (بدون شعبه)
۴. Tariff::findForServiceYear(سال شمسی تاریخ رزرو) ← موجود، دست‌نخورده
۵. ServiceItem.price_rials ← آخرین fallback
```
هیچ‌وقت خطا یا صفر برنمی‌گرداند. سطر ۴ و ۵ تضمین می‌کنند همهٔ داده‌های موجود بدون هیچ
لیست قیمتی درست کار کنند.
**تاریخ مبنا:** تاریخ **رزرو** (`slot_start`)، نه تاریخ ثبت. مستند بند ۱۲: «از لیست قیمت
معتبر در تاریخ رزرو». اگر بیمار امروز برای سه ماه بعد نوبت بگیرد، قیمت آن روز اعمال می‌شود.
این تصمیم را در `docs/api/pricing.md` صریح بنویس — دو تفسیر دارد و پشتیبانی از هر دو
غیرممکن است.
## `PriceSnapshot` — فاکتور منجمد
```php
class PriceSnapshot
{
use TenantOwnedTrait;
private Appointment $appointment;
private int $baseRials;
private int $optionsRials;
private int $discountRials;
private int $insuranceBaseRials;
private int $insuranceSupplementaryRials;
private int $taxRials;
private int $finalRials;
private int $depositRials;
private array $appliedPolicyIds = []; // قانون‌های اعمال‌شده — قانون پنجم مستند
private int $createdAt;
private Collection $lines; // PriceSnapshotLine
}
```
`appliedPolicyIds` از روز اول: مستند بند ۸ می‌گوید «هر نوبت فهرست قانون‌هایی که رویش
اعمال شده را ذخیره می‌کند». تسک ۰۹ نسخهٔ قانون‌ها را هم اضافه می‌کند؛ فعلاً شناسهٔ
`DiscountRule` ها ثبت می‌شود.
`PriceSnapshotLine` ردیف‌های تفکیک‌شده: نوع (`base`|`option`|`discount`|`insurance`|`tax`
نام، مبلغ، ارجاع اختیاری به منبع (سرویس/آیتم/قانون).
## رابطه با `Invoice` موجود
`Invoice`/`InvoiceItem` (دامنهٔ `Billing`) **باقی می‌مانند** و کارشان صورتحساب مراجعهٔ
انجام‌شده است. `PriceSnapshot` کار متفاوتی می‌کند: قیمت **لحظهٔ رزرو**.
| | `PriceSnapshot` | `Invoice` |
|---|---|---|
| کِی ساخته می‌شود | `confirm` نوبت | پایان مراجعه |
| چه چیزی را ثبت می‌کند | آن‌چه قرار بود پرداخت شود | آن‌چه واقعاً انجام و صورتحساب شد |
| تغییر می‌کند | هرگز | تا تسویه |
اگر بیمار سر نوبت خدمت اضافه بگیرد، `Invoice` فرق می‌کند و `PriceSnapshot` نه — و همین
تفاوت، منبع گزارش «اختلاف پیش‌بینی و واقعیت» است.
این جدول را در `docs/architecture/insurance-billing-system.md` اضافه کن، وگرنه اولین
کسی که هر دو را می‌بیند یکی را حذف می‌کند.
## سیاست بیعانه
```php
class DepositPolicy
{
use TenantOwnedTrait;
private ?ServiceItem $service = null; // null = پیش‌فرض محیط
private string $mode; // none | fixed | percent
private int $value = 0;
private ?int $minRials = null;
private ?int $maxRials = null;
}
```
`DepositCalculator` اختصاصی‌ترین سیاست را می‌گیرد (سرویس بر محیط) و مقدار را به
`Appointment.deposit_required/deposit_amount_rials` موجود می‌نویسد — ستون‌های جدید لازم نیست.
## پنل ادمین
- `PriceListsPage.tsx` — لیست با بازهٔ شمسی و وضعیت (پیش‌نویس/فعال/منقضی)
- `PriceListFormPage.tsx` — بازهٔ تاریخ با `PersianDatePicker`، شعبه با `SearchableSelect`،
جدول سرویس‌ها با `PriceInput`
- در `AppointmentDetailPage.tsx` یک کارت «فاکتور» با ردیف‌های snapshot
- «کپی از لیست قیمت قبلی» — بدون آن، کلینیک با ۲۰۰ سرویس هرگز لیست جدید نمی‌سازد
@@ -0,0 +1,143 @@
# دیتابیس — تسک ۰۸
## `price_lists`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
| `branch_id` | INT NULL | NULL = همهٔ شعب محیط |
| `name` | VARCHAR(150) NOT NULL | «نیمهٔ دوم ۱۴۰۵» |
| `valid_from` | INT NOT NULL | نیمه‌شب روز شروع |
| `valid_to` | INT NULL | NULL = بی‌پایان |
| `status` | VARCHAR(10) NOT NULL DEFAULT 'draft' | `draft`\|`active`\|`archived` |
| `created_at`/`updated_at` | INT NOT NULL | |
```sql
KEY idx_price_lists_tenant (entity_type, entity_id, status, valid_from)
KEY idx_price_lists_branch (branch_id, status, valid_from)
```
تداخل بازه در سطح اپلیکیشن بررسی می‌شود (`activate`)، نه DB — MariaDB محدودیت بازه‌ای ندارد
و راه سطل زمانی تسک ۰۷ اینجا بی‌مورد است چون تعداد لیست‌ها کم و تغییرشان نادر است.
## `price_list_items`
```sql
CREATE TABLE price_list_items (
id INT PRIMARY KEY AUTO_INCREMENT,
price_list_id INT NOT NULL,
service_item_id INT NULL, -- قیمت سرویس
service_option_id INT NULL, -- قیمت آیتم
price_rials INT NOT NULL,
UNIQUE KEY uniq_pli_service (price_list_id, service_item_id),
UNIQUE KEY uniq_pli_option (price_list_id, service_option_id),
KEY idx_pli_list (price_list_id),
CONSTRAINT fk_pli_list FOREIGN KEY (price_list_id) REFERENCES price_lists(id) ON DELETE CASCADE,
CONSTRAINT fk_pli_service FOREIGN KEY (service_item_id) REFERENCES service_items(id) ON DELETE CASCADE,
CONSTRAINT fk_pli_option FOREIGN KEY (service_option_id) REFERENCES service_options(id) ON DELETE CASCADE
);
```
دقیقاً یکی از `service_item_id` / `service_option_id` غیر-NULL (قید اپلیکیشنی).
فرزند aggregate با ریشهٔ `PriceList`.
## `price_snapshots`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
| `appointment_id` | INT NOT NULL UNIQUE | FK ON DELETE CASCADE — یک snapshot per نوبت |
| `base_rials` | INT NOT NULL | |
| `options_rials` | INT NOT NULL DEFAULT 0 | |
| `discount_rials` | INT NOT NULL DEFAULT 0 | |
| `insurance_base_rials` | INT NOT NULL DEFAULT 0 | |
| `insurance_supplementary_rials` | INT NOT NULL DEFAULT 0 | |
| `tax_rials` | INT NOT NULL DEFAULT 0 | |
| `final_rials` | INT NOT NULL | |
| `deposit_rials` | INT NOT NULL DEFAULT 0 | |
| `applied_policy_ids` | JSON NULL | `[{id, version}]` |
| `price_list_id` | INT NULL | FK SET NULL — کدام لیست مبنا بود |
| `created_at` | INT NOT NULL | |
```sql
UNIQUE KEY uniq_snapshot_appointment (appointment_id)
KEY idx_snapshot_tenant (entity_type, entity_id, created_at)
```
`UNIQUE` روی `appointment_id`: یک نوبت یک فاکتور رزرو دارد. `reschedule` نوبت **جدید**
می‌سازد (تسک ۰۷) پس snapshot جدید هم می‌گیرد و قدیمی سالم می‌ماند.
⚠️ همهٔ مبالغ `INT` ریال. `DECIMAL` یا `FLOAT` ننویس — بقیهٔ پروژه (`price_rials`,
`visit_price_rials`, `deposit_amount_rials`) همه `INT` ریال‌اند و قاطی کردن دو نوع یعنی
خطای گردکردن در جمع فاکتور.
## `price_snapshot_lines`
```sql
CREATE TABLE price_snapshot_lines (
id INT PRIMARY KEY AUTO_INCREMENT,
snapshot_id INT NOT NULL,
kind VARCHAR(15) NOT NULL, -- base|option|discount|insurance|tax|package
label VARCHAR(200) NOT NULL, -- snapshot متنی — نام لحظهٔ ثبت
amount_rials INT NOT NULL, -- منفی برای تخفیف و سهم بیمه
source_type VARCHAR(20) NULL, -- service|option|discount_rule|policy|insurance
source_id INT NULL, -- بدون FK — منبع ممکن است حذف شود
sort_order SMALLINT NOT NULL DEFAULT 0,
KEY idx_psl_snapshot (snapshot_id, sort_order),
CONSTRAINT fk_psl_snapshot FOREIGN KEY (snapshot_id) REFERENCES price_snapshots(id) ON DELETE CASCADE
);
```
`source_id` **بدون FK** عمدی: قانون تخفیف ممکن است فردا حذف شود ولی فاکتور دیروز باید
همان‌طور بماند. `label` هم به همین دلیل کپی متنی است، نه JOIN.
## `deposit_policies`
```sql
CREATE TABLE deposit_policies (
id INT PRIMARY KEY AUTO_INCREMENT,
uuid VARCHAR(36) NOT NULL UNIQUE,
entity_type VARCHAR(10) NOT NULL,
entity_id INT NOT NULL,
service_item_id INT NULL, -- NULL = پیش‌فرض محیط
mode VARCHAR(10) NOT NULL, -- none|fixed|percent
value INT NOT NULL DEFAULT 0,
min_rials INT NULL,
max_rials INT NULL,
active TINYINT(1) NOT NULL DEFAULT 1,
created_at INT NOT NULL,
updated_at INT NOT NULL,
UNIQUE KEY uniq_deposit_scope (entity_type, entity_id, service_item_id),
KEY idx_deposit_tenant (entity_type, entity_id, active)
);
```
## تغییر جدول موجود
هیچ. `appointments.visit_price_rials`, `deposit_required`, `deposit_amount_rials`,
`insurance_base_id`, `insurance_supplementary_id` همه استفاده می‌شوند و کافی‌اند.
`Tariff` هم دست‌نخورده می‌ماند و در زنجیرهٔ `PriceResolver` سطر ۴ است.
## Migration
```bash
ddev exec php bin/console doctrine:migrations:diff --no-interaction
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
ddev exec php bin/console app:pricing:backfill-snapshots --force
```
`app:pricing:backfill-snapshots` برای نوبت‌های `confirmed` آینده که snapshot ندارند، یکی
از `visit_price_rials` موجود می‌سازد (یک ردیف `base`). بدون آن، صفحهٔ فاکتور برای
نوبت‌های موجود خالی است.
## طبقه‌بندی tenant
| جدول | وضعیت |
|---|---|
| `price_lists`, `price_snapshots`, `deposit_policies` | جفت tenant |
| `price_list_items`, `price_snapshot_lines` | `AGGREGATE_CHILDREN` |
@@ -0,0 +1,136 @@
# نکات پیاده‌سازی — تسک ۰۸
## ۱. تاریخ مبنا: تاریخ رزرو، نه تاریخ ثبت
```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` به‌روز کن — این تنها راه جلوگیری از حذف یکی از آن‌ها در
آیندهٔ نزدیک است.
@@ -0,0 +1,81 @@
# تسک ۰۸ — لیست قیمت بازه‌دار و snapshot فاکتور
**فاز:** ۱ (هسته) · **وابستگی:** ۰۴، ۰۷ · **زمان:** ۱۲-۱۴ ساعت
---
## هدف
مستند بند ۱۲: قیمت یک لایهٔ جداست با زندگی خودش (تاریخ اعتبار، مالیات، بیمه، بیعانه) و
قانون پنجم: **تغییر قیمت هرگز نوبت‌های ثبت‌شده را عوض نمی‌کند.**
## وضعیت فعلی
زنجیرهٔ قیمت امروز واقعاً وجود دارد و کار می‌کند:
```
ServiceItem.price_rials
→ Tariff (سال‌محور: findForServiceYear)
→ TenantServiceCoverage / TenantInsurance (بیمهٔ پایه و تکمیلی)
→ DiscountRule + DiscountEngine
→ Invoice / InvoiceItem
→ Payment
```
روی نوبت هم `visit_price_rials`, `deposit_required`, `deposit_amount_rials`,
`insurance_base_id`, `insurance_supplementary_id` هست.
**دو شکاف:**
1. `Tariff` فقط **سال** دارد، بازهٔ دقیق تاریخ ندارد. تغییر تعرفه وسط سال قابل بیان نیست.
2. `visit_price_rials` یک عدد است. فاکتور **تفکیک‌شده** روی نوبت ذخیره نمی‌شود، پس بعد از
تغییر قیمت یا تخفیف، نمی‌شود گفت آن ۲٬۴۰۰٬۰۰۰ ریال از چه تشکیل شده بود.
## دامنه
**هست:**
- `PriceList` (بازهٔ تاریخ + شعبه) و `PriceListItem`
- `PriceSnapshot` — فاکتور تفکیک‌شدهٔ لحظهٔ ثبت نوبت
- `PricingEngine` — زنجیرهٔ هفت‌مرحله‌ای مستند بند ۱۲
- سیاست بیعانه per سرویس/محیط
- اتصال به `BookingService::confirm()` (قلاب مرحلهٔ ۶ تسک ۰۷)
**نیست:** پکیج و دفتر اعتبار (تسک ۱۱)، قوانین قیمت پیشرفته (تسک ۰۹ — `DiscountRule`
موجود فعلاً کافی است).
## Endpoint ها
| متد | مسیر | توضیح |
|---|---|---|
| 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` | فعال‌سازی (بررسی تداخل بازه) |
| POST | `/api/v1/pricing/quote` | محاسبهٔ قیمت بدون ثبت |
| GET | `/api/v1/appointment/{uuid}/price-snapshot` | فاکتور تفکیک‌شدهٔ نوبت |
## معیار پذیرش
- ✅ موفق: لیست قیمت «نیمهٔ دوم ۱۴۰۵» با بازهٔ ۱۴۰۵/۰۷/۰۱ تا ۱۴۰۵/۱۲/۲۹ فعال می‌شود؛
`POST /pricing/quote` برای تاریخ مهر قیمت جدید و برای شهریور قیمت قبلی می‌دهد.
- ✅ موفق: `confirm` نوبت → `price_snapshots` یک ردیف با تفکیک کامل دارد:
قیمت پایه، جمع آیتم‌ها، تخفیف‌های اعمال‌شده (با نام و مبلغ هر کدام)، سهم بیمهٔ پایه،
سهم تکمیلی، مالیات، مبلغ نهایی، بیعانه.
- ✅ موفق (**قانون پنجم**): بعد از ثبت نوبت، قیمت سرویس دو برابر می‌شود →
`GET /appointment/{uuid}/price-snapshot` **همان اعداد قبلی** را می‌دهد.
- ✅ موفق: قیمت override شعبه (تسک ۰۴) بر لیست قیمت محیط اولویت دارد.
- ❌ خطا: دو لیست قیمت فعال با بازهٔ هم‌پوشان برای یک شعبه → `422` هنگام `activate`.
- ❌ خطا: `quote` با سرویس محیط دیگر → `404`.
- ⚠️ مرزی: تاریخی که هیچ لیست قیمتی نمی‌پوشاند → fallback به `Tariff` سال، بعد به
`ServiceItem.price_rials`. هرگز صفر یا خطا.
- ⚠️ مرزی: تخفیف بیشتر از مبلغ → مبلغ نهایی صفر، نه منفی.
- ⚠️ مرزی: سقف جمع تخفیف‌ها (`max_total_discount_percent` per محیط) → اعمال شود.
- ⚠️ مرزی: بیعانه بیشتر از مبلغ نهایی → `422` هنگام تنظیم سیاست.
- ⚠️ مرزی: نوبت بدون سرویس (نوبت ویزیت ساده در حالت `slot`) → snapshot با
`visit_price_rials` موجود ساخته شود، نه خالی.
## خروجی
- `src/Pricing/`
- `assets/admin/pages/PriceListsPage.tsx` + `PriceListFormPage.tsx`
- `docs/api/pricing.md`
- به‌روزرسانی `docs/architecture/insurance-billing-system.md`