Files
clinicpro/docs/new_feture/taskes/task-13-cancellation-waitlist/architecture.md
T
hamed 021d0eb6b2 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.
2026-07-30 11:43:58 +03:30

8.2 KiB
Raw Blame History

معماری — تسک ۱۳

ساختار فایل

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

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

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 — ترتیب

$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

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 — همه مطلع می‌شوند، صف انحصاری نه

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

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