"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>
10 KiB
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 نباید
باشد. تست عجیبی به نظر میرسد ولی همان چیزی است که شش ماه بعد جلوی «بهینهسازی»
میایستد.