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,191 @@
# معماری — تسک ۱۳
## ساختار فایل
```
src/Cancellation/
├── Entity/{CancellationPolicy, NoShowRecord}.php
├── Service/
│ ├── CancellationPolicyResolver.php # اختصاصی‌ترین سیاست
│ ├── PenaltyCalculator.php
│ ├── CancellationService.php # ارکستراتور لغو
│ └── NoShowTracker.php
└── Controller/CancellationController.php
src/Waitlist/
├── Entity/WaitlistEntry.php
├── Service/
│ ├── WaitlistService.php
│ └── WaitlistMatcher.php # تطبیق ظرفیت آزاد با درخواست‌ها
├── MessageHandler/NotifyWaitlistHandler.php
└── Controller/WaitlistController.php
```
## `CancellationPolicy`
```php
class CancellationPolicy
{
use TenantOwnedTrait;
private ?ServiceItem $service = null; // null = پیش‌فرض محیط
private int $freeWindowHours = 24; // تا چند ساعت قبل، رایگان
private string $penaltyMode = 'percent'; // none | percent | fixed
private int $penaltyValue = 0;
private bool $depositRefundable = false; // پس از پنجرهٔ رایگان
private bool $creditRefundable = true; // اعتبار پکیج (تسک ۱۱)
private int $noShowThreshold = 3; // بعد از چند بار، برچسب پرریسک
private ?string $riskTagUuid = null; // TenantTag موجود
}
```
`riskTagUuid` به `TenantTag` موجود اشاره می‌کند، نه یک ستون `is_risky` روی بیمار.
دلیل: سیستم برچسب از قبل هست، در `DiscountRule.target_tag_uuid` و `FieldRegistry`
(`patient.tags`) استفاده می‌شود، و قانون `eligibility` تسک ۰۹ می‌تواند رویش شرط بگذارد.
ستون بولین جدید یعنی یک مفهوم موازی که هیچ‌کدام از آن‌ها نمی‌بینند.
## `PenaltyCalculator`
```php
public function forCancellation(Appointment $appt, string $by, int $now): PenaltyResult
{
// لغو توسط کلینیک: هرگز جریمه
if ($by === Appointment::STATUS_CANCELLED_BY_DOCTOR) {
return PenaltyResult::free();
}
$policy = $this->resolver->forAppointment($appt);
$hoursLeft = intdiv($appt->getSlotStart() - $now, 3600);
if ($hoursLeft >= $policy->getFreeWindowHours()) {
return PenaltyResult::free();
}
$paid = $this->paymentRepo->totalPaidFor($appt);
$penalty = match ($policy->getPenaltyMode()) {
'percent' => intdiv($this->snapshotFinal($appt) * $policy->getPenaltyValue(), 100),
'fixed' => $policy->getPenaltyValue(),
default => 0,
};
return new PenaltyResult(
penaltyRials: min($penalty, $paid), // ← سقف: مبلغ پرداختی
depositRefundable: $policy->isDepositRefundable(),
creditRefundable: $policy->isCreditRefundable(),
);
}
```
`min($penalty, $paid)` مهم است: جریمهٔ بیشتر از پرداختی یعنی بدهی — که مسئلهٔ حسابداری
است، نه لغو. برای نوبت نقدی (`$paid = 0`) جریمه صفر می‌شود و در پاسخ یک
`note: 'جریمه در مراجعهٔ بعدی محاسبه می‌شود'` می‌آید.
## `cancellation-preview` — اجباری پیش از لغو
```
GET /appointment/{uuid}/cancellation-preview
{
"hours_left": 6,
"free_window_hours": 24,
"penalty_rials": 1200000,
"deposit_refundable": false,
"credit_refundable": true,
"refund_rials": 800000,
"message": "لغو در کمتر از ۲۴ ساعت باقی‌مانده ۵۰٪ جریمه دارد."
}
```
بدون این endpoint، کاربر لغو می‌کند و بعد جریمه می‌بیند. UI باید preview را در
`ConfirmDialog` نشان دهد.
## `CancellationService` — ترتیب
```php
$this->em->wrapInTransaction(function () use ($appt, $by, $reason) {
$penalty = $this->penalty->forCancellation($appt, $by, time());
$this->transition($appt, $by); // ۱ وضعیت
$this->occupancyWriter->release($appt); // ۲ آزادسازی منابع (تسک ۰۷)
$this->refundDeposit($appt, $penalty); // ۳ بیعانه
$this->chargePenalty($appt, $penalty); // ۴ جریمه در wallet_transactions
$this->refundCredit($appt, $penalty); // ۵ اعتبار پکیج (تسک ۱۱)
$this->courseLinker->releaseSession($appt); // ۶ جلسهٔ دوره (تسک ۱۲)
$this->events->dispatch(new AppointmentCancelled($appt->getUuid())); // ۷ بعد از commit
});
```
مرحلهٔ ۷ رویداد است که `NotifyWaitlistHandler` به آن گوش می‌دهد — لیست انتظار async
مطلع می‌شود، نه در تراکنش لغو.
## `WaitlistEntry`
```php
class WaitlistEntry
{
use TenantOwnedTrait;
private PatientRecord $patient;
private ServiceItem $service;
private ?Branch $branch = null;
private int $desiredFrom; // بازهٔ دلخواه
private int $desiredTo;
private array $preferredDayParts = []; // ['morning','afternoon','evening']
private int $priority = 0;
private ?int $notifiedAt = null;
private int $notifyCount = 0;
private string $status = 'waiting'; // waiting | notified | converted | expired
}
```
## `WaitlistMatcher` — همه مطلع می‌شوند، صف انحصاری نه
```php
public function onCapacityFreed(int $from, int $to, ServiceItem $service, ?Branch $branch): void
{
$matches = $this->repo->findMatching($from, $to, $service, $branch, limit: 10);
foreach ($matches as $entry) {
$this->bus->dispatch(new NotifyWaitlistMessage($entry->getUuid()));
}
}
```
**تصمیم: broadcast، نه قفل انحصاری.**
| گزینه | مشکل |
|---|---|
| قفل انحصاری برای نفر اول (مثلاً ۳۰ دقیقه) | نفر اول ممکن است شب باشد و پیام را نبیند؛ ظرفیت ۳۰ دقیقه بلوکه و بعد نفر دوم، و همین‌طور — یک ساعت خالی می‌تواند سه ساعت معطل بماند |
| **اطلاع به همه، اولین رزروکننده می‌برد** ✅ | ظرفیت سریع پر می‌شود؛ هزینه‌اش این است که چند نفر پیام می‌گیرند و جا نیست |
هزینهٔ گزینهٔ دوم با یک جملهٔ صریح در پیامک قابل مدیریت است:
«یک وقت آزاد شد. اولین نفری که رزرو کند آن را می‌گیرد.»
سقف ۱۰ نفر برای جلوگیری از انبوه پیامک. `priority` ترتیب را تعیین می‌کند (بیمار وفادار
یا پکیج‌دار می‌تواند اولویت بگیرد).
## `NoShowTracker`
```php
public function record(Appointment $appt): void
{
$this->em->persist(new NoShowRecord($appt));
$count = $this->repo->countForPatient($appt->patientRecord(), since: $this->windowStart());
$policy = $this->resolver->forTenant($appt->tenantPair());
if ($count >= $policy->getNoShowThreshold() && $policy->getRiskTagUuid() !== null) {
$this->tagService->attach($appt->patientRecord(), $policy->getRiskTagUuid());
}
}
```
پنجرهٔ شمارش: ۱۲ ماه گذشته (نه کل عمر). بیماری که سه سال پیش سه بار نیامده، امروز
پرریسک نیست.
## پنل ادمین
- `CancellationPolicyPage.tsx` — سیاست محیط + جدول override سرویس‌ها
- `WaitlistPage.tsx` — لیست درخواست‌ها با فیلتر بازه/سرویس، و تب «قابل تطبیق» که
ظرفیت‌های آزاد شده و کاندیدهایشان را نشان می‌دهد
- در `AppointmentDetailPage.tsx` دکمهٔ لغو → `ConfirmDialog` با محتوای preview
- در `PatientDetailPage.tsx` نشان «پرریسک» + شمارش عدم حضور
- `ReserveAppointmentsPage.tsx` موجود می‌ماند (نوبت رزرو روزی) — مفهوم متفاوتی است و
ادغامشان با لیست انتظار خارج از دامنهٔ این تسک است
@@ -0,0 +1,141 @@
# دیتابیس — تسک ۱۳
## `cancellation_policies`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
| `service_item_id` | INT NULL | NULL = پیش‌فرض محیط — FK ON DELETE CASCADE |
| `free_window_hours` | SMALLINT NOT NULL DEFAULT 24 | |
| `penalty_mode` | VARCHAR(10) NOT NULL DEFAULT 'none' | `none`\|`percent`\|`fixed` |
| `penalty_value` | INT NOT NULL DEFAULT 0 | درصد ۰..۱۰۰ یا ریال |
| `deposit_refundable` | TINYINT(1) NOT NULL DEFAULT 0 | پس از پنجرهٔ رایگان |
| `credit_refundable` | TINYINT(1) NOT NULL DEFAULT 1 | اعتبار پکیج |
| `no_show_threshold` | SMALLINT NOT NULL DEFAULT 3 | |
| `risk_tag_uuid` | VARCHAR(36) NULL | ارجاع به `tenant_tags.uuid` — بدون FK، الگوی موجود پروژه |
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
| `created_at`/`updated_at` | INT NOT NULL | |
```sql
UNIQUE KEY uniq_cancel_policy_scope (entity_type, entity_id, service_item_id)
KEY idx_cancel_policies_tenant (entity_type, entity_id, active)
```
`risk_tag_uuid` بدون FK — همان الگوی `DiscountRule.target_tag_uuid` موجود.
## `no_show_records`
```sql
CREATE TABLE no_show_records (
id INT PRIMARY KEY AUTO_INCREMENT,
uuid VARCHAR(36) NOT NULL UNIQUE,
entity_type VARCHAR(10) NOT NULL,
entity_id INT NOT NULL,
patient_record_id INT NOT NULL,
appointment_id INT NOT NULL,
recorded_at INT NOT NULL,
recorded_by INT NULL,
UNIQUE KEY uniq_no_show_appointment (appointment_id), -- یک بار per نوبت
KEY idx_no_show_patient (patient_record_id, recorded_at), -- کوئری شمارش ۱۲ ماه
KEY idx_no_show_tenant (entity_type, entity_id, recorded_at),
CONSTRAINT fk_ns_patient FOREIGN KEY (patient_record_id) REFERENCES patient_records(id) ON DELETE CASCADE,
CONSTRAINT fk_ns_appt FOREIGN KEY (appointment_id) REFERENCES appointments(id) ON DELETE CASCADE
);
```
جدول جدا و نه یک ستون شمارنده روی بیمار — همان استدلال دفتر اعتبار تسک ۱۱:
شمارنده، «چه زمانی و کدام نوبت» را از دست می‌دهد و پنجرهٔ ۱۲ ماهه غیرقابل محاسبه می‌شود.
`uniq_no_show_appointment`: تغییر وضعیت به `no_show` ممکن است دوبار اتفاق بیفتد
(idempotency)؛ رکورد دوم ثبت نشود.
## `waitlist_entries`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
| `patient_record_id` | INT NOT NULL | FK ON DELETE CASCADE |
| `service_item_id` | INT NOT NULL | FK ON DELETE CASCADE |
| `branch_id` | INT NULL | FK ON DELETE CASCADE |
| `desired_from` | INT NOT NULL | |
| `desired_to` | INT NOT NULL | |
| `preferred_day_parts` | JSON NULL | `["morning","evening"]` |
| `priority` | SMALLINT NOT NULL DEFAULT 0 | |
| `status` | VARCHAR(12) NOT NULL DEFAULT 'waiting' | `waiting`\|`notified`\|`converted`\|`expired` |
| `notified_at` | INT NULL | آخرین اطلاع |
| `notify_count` | SMALLINT NOT NULL DEFAULT 0 | سقف برای جلوگیری از اسپم |
| `converted_appointment_id` | INT NULL | FK ON DELETE SET NULL |
| `created_at`/`updated_at` | INT NOT NULL | |
```sql
KEY idx_waitlist_match (service_item_id, branch_id, status, desired_from, desired_to)
KEY idx_waitlist_tenant (entity_type, entity_id, status, created_at)
KEY idx_waitlist_patient (patient_record_id, status)
```
`idx_waitlist_match` کوئری داغ است: «چه کسانی منتظر این سرویس در این بازه‌اند؟»
```sql
SELECT * FROM waitlist_entries
WHERE service_item_id = ? AND (branch_id = ? OR branch_id IS NULL)
AND status = 'waiting'
AND desired_from <= :freedEnd AND desired_to >= :freedStart
ORDER BY priority DESC, created_at ASC
LIMIT 10
```
`preferred_day_parts` در PHP فیلتر می‌شود (JSON قابل ایندکس مطمئن نیست و نتیجه ≤ ۱۰ ردیف است).
## هیچ تغییری در `appointments`
وضعیت‌های `cancelled_by_user`, `cancelled_by_doctor`, `no_show` از قبل هستند.
`deposit_amount_rials` هم.
## جریمه در دفتر مالی موجود
جدول جدید ندارد. `WalletTransaction` موجود استفاده می‌شود:
```php
new WalletTransaction(
user: $appt->getUser(),
amountRials: -$penalty,
kind: 'cancellation_penalty', // ← مقدار جدید در enum موجود
reference: $appt->getUuid(),
);
$tx->setRecordedEntity($appt->getEntityType(), $appt->getEntityId()); // per-محیط، طبق tenancy.md
```
`setRecordedEntity` اجباری است، وگرنه جریمه در دفتر همهٔ محیط‌ها دیده می‌شود
(`docs/architecture/tenancy.md`، بخش کیف پول).
## 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:cancellation:seed-default-policy --force
```
`seed-default-policy` برای هر محیط یک سیاست پیش‌فرض محافظه‌کار می‌سازد:
`free_window_hours=24, penalty_mode=none, deposit_refundable=true, credit_refundable=true`.
**پیش‌فرض بدون جریمه** عمدی است: فعال شدن ناگهانی جریمه روی بیماران موجود، شکایت است.
کلینیک خودش باید فعالش کند.
## پاکسازی
```bash
ddev exec php bin/console app:waitlist:expire # روزانه
```
ورودی‌هایی که `desired_to` گذشته → `status='expired'`.
## طبقه‌بندی tenant
| جدول | وضعیت |
|---|---|
| `cancellation_policies`, `no_show_records`, `waitlist_entries` | جفت tenant |
@@ -0,0 +1,157 @@
# نکات پیاده‌سازی — تسک ۱۳
## ۱. پیش‌فرض بدون جریمه
`seed-default-policy` باید `penalty_mode='none'` بسازد. اگر پیش‌فرض جریمه‌دار باشد،
لحظهٔ deploy همهٔ بیماران با نوبت آیندهٔ نزدیک مشمول جریمه می‌شوند و کلینیک خبر ندارد.
فعال‌سازی جریمه یک تصمیم کسب‌وکاری صریح است، نه پیش‌فرض فنی.
## ۲. لغو توسط کلینیک هرگز جریمه ندارد
```php
if ($by === Appointment::STATUS_CANCELLED_BY_DOCTOR) {
return PenaltyResult::free(); // ← اول از همه، پیش از هر محاسبه
}
```
این شرط باید **اولین** خط باشد. اگر بعد از محاسبهٔ پنجرهٔ زمانی بیاید، یک refactor
می‌تواند ترتیب را عوض کند و کلینیک از بیمار برای لغو خودش جریمه بگیرد.
## ۳. سقف جریمه = مبلغ پرداختی
```php
penaltyRials: min($penalty, $paid)
```
جریمهٔ بیشتر از پرداختی یعنی بدهی، و بدهی مسئلهٔ `Invoice`/`Claim` است نه لغو. برای
نوبت نقدی (`$paid = 0`) جریمه صفر می‌شود و پاسخ یک `note` می‌گیرد.
اگر روزی «بدهی لغو» لازم شد، یک تسک جدا با اتصال به `Billing` — نه یک مقدار منفی
پنهان در کیف پول.
## ۴. `setRecordedEntity` روی تراکنش کیف پول
```php
$tx->setRecordedEntity($appt->getEntityType(), $appt->getEntityId());
```
فراموش کردنش یعنی کلینیک الف جریمهٔ ثبت‌شده در کلینیک ب را می‌بیند — دقیقاً همان نشتی
که `PatientWalletTenantTest` می‌سنجد. آن تست باید بعد از این تسک هم سبز بماند.
## ۵. لیست انتظار: broadcast، با جملهٔ صریح
تصمیم معماری (جدول کامل در `architecture.md`): ظرفیت آزادشده به حداکثر ۱۰ نفر اطلاع
داده می‌شود و اولین رزروکننده می‌برد.
متن پیامک اجباراً شامل این جمله:
> «یک وقت در تاریخ X آزاد شد. اولین نفری که رزرو کند آن را می‌گیرد.»
بدون این جمله، ۹ نفر فکر می‌کنند نوبتشان تضمین شده و شکایت می‌کنند. با آن، انتظار
درست تنظیم می‌شود.
`notify_count` سقف دارد (پیشنهاد: ۳). بیماری که سه بار مطلع شده و رزرو نکرده، دیگر
پیام نمی‌گیرد تا خودش لیست را تازه کند.
## ۶. اطلاع‌رسانی async، بیرون تراکنش لغو
```php
// CancellationService — داخل تراکنش فقط dispatch
$this->events->dispatch(
(new Envelope(new AppointmentCancelled($appt->getUuid())))
->with(new DispatchAfterCurrentBusStamp())
);
// NotifyWaitlistHandler — بیرون، async
public function __invoke(AppointmentCancelled $event): void
{
$appt = $this->repo->findByUuid($event->appointmentUuid);
$this->matcher->onCapacityFreed($appt->getSlotStart(), $appt->getSlotEnd(), );
}
```
ده پیامک داخل تراکنش لغو یعنی لغو کند می‌شود و اگر پیامک شکست خورد، لغو rollback
می‌شود — که غلط است. لغو موفق است حتی اگر هیچ پیامکی نرود.
`messenger:consume async` از قبل در استک هست.
## ۷. برچسب پرریسک، نه مسدودسازی
```php
$this->tagService->attach($patient, $policy->getRiskTagUuid());
// نه: $patient->setBlocked(true)
```
مسدودسازی یک تصمیم است که کلینیک باید بگیرد، و ابزارش از قبل ساخته می‌شود: یک قانون
`eligibility` (تسک ۰۹) با شرط `patient.tags in ['پرریسک']` و اثر `deny`.
اگر اینجا مسدود کنی، دو مکانیزم موازی برای یک کار داری و کلینیک نمی‌تواند خاموشش کند.
## ۸. پنجرهٔ شمارش عدم حضور
```php
private function windowStart(): int { return time() - 365 * 86400; }
```
۱۲ ماه، نه کل تاریخ. سه عدم حضور در سال ۱۴۰۲ امروز بی‌معناست. مقدار را ثابت نگه دار
(نه تنظیم‌پذیر) تا شمارش بین کلینیک‌ها قابل مقایسه بماند؛ اگر لازم شد، ستون اضافه کن.
## ۹. edge case ها
| حالت | رفتار درست |
|---|---|
| لغو دوبارهٔ همان نوبت | idempotent — همان وضعیت، بدون جریمهٔ دوم |
| لغو نوبت گذشته | `422` — برای گذشته `no_show`/`completed` |
| جریمه > پرداختی | سقف = پرداختی + `note` |
| نوبت نقدی | جریمه صفر + `note: 'در مراجعهٔ بعدی محاسبه می‌شود'` |
| لغو توسط کلینیک | بدون جریمه، بیعانه کامل، اعتبار کامل |
| لغو جلسهٔ دوره | اعتبار **طبق سیاست**، نه همیشه؛ `CourseSession``planned` |
| بیمار در لیست انتظار که خودش نوبت گرفت | ورودی → `converted` خودکار (روی رویداد `AppointmentBooked`) |
| ظرفیت آزادشده که هیچ‌کس منتظرش نیست | هیچ کاری — لاگ debug، نه هشدار |
| ده نفر مطلع، هیچ‌کس رزرو نکرد | ورودی‌ها `waiting` می‌مانند، `notify_count++` |
| ثبت لیست انتظار برای بازهٔ گذشته | `422` |
| `desired_to - desired_from` بزرگ‌تر از ۹۰ روز | `422` — همان سقف جستجو |
| سیاست سرویس و سیاست محیط هر دو | سرویس (اختصاصی‌تر) برنده |
سطر «بیمار در لیست انتظار که خودش نوبت گرفت» را فراموش نکن: بدون آن، بیمار نوبت دارد و
همچنان پیامک «وقت آزاد شد» می‌گیرد.
## ۱۰. تست
```
tests/Cancellation/PenaltyCalculatorTest.php ← ⭐
- داخل پنجرهٔ رایگان → صفر
- بیرون پنجره → درصد درست
- لغو توسط کلینیک → همیشه صفر (حتی ۱ ساعت قبل)
- جریمه > پرداختی → سقف
- نوبت نقدی → صفر + note
tests/Cancellation/CancellationServiceTest.php
- اشغال منابع آزاد می‌شود
- جریمه در wallet_transactions با recorded_entity
- لغو دوباره → idempotent
- لغو گذشته → 422
tests/Cancellation/PolicyResolverTest.php
- سیاست سرویس بر محیط اولویت دارد
tests/Cancellation/NoShowTrackerTest.php
- سومین no_show → برچسب پرریسک
- عدم حضور قدیمی‌تر از ۱۲ ماه شمرده نمی‌شود
- همان نوبت دوبار → یک رکورد
- بیمار پرریسک مسدود نمی‌شود (رزرو موفق)
tests/Waitlist/WaitlistMatcherTest.php
- لغو → حداکثر ۱۰ نفر مطلع، به ترتیب priority سپس created_at
- فیلتر preferred_day_parts
- notify_count سقف دارد
tests/Waitlist/WaitlistConversionTest.php
- بیمار خودش نوبت گرفت → converted
tests/Waitlist/WaitlistAsyncTest.php
- شکست پیامک، لغو را rollback نمی‌کند
tests/Patient/PatientWalletTenantTest.php ← موجود، باید سبز بماند
tests/Course/CourseLifecycleTest.php ← موجود، سیاست اعتبار اعمال شود
```
## ۱۱. مستندات
`docs/api/cancellation.md` و `docs/api/waitlist.md`. در اولی حتماً بنویس که
`cancellation-preview` پیش از لغو اجباری است و لغو توسط کلینیک هرگز جریمه ندارد.
در دومی تصمیم broadcast و دلیلش.
@@ -0,0 +1,75 @@
# تسک ۱۳ — سیاست لغو، عدم حضور، لیست انتظار
**فاز:** ۳ (کسب‌وکار) · **وابستگی:** ۰۷ · **زمان:** ۱۰-۱۲ ساعت
---
## هدف
مستند بند ۱۱: «هر کلینیک تنظیم می‌کند: تا چند ساعت قبل لغو رایگان است، جریمه چقدر است،
بیعانه برمی‌گردد یا نه، بعد از چند بار عدم حضور بیمار پرریسک علامت بخورد.»
و بند ۱۷: «رقابت روی ساعت‌های پرتقاضا → پیشنهاد خودکار ساعت جایگزین» و
بند ۱۸ فاز ۳: «لیست انتظار».
## وضعیت فعلی
- لغو کار می‌کند (`cancelled_by_user` / `cancelled_by_doctor`) ولی **بدون سیاست**:
هیچ جریمه‌ای، هیچ محدودیت زمانی، هیچ رفتاری با بیعانه
- `no_show` وضعیت هست ولی هیچ اثری ندارد
- `Appointment.is_reserve` وجود دارد: «نوبت رزرو» روزی (بدون ساعت) — یک لیست انتظار
ابتدایی که `ReserveAppointmentsPage.tsx` نمایشش می‌دهد
- بیعانه ثبت می‌شود (`deposit_required`, `deposit_amount_rials`) ولی بازگشتش دستی است
## دامنه
**هست:**
- `CancellationPolicy` per محیط/سرویس: پنجرهٔ لغو رایگان، درصد/مبلغ جریمه، رفتار بیعانه
- `NoShowPolicy`: بعد از N بار، برچسب پرریسک روی بیمار (استفاده از `TenantTag` موجود)
- محاسبهٔ جریمه در لحظهٔ لغو + ثبت در دفتر مالی موجود
- `Waitlist` — لیست انتظار برای بازهٔ زمانی مشخص (توسعهٔ `is_reserve` موجود)
- اطلاع‌رسانی خودکار به لیست انتظار وقتی ظرفیت آزاد می‌شود
**نیست:** پیش‌بینی عدم حضور (فاز ۴ مستند — خارج از دامنه).
## Endpoint ها
| متد | مسیر | توضیح |
|---|---|---|
| GET/PUT | `/api/v1/cancellation-policy` | سیاست محیط |
| PUT | `/api/v1/service-item/{uuid}/cancellation-policy` | override سرویس |
| GET | `/api/v1/appointment/{uuid}/cancellation-preview` | جریمه و بازگشت **پیش از** لغو |
| POST | `/api/v1/appointment/{uuid}/cancel` | لغو با اعمال سیاست |
| GET/POST | `/api/v1/waitlist` | ثبت در لیست انتظار |
| DELETE | `/api/v1/waitlist/{uuid}` | |
| GET | `/api/v1/waitlist/matches` | (پنل) درخواست‌های قابل تطبیق با ظرفیت آزاد |
## معیار پذیرش
- ✅ موفق: سیاست «لغو رایگان تا ۲۴ ساعت قبل، پس از آن ۵۰٪ جریمه، بیعانه برنمی‌گردد» →
`GET /cancellation-preview` برای نوبت ۴۸ ساعت بعد: `penalty_rials: 0, deposit_refundable: true`؛
برای نوبت ۶ ساعت بعد: `penalty_rials: <۵۰٪>, deposit_refundable: false`.
- ✅ موفق: `POST /cancel` جریمه را در `wallet_transactions` (الگوی موجود) ثبت می‌کند و
اشغال منابع را آزاد می‌کند.
- ✅ موفق: سومین `no_show` بیمار → برچسب «پرریسک» (`TenantTag`) خودکار اضافه می‌شود و
در `PatientDetailPage` دیده می‌شود.
- ✅ موفق: بیمار در لیست انتظار برای «۵ مرداد، بعدازظهر» است؛ نوبتی در آن بازه لغو
می‌شود → یک پیامک به او می‌رود و رکورد `notified_at` پر می‌شود.
- ✅ موفق: لغو دورهٔ درمان (تسک ۱۲) → اعتبار پکیج **طبق سیاست** برمی‌گردد، نه همیشه.
- ❌ خطا: `cancel` نوبتی که قبلاً لغو شده → `409` idempotent (همان وضعیت برگردد).
- ❌ خطا: `cancel` نوبت گذشته → `422`؛ برای گذشته `no_show` یا `completed` معنی دارد.
- ❌ خطا: ثبت در لیست انتظار برای بازهٔ گذشته → `422`.
- ⚠️ مرزی: جریمه بیشتر از مبلغ پرداختی → سقف = مبلغ پرداختی.
- ⚠️ مرزی: لغو توسط **کلینیک** (`cancelled_by_doctor`) → هرگز جریمه ندارد و بیعانه
کامل برمی‌گردد.
- ⚠️ مرزی: نوبت بدون پرداخت (نقدی سر جلسه) → جریمه ثبت می‌شود به‌عنوان بدهی، نه کسر.
- ⚠️ مرزی: لیست انتظار با ده نفر برای یک بازه → **همه** مطلع می‌شوند (اولین رزروکننده
می‌برد) — نه صف انحصاری. تصمیم و دلیلش در implementation_notes.
- ⚠️ مرزی: بیمار پرریسک → **مسدود نمی‌شود**؛ فقط برچسب. مسدودسازی یک قانون
`eligibility` (تسک ۰۹) روی همان برچسب است.
## خروجی
- `src/Cancellation/` + `src/Waitlist/`
- `assets/admin/pages/CancellationPolicyPage.tsx` + `WaitlistPage.tsx`
- توسعهٔ `AppointmentDetailPage.tsx` با پیش‌نمایش لغو
- `docs/api/cancellation.md` + `docs/api/waitlist.md`