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

303 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 نباید
باشد. تست عجیبی به نظر می‌رسد ولی همان چیزی است که شش ماه بعد جلوی «بهینه‌سازی»
می‌ایستد.