# معماری — تسک ۱۱ ## ساختار فایل ``` src/Package/ ├── Entity/ │ ├── Package.php # تعریف │ ├── PackageService.php # سرویس‌های پوشش‌داده‌شده (ManyToMany با تعداد) │ ├── PatientPackage.php # نمونهٔ خریداری‌شده │ └── SessionCreditLedger.php # دفتر ├── Service/ │ ├── PackageSalesService.php # فروش │ ├── CreditLedgerService.php # ← تنها نویسندهٔ دفتر │ └── PackageConsumptionService.php # مصرف در زنجیرهٔ قیمت ├── Repository/… └── Controller/{PackageController, PatientPackageController}.php ``` ## دفتر، نه شمارنده ```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 پکیج. تعداد ردیف‌ها کوچک است (پکیج ۸ جلسه‌ای ≤ ۲۰ ردیف). اگر روزی لازم شد، **کش** بگذار، نه ستون: ```php // cp:pkg:{patientPackageId}:balance TTL 60s، ابطال روی هر record() ``` ستون denormalized یعنی دو منبع حقیقت و همان مشکلی که مستند هشدار داده. ## جلوگیری از منفی شدن مانده دو نوبت هم‌زمان که هر دو آخرین اعتبار را می‌خواهند: ```php 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 بود: ```php // 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 ```php // PackageFinder::firstUsable() // قدیمی‌ترین پکیج منقضی‌نشده با مانده > 0 $qb->orderBy('pp.purchasedAt', 'ASC') ->andWhere('pp.validTo IS NULL OR pp.validTo >= :now'); ``` قدیمی‌ترین اول، چون نزدیک‌تر به انقضا است. اگر LIFO بود، پکیج قدیمی منقضی می‌شد و بیمار پولش را از دست می‌داد. ## انقضا ```bash 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 — و همین به کاربر ثابت می‌کند عدد از کجا آمده