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