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` موجود می‌ماند (نوبت رزرو روزی) — مفهوم متفاوتی است و
ادغامشان با لیست انتظار خارج از دامنهٔ این تسک است