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