# Package — پکیج و دفتر اعتبار جلسات اندپوینت‌های `src/Package/*`. «پکیج شش جلسه لیزر» حالت رایج کلینیک زیبایی است: بیمار یکجا پول می‌دهد و بعداً جلساتش را رزرو می‌کند. **اعتبار یک دفتر حساب است، نه یک شمارنده.** هیچ ستون `remaining` یا `used_count` در هیچ جدولی وجود ندارد و مانده همیشه `SUM(delta)` ردیف‌های دفتر است. ردیف‌ها append-only اند؛ تصحیح یعنی ردیف تازه، نه ویرایش ردیف قبلی. همهٔ مسیرها `IS_AUTHENTICATED_FULLY` می‌خواهند و به محیط جاری محدودند (`404` برای محیط دیگر). `adjust` و `expire` علاوه بر آن نقش پزشک/کلینیک/ادمین می‌خواهند (`403` برای منشی). --- ## چرخهٔ اعتبار | نوع ردیف | delta | کِی نوشته می‌شود | |---|---|---| | `purchase` | `+session_count` | فروش پکیج به بیمار | | `consume` | `-1` | `BookingService::confirm()` — ثبت نهایی نوبت | | `refund` | `+1` | لغو همان نوبت؛ ردیف `consume` **حذف نمی‌شود** | | `adjustment` | `±n` | اصلاح دستی، همیشه با `reason` و `created_by` | | `expiry` | `-balance` | `app:package:expire` یا ابطال دستی | `UNIQUE(appointment_id, kind)` مصرف دوباره را می‌بندد: `confirm` idempotent است و اجرای دومش جلسهٔ دوم نمی‌خورد. **FIFO:** وقتی بیمار چند پکیج معتبر برای یک سرویس دارد، قدیمی‌ترین اول مصرف می‌شود — چون به انقضا نزدیک‌تر است و نگه داشتنش یعنی بیمار پولش را از دست بدهد. --- ## GET `/api/v1/packages` | Query | Type | Description | |---|---|---| | `active` | bool | فقط فعال‌ها یا فقط غیرفعال‌ها | ### Response `200` ```json { "success": true, "data": [ { "uuid": "5502aa44-213a-44bd-9b24-64094c675f6e", "name": "۶ جلسه لیزر فول‌بادی", "session_count": 6, "price_rials": 25000000, "validity_days": 365, "active": true, "services": [{ "uuid": "be2e7a93-…", "name": "لیزر فول‌بادی" }], "created_at": 1785483226 } ] } ``` --- ## POST `/api/v1/packages` ### Request Body ```json { "name": "۶ جلسه لیزر فول‌بادی", "session_count": 6, "price_rials": 25000000, "validity_days": 365, "service_uuids": ["be2e7a93-976a-468b-aa7a-b8f4a4278953"] } ``` | Field | Type | Required | Description | |---|---|---|---| | `name` | string | ✅ | | | `session_count` | int | ✅ | حداقل ۱ | | `price_rials` | int | — | ریال؛ ستون `bigint` است | | `validity_days` | int\|null | — | اعتبار از تاریخ خرید؛ `null` = بی‌پایان | | `service_uuids` | string[] | ✅ | حداقل یک سرویس از همین محیط | | `active` | bool | — | پیش‌فرض `true` | ### Response `201` همان شکل بالا، با `uuid` تازه. ### Errors | Code | HTTP | Description | |---|---|---| | `ERR_VALIDATION_002` | 422 | نام خالی، `session_count < 1`، یا `service_uuids` خالی | | `ERR_NOT_FOUND_001` | 404 | سرویس خارج از محیط جاری | > پکیج بدون سرویس هرگز قابل مصرف نیست؛ ساختنش فقط یک تلهٔ خاموش برای اپراتور است، > پس `422` می‌گیرد. --- ## GET · PATCH · DELETE `/api/v1/package/{uuid}` `PATCH` همان فیلدهای ساخت را می‌پذیرد. فرستادن `service_uuids` **جایگزینی کامل** است. `DELETE` پکیج را **غیرفعال** می‌کند، حذف نمی‌کند: ردیف‌های دفترِ بیماران به آن ارجاع دارند و حذفش تاریخچهٔ اعتبار را بی‌معنا می‌کند. --- ## POST `/api/v1/patient/{uuid}/package` فروش پکیج به بیمار. یک ردیف `purchase` هم‌زمان نوشته می‌شود. ### Request Body ```json { "package_uuid": "5502aa44-…", "price_paid_rials": 25000000 } ``` `price_paid_rials` اختیاری است؛ نبودنش یعنی قیمت تعریفِ لحظهٔ خرید. ### Response `201` ```json { "success": true, "data": { "uuid": "dcd21f8e-ddee-4f3a-96fb-9fd6c1867a27", "package_uuid": "5502aa44-…", "package_name": "۶ جلسه لیزر فول‌بادی", "patient_uuid": "5faa22e0-…", "session_count": 6, "price_paid_rials": 25000000, "purchased_at": 1785483226, "valid_to": 1817019226, "expired": false, "balance": 6 } } ``` `session_count` و `price_paid_rials` **کپی**اند نه ارجاع: تغییر تعریف پکیج فردا، پکیج فروخته‌شدهٔ دیروز را عوض نمی‌کند. --- ## GET `/api/v1/patient/{uuid}/packages` پکیج‌های بیمار با ماندهٔ روز. پکیج منقضی `balance: 0` نشان می‌دهد ولی دفترش دست‌نخورده می‌ماند. --- ## GET `/api/v1/patient-package/{uuid}/ledger` ### Response `200` ```json { "success": true, "data": { "package": { "uuid": "dcd21f8e-…", "balance": 7, "…": "مثل بالا" }, "rows": [ { "uuid": "3d11dc1f-…", "kind": "purchase", "delta": 6, "appointment_uuid": null, "service_uuid": null, "service_name": null, "reason": "خرید پکیج «۶ جلسه لیزر فول‌بادی»", "created_by": "b093deb7-…", "created_at": 1785483226, "running_balance": 6 }, { "uuid": "ca4ab9fb-…", "kind": "adjustment", "delta": 1, "reason": "جبران جلسهٔ لغوشده", "created_by": "b093deb7-…", "created_at": 1785483226, "running_balance": 7 } ] } } ``` `running_balance` جمع تجمعی است و در پاسخ محاسبه می‌شود — همین به کاربر نشان می‌دهد عدد نهایی از کجا آمده. --- ## POST `/api/v1/patient-package/{uuid}/adjust` **Permission:** پزشک · کلینیک · ادمین (منشی `403`) ### Request Body ```json { "delta": 2, "reason": "جبران جلسهٔ لغوشده توسط کلینیک" } ``` ### Errors | Code | HTTP | Description | |---|---|---| | `ERR_VALIDATION_002` | 422 | `delta` صفر یا غایب، یا `reason` خالی | | `ERR_VALIDATION_001` | 422 | اصلاحی که مانده را منفی می‌کند | | `ERR_FORBIDDEN_001` | 403 | نقش منشی | ```json { "success": false, "data": null, "errors": [{ "code": "ERR_VALIDATION_002", "message": "دلیل اصلاح الزامی است", "field": "reason" }] } ``` --- ## POST `/api/v1/patient-package/{uuid}/expire` ابطال دستی: یک ردیف `expiry` با `delta = -balance`. ماندهٔ صفر → `422`. --- ## اثر پکیج روی قیمت `POST /api/v1/pricing/quote` یک فیلد اختیاری `patient_uuid` می‌گیرد. با آن، اگر بیمار پکیج معتبری برای همان سرویس داشته باشد **قیمت پایهٔ سرویس** پوشش داده می‌شود: ```json { "base_rials": 5000000, "final_rials": 0, "package_will_be_consumed": true, "package_uuid": "dcd21f8e-…", "breakdown": { "discounts": [ { "label": "پوشش پکیج «۶ جلسه لیزر فول‌بادی»", "rials": 5000000, "kind": "package" } ] } } ``` ⚠️ **`quote` هیچ‌وقت مصرف نمی‌کند** — فقط اعلام می‌کند. مصرف واقعی در `BookingService::confirm()` است. اگر پیش‌نمایش مصرف می‌کرد، هر رفرش صفحه یک جلسه از بیمار می‌گرفت. پکیج **قیمت پایه** را می‌پوشاند نه آیتم‌های اضافه: «شش جلسه لیزر» یعنی شش بار خودِ لیزر، نه هر چیزی که کنارش انتخاب شود. ماندهٔ صفر خطا نیست: پکیج اعمال نمی‌شود و بیمار مبلغ کامل را نقدی می‌پردازد. --- ## دستور انقضا ```bash ddev exec php bin/console app:package:expire # روزانه ddev exec php bin/console app:package:expire --dry-run ``` برای هر پکیج با `valid_to` گذشته و ماندهٔ مثبت، یک ردیف `expiry` می‌نویسد. دفتر دست‌نخورده می‌ماند تا «۳ جلسه‌ام چه شد؟» همیشه جواب داشته باشد. --- ## قفل بدبینانه اینجا، سطل زمانی آنجا مصرف اعتبار با `PESSIMISTIC_WRITE` روی همان یک ردیف پکیج قفل می‌شود — برخلاف رزرو اسلات (تسک ۰۷) که با سطل‌های پنج‌دقیقه‌ای و کلید یکتا کار می‌کند. این تفاوت عمدی است: | | رزرو اسلات (تسک ۰۷) | اعتبار جلسه (تسک ۱۱) | |---|---|---| | نرخ رقابت | بالا — ساعت پرتقاضا | ناچیز — یک بیمار، یک پکیج | | ردیف‌های درگیر | ده‌ها سطل | یک ردیف | | هزینهٔ قفل | صف‌شدن رزروها | ناچیز | | راه‌حل | کلید یکتا روی سطل | قفل بدبینانه | اگر روزی کسی خواست «برای یکدستی» یکی را به دیگری تبدیل کند، همین جدول جواب است. --- ## طبقه‌بندی محیط | جدول | وضعیت | |---|---| | `packages` · `patient_packages` · `session_credit_ledger` | جفت محیط | | `package_services` | `AGGREGATE_CHILDREN` — ریشه `Package` | برخلاف `wallet_transactions` (که `ENTITIES` است چون پول مال شخص است)، اعتبار جلسه جفت محیط واقعی می‌گیرد: اعتبار جلسهٔ لیزر در کلینیک الف در کلینیک ب معنا ندارد. ## صفحه‌های پنل | مسیر | صفحه | |---|---| | `/admin/packages` | تعریف پکیج‌ها | | `/admin/patient-package/{uuid}/ledger` | دفتر اعتبار یک پکیج خریداری‌شده | ## تست‌ها ```bash ddev exec php bin/phpunit tests/Package # ۱۶ تست ``` مهم‌ترینش `testNoStoredBalanceColumnExists` است: هیچ ستون مانده‌ای در schema نباید باشد. تست عجیبی به نظر می‌رسد ولی همان چیزی است که شش ماه بعد جلوی «بهینه‌سازی» می‌ایستد.