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