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,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` موجود میماند (نوبت رزرو روزی) — مفهوم متفاوتی است و
|
||||
ادغامشان با لیست انتظار خارج از دامنهٔ این تسک است
|
||||
Reference in New Issue
Block a user