Files
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

6.2 KiB
Raw Permalink Blame History

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

ساختار فایل

src/Package/
├── Entity/
│   ├── Package.php               # تعریف
│   ├── PackageService.php        # سرویس‌های پوشش‌داده‌شده (ManyToMany با تعداد)
│   ├── PatientPackage.php        # نمونهٔ خریداری‌شده
│   └── SessionCreditLedger.php   # دفتر
├── Service/
│   ├── PackageSalesService.php   # فروش
│   ├── CreditLedgerService.php   # ← تنها نویسندهٔ دفتر
│   └── PackageConsumptionService.php  # مصرف در زنجیرهٔ قیمت
├── Repository/…
└── Controller/{PackageController, PatientPackageController}.php

دفتر، نه شمارنده

final class CreditLedgerService
{
    public const KIND_PURCHASE   = 'purchase';    // + خرید
    public const KIND_CONSUME    = 'consume';     //  مصرف در نوبت
    public const KIND_REFUND     = 'refund';      // + بازگشت با لغو
    public const KIND_ADJUSTMENT  = 'adjustment'; // ± اصلاح دستی
    public const KIND_EXPIRY     = 'expiry';      //  ابطال

    /** مانده = جمع همهٔ delta ها. هیچ ستون ذخیره‌شده‌ای نیست. */
    public function balance(PatientPackage $pkg, ?ServiceItem $service = null): int
    {
        return $this->ledgerRepo->sumDelta($pkg, $service);
    }

    /** هیچ‌جای دیگری نباید در session_credit_ledger بنویسد. */
    public function record(PatientPackage $pkg, string $kind, int $delta, LedgerMeta $meta): SessionCreditLedger;
}

مستند: «اگر فقط یک عدد نگه داریم، اولین اشتباه هرگز قابل ردیابی نیست.» پس:

  • هیچ ستون remaining یا used_count در هیچ جدولی نیست — تست schema این را اجبار کند
  • هر تغییر یک ردیف است، با reason و created_by و ارجاع به نوبت
  • تصحیح خطا = ردیف adjustment جدید، نه ویرایش ردیف قبلی

هزینهٔ کارایی و پاسخش

SUM(delta) per بیمار per پکیج. تعداد ردیف‌ها کوچک است (پکیج ۸ جلسه‌ای ≤ ۲۰ ردیف). اگر روزی لازم شد، کش بگذار، نه ستون:

// cp:pkg:{patientPackageId}:balance   TTL 60s، ابطال روی هر record()

ستون denormalized یعنی دو منبع حقیقت و همان مشکلی که مستند هشدار داده.

جلوگیری از منفی شدن مانده

دو نوبت هم‌زمان که هر دو آخرین اعتبار را می‌خواهند:

public function consume(PatientPackage $pkg, Appointment $appt): bool
{
    // قفل بدبینانه روی خودِ ردیف پکیج — تعداد رقابت‌ها ناچیز است
    $locked = $this->em->find(PatientPackage::class, $pkg->getId(), LockMode::PESSIMISTIC_WRITE);

    if ($this->ledger->balance($locked) <= 0) {
        return false;   // ← خطا نیست؛ مبلغ کامل محاسبه می‌شود
    }
    $this->ledger->record($locked, KIND_CONSUME, -1, LedgerMeta::forAppointment($appt));
    return true;
}

اینجا قفل بدبینانه درست است، برخلاف تسک ۰۷:

تسک ۰۷ (اسلات) تسک ۱۱ (اعتبار)
نرخ رقابت بالا — ساعت پرتقاضا ناچیز — یک بیمار، یک پکیج
تعداد ردیف درگیر ده‌ها سطل یک ردیف
هزینهٔ قفل صف‌شدن رزروها ناچیز

پس راه‌حل متفاوت است و این تفاوت باید مستند شود، وگرنه کسی «برای یکدستی» یکی را به دیگری تبدیل می‌کند.

اتصال به زنجیرهٔ قیمت

قلاب مرحلهٔ ۴ تسک ۰۸ که تا حالا no-op بود:

// PackageConsumptionService::consume(array $lines, QuoteRequest $req): array
$pkg = $this->finder->firstUsable($req->patient, $req->service, $req->at);   // FIFO
if ($pkg === null) return $lines;

// در quote فقط نمایش می‌دهیم، در confirm واقعاً کسر می‌کنیم
$lines[] = PriceLine::package($pkg, -$this->coveredAmount($lines, $pkg));
return $lines;

⚠️ تفکیک حیاتی: quote (پیش‌نمایش) هیچ‌وقت مصرف نمی‌کند. مصرف فقط در BookingService::confirm() داخل همان تراکنش. اگر quote مصرف کند، هر بار که بیمار صفحه را رفرش کند یک جلسه از دست می‌دهد.

PriceQuote یک پرچم packageWillBeConsumed می‌گیرد تا UI بگوید «۱ جلسه از پکیج شما کسر می‌شود».

FIFO

// PackageFinder::firstUsable()
// قدیمی‌ترین پکیج منقضی‌نشده با مانده > 0
$qb->orderBy('pp.purchasedAt', 'ASC')
   ->andWhere('pp.validTo IS NULL OR pp.validTo >= :now');

قدیمی‌ترین اول، چون نزدیک‌تر به انقضا است. اگر LIFO بود، پکیج قدیمی منقضی می‌شد و بیمار پولش را از دست می‌داد.

انقضا

ddev exec php bin/console app:package:expire   # روزانه با symfony/scheduler

برای هر PatientPackage با valid_to گذشته و مانده > ۰: یک ردیف expiry با delta = -balance ثبت می‌شود. دفتر دست‌نخورده می‌ماند و تاریخچه کامل است — بیمار می‌تواند بپرسد «۳ جلسه‌ام چه شد؟» و جواب در دفتر است.

پنل ادمین

  • PackagesPage.tsx — تعریف پکیج‌ها با PriceInput و انتخاب سرویس‌ها
  • در PatientDetailPage.tsx کارت «پکیج‌ها»: هر پکیج با مانده، تاریخ انقضا و لینک دفتر
  • PatientPackageLedgerPage.tsx — جدول دفتر با ستون‌های: تاریخ، نوع، تغییر، مانده تجمعی، دلیل، ثبت‌کننده، نوبت مرتبط
  • «مانده تجمعی» ستون محاسبه‌شده در UI است، نه ستون DB — و همین به کاربر ثابت می‌کند عدد از کجا آمده