ledger->sumDelta($package); } public function record( PatientPackage $package, string $kind, int $delta, ?Appointment $appointment = null, ?ServiceItem $service = null, ?string $reason = null, ?User $by = null, bool $flush = true, ): SessionCreditLedger { $row = new SessionCreditLedger($package, $kind, $delta, $appointment, $service, $reason, $by); $this->em->persist($row); if ($flush) { $this->em->flush(); } return $row; } /** * مصرف یک جلسه — `false` یعنی «اعتباری نبود»، نه خطا. * * بیمار بدون اعتبار باید بتواند نقدی بپردازد؛ استثنا پرتاب کردن اینجا یعنی * رزروِ کاملاً معتبر شکست بخورد. * * قفل بدبینانه روی همان یک ردیف پکیج است. برخلاف اسلات‌های تسک ۰۷ — که نرخ رقابت * بالا و ده‌ها ردیف درگیر دارند — اینجا یک بیمار و یک پکیج است، پس هزینهٔ قفل * ناچیز و سادگی‌اش برنده است. */ public function consume(PatientPackage $package, Appointment $appointment, ?ServiceItem $service = null): bool { // قفل بدون تراکنش معنا ندارد؛ خواندن و نوشتن باید در یک واحد اتمی باشند // وگرنه دو درخواست هم‌زمان هر دو ماندهٔ ۱ را می‌بینند. return $this->em->wrapInTransaction(function () use ($package, $appointment, $service): bool { $locked = $this->em->find(PatientPackage::class, $package->getId(), LockMode::PESSIMISTIC_WRITE); if ($locked === null || $locked->isExpired()) { return false; } // همین نوبت قبلاً مصرف کرده؟ `confirm` idempotent است و اجرای دومش نباید // جلسهٔ دوم بخورد. بررسی **پیش از** درج است نه گرفتنِ استثنا: نقض کلید // یکتا در Doctrine خودِ EntityManager را می‌بندد و بقیهٔ همان request را // هم می‌سوزاند. کلید یکتا آخرین خط دفاع می‌ماند، نه مسیر عادی. if ($this->ledger->findForAppointment($appointment, SessionCreditLedger::KIND_CONSUME) !== null) { return true; } if ($this->balance($locked) <= 0) { return false; } $this->record($locked, SessionCreditLedger::KIND_CONSUME, -1, $appointment, $service); return true; }); } /** * بازگشت اعتبار هنگام لغو — ردیف `consume` **حذف نمی‌شود**. * * فعلاً هر لغوی اعتبار را کامل برمی‌گرداند. سیاست واقعی (لغو دیرهنگام، جریمه، * عدم‌حضور) کارِ تسک ۱۳ است و همان‌جا این متد یک پارامتر سیاست می‌گیرد؛ پرچم * نیم‌کاره اینجا فقط رفتاری می‌ساخت که هیچ‌کس تنظیمش نمی‌کند. * * @return bool `false` یعنی این نوبت اصلاً از پکیج مصرف نکرده بود */ public function refund(Appointment $appointment, ?User $by = null): bool { $consumed = $this->ledger->findForAppointment($appointment, SessionCreditLedger::KIND_CONSUME); if ($consumed === null) { return false; } if ($this->ledger->findForAppointment($appointment, SessionCreditLedger::KIND_REFUND) !== null) { return false; } $this->record( $consumed->getPatientPackage(), SessionCreditLedger::KIND_REFUND, -$consumed->getDelta(), $appointment, $consumed->getServiceItem(), 'بازگشت اعتبار با لغو نوبت', $by, ); return true; } /** @return SessionCreditLedger[] */ public function history(PatientPackage $package): array { return $this->ledger->historyFor($package); } }