Files
clinicpro/docs/api/package.md
T
hamedandClaude Opus 5 ca9648732d feat(package): session packages backed by a credit ledger
"Six laser sessions" is the common case in an aesthetics clinic: the patient
pays once and books the sessions later.

Credit is a ledger, not a counter. No table has a remaining/used_count column
and a schema test enforces that — the balance is always SUM(delta) over
append-only rows, so every number a patient sees has a full history behind it.
Corrections are new rows, never edits.

- purchase / consume / refund / adjustment / expiry, each with a reason, an
  author and the appointment it belongs to
- consume happens in confirm(), never in quote(): if the preview consumed, a
  page refresh would cost the patient a session
- cancelling adds a refund row; the consume row stays
- FIFO across a patient's packages — the oldest is closest to expiring
- an empty package is not an error, it just does not apply and the patient pays
- adjust/expire need a doctor or clinic role, and adjust always needs a reason
- app:package:expire writes the closing row so "where did my 3 sessions go?"
  always has an answer

Consume takes a pessimistic lock on the one package row. That is the opposite
of task 07's slot buckets, and docs/api/package.md carries the table explaining
why, so nobody unifies them later.

Idempotency checks for an existing consume row before inserting rather than
catching the unique violation: in Doctrine that exception closes the
EntityManager and burns the rest of the request. The unique key stays as the
last line of defence.

Admin: PackagesPage, a packages tab on the patient record, and a ledger page
whose running-balance column shows where the final number came from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:11:03 +03:30

10 KiB
Raw Blame History

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

{
  "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

{
  "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

{ "package_uuid": "5502aa44-…", "price_paid_rials": 25000000 }

price_paid_rials اختیاری است؛ نبودنش یعنی قیمت تعریفِ لحظهٔ خرید.

Response 201

{
  "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

{
  "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

{ "delta": 2, "reason": "جبران جلسهٔ لغوشده توسط کلینیک" }

Errors

Code HTTP Description
ERR_VALIDATION_002 422 delta صفر یا غایب، یا reason خالی
ERR_VALIDATION_001 422 اصلاحی که مانده را منفی می‌کند
ERR_FORBIDDEN_001 403 نقش منشی
{
  "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 می‌گیرد. با آن، اگر بیمار پکیج معتبری برای همان سرویس داشته باشد قیمت پایهٔ سرویس پوشش داده می‌شود:

{
  "base_rials": 5000000,
  "final_rials": 0,
  "package_will_be_consumed": true,
  "package_uuid": "dcd21f8e-…",
  "breakdown": {
    "discounts": [
      { "label": "پوشش پکیج «۶ جلسه لیزر فول‌بادی»", "rials": 5000000, "kind": "package" }
    ]
  }
}

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

پکیج قیمت پایه را می‌پوشاند نه آیتم‌های اضافه: «شش جلسه لیزر» یعنی شش بار خودِ لیزر، نه هر چیزی که کنارش انتخاب شود.

ماندهٔ صفر خطا نیست: پکیج اعمال نمی‌شود و بیمار مبلغ کامل را نقدی می‌پردازد.


دستور انقضا

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 دفتر اعتبار یک پکیج خریداری‌شده

تست‌ها

ddev exec php bin/phpunit tests/Package   # ۱۶ تست

مهم‌ترینش testNoStoredBalanceColumnExists است: هیچ ستون مانده‌ای در schema نباید باشد. تست عجیبی به نظر می‌رسد ولی همان چیزی است که شش ماه بعد جلوی «بهینه‌سازی» می‌ایستد.