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:
@@ -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`
|
||||
Reference in New Issue
Block a user