# نکات پیاده‌سازی — تسک ۱۱ ## ۱. `quote` نمایش می‌دهد، `confirm` مصرف می‌کند بدترین باگ ممکن در این تسک: ```php // ❌ بیمار صفحه را سه بار رفرش می‌کند، سه جلسه از دست می‌دهد public function quote(QuoteRequest $req): PriceQuote { $this->packages->consume(…); } ``` ```php // ✅ public function quote(…): PriceQuote { $pkg = $this->finder->firstUsable(…); return $quote->withPackagePreview($pkg); // فقط نمایش } // و در BookingService::confirm() داخل تراکنش: $this->packages->consume($pkg, $appointment); ``` تست اجباری: ده بار `quote` → مانده بدون تغییر. ## ۲. مانده صفر خطا نیست ```php if ($this->ledger->balance($pkg) <= 0) { return false; // ✅ مبلغ کامل محاسبه می‌شود // نه: throw new AppException(...) } ``` بیمار با پکیج تمام‌شده باید بتواند نقدی نوبت بگیرد. `422` یعنی بن‌بست بی‌دلیل. UI پیام بدهد: «اعتبار پکیج شما تمام شده؛ این نوبت نقدی محاسبه می‌شود.» ## ۳. `uniq_scl_consume` و idempotency `confirm` تسک ۰۷ idempotent است. اگر دوبار صدا زده شود: ```php try { $this->ledger->record($pkg, KIND_CONSUME, -1, $meta); } catch (UniqueConstraintViolationException) { // قبلاً مصرف شده — همان رفتار idempotent، نه خطا } ``` با کلید یکتای `(appointment_id, kind)` این تضمین از دیتابیس می‌آید. همان الگوی تسک ۰۷. ## ۴. لغو = ردیف `refund`، نه حذف `consume` ```php // ❌ تاریخ را پاک می‌کند $this->em->remove($consumeRow); // ✅ $this->ledger->record($pkg, KIND_REFUND, +1, LedgerMeta::forCancellation($appt)); ``` دفتر append-only است. بعد از سه ماه، سؤال «چند بار این بیمار نوبتش را لغو کرد؟» فقط از دفتر جواب دارد. ⚠️ بازگشت اعتبار **مشروط به سیاست لغو** است (تسک ۱۳). تا آن تسک نیامده، همیشه برگردان و یک `TODO` با ارجاع به تسک ۱۳ بگذار — نه یک پرچم نیم‌کاره. ## ۵. FIFO و انقضا ```php ->orderBy('pp.purchasedAt', 'ASC') ``` قدیمی‌ترین اول. اگر LIFO باشد، پکیج قدیمی منقضی می‌شود و بیمار پولش را از دست می‌دهد — و شکایتش درست است. `valid_to` هنگام **خرید** محاسبه و ذخیره می‌شود (`purchased_at + validity_days * 86400`)، نه در زمان اجرا: تغییر `validity_days` تعریف پکیج نباید اعتبار خریدهای قبلی را عوض کند. ## ۶. قفل بدبینانه اینجا درست است برخلاف تسک ۰۷ که قفل را رد کردیم: ```php $locked = $this->em->find(PatientPackage::class, $id, LockMode::PESSIMISTIC_WRITE); ``` نرخ رقابت اینجا ناچیز است (یک بیمار، یک پکیج) و یک ردیف قفل می‌شود، نه ده‌ها سطل. جدول مقایسه در `architecture.md` را در `docs/api/package.md` هم بنویس، وگرنه کسی روزی «برای یکدستی» یکی را به دیگری تبدیل می‌کند. ## ۷. `adjustment` فقط با نقش مدیر و با دلیل ```php #[IsGranted('ROLE_CLINIC_OWNER')] // نه منشی، نه پرسنل public function adjust(string $uuid, Request $request): JsonResponse { $reason = trim((string) $data['reason'] ?? ''); if ($reason === '') { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'ذکر دلیل اصلاح الزامی است', 422, 'reason'); } } ``` اصلاح دستی بدون دلیل، دفتر را به همان شمارندهٔ غیرقابل‌ردیابی تبدیل می‌کند که مستند هشدار داده. ## ۸. edge case ها | حالت | رفتار درست | |---|---| | بیمار دو پکیج معتبر برای یک سرویس | FIFO — قدیمی‌ترِ منقضی‌نشده | | پکیج معتبر ولی سرویس نوبت پوشش داده نمی‌شود | اعمال نمی‌شود، مبلغ کامل | | پکیج منقضی با مانده ۳ | ردیف `expiry -3` توسط cron؛ مانده صفر، دفتر کامل | | `confirm` دوباره | `uniq_scl_consume` → idempotent | | لغو نوبتی که پکیج نداشت | هیچ ردیفی ثبت نمی‌شود | | `delta = 0` | `422` — ردیف بی‌اثر ننویس | | حذف تعریف پکیجی که فروخته شده | `422` (FK RESTRICT) — `active=false` مسیر درست | | پکیج بدون سرویس | `422` هنگام ساخت | | مبلغ پکیج بزرگ‌تر از سقف INT | `BIGINT` — از قبل حل شده | | بیمار مهمان بدون `patient_record` | پکیج فروش نمی‌رود — `422` با پیام «ابتدا پروندهٔ بیمار را ثبت کنید» | ## ۹. تست ``` tests/Package/CreditLedgerTest.php ← ⭐ - مانده = SUM(delta) در همهٔ سناریوها - purchase → consume → refund → مانده اولیه - append-only: هیچ remove/update روی ردیف‌ها tests/Package/LedgerSchemaTest.php ← ⭐ - هیچ ستون remaining/used_count در schema tests/Package/QuoteDoesNotConsumeTest.php ← ⭐ - ده بار quote → مانده بدون تغییر tests/Package/ConcurrentConsumeTest.php - دو نوبت هم‌زمان روی آخرین اعتبار → یکی می‌گیرد، مانده منفی نمی‌شود tests/Package/IdempotentConsumeTest.php - confirm دوبار → یک ردیف consume tests/Package/FifoTest.php - قدیمی‌ترین پکیج اول مصرف می‌شود tests/Package/ExpiryTest.php - cron ردیف expiry با delta = -balance می‌سازد - پکیج منقضی در finder نمی‌آید tests/Package/AdjustmentAuthTest.php - منشی → 403 · مدیر بدون دلیل → 422 · مدیر با دلیل → 200 tests/Package/PackageTenantTest.php - پکیج محیط دیگر → 404 tests/Package/PricingIntegrationTest.php - ردیف package در price_snapshot_lines با مبلغ منفی - جمع ردیف‌ها = مبلغ نهایی (invariant تسک ۰۸ حفظ شود) ``` ## ۱۰. مستندات `docs/api/package.md` بساز. `docs/architecture/tenancy.md` را با دلیل تفاوت «دفتر اعتبار (جفت tenant)» و «کیف پول (سراسری + انتساب)» به‌روز کن — این دو شبیه‌اند و اشتباه گرفتنشان نشتی مالی می‌سازد.