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>
This commit is contained in:
hamed
2026-07-31 11:11:03 +03:30
co-authored by Claude Opus 5
parent d6294242b7
commit ca9648732d
35 changed files with 3205 additions and 78 deletions
+10
View File
@@ -174,3 +174,13 @@ ddev exec php bin/phpunit tests/Appointment/HoldAndBookTest.php # ۱۲ تست
دقیقه نگه‌داشتن صندلی، هم وقت بیمار را تلف می‌کند هم صندلی را.
جزئیات: [policy.md](policy.md)
---
## اعتبار پکیج
`confirm` یک جلسه از پکیج معتبر بیمار کسر می‌کند (ردیف `consume`) و لغو نوبت آن را
برمی‌گرداند (ردیف `refund`) — ردیف مصرف حذف نمی‌شود. کلید یکتای دفتر تضمین می‌کند
اجرای دوبارهٔ `confirm` جلسهٔ دوم نخورد.
جزئیات: [package.md](package.md)
+302
View File
@@ -0,0 +1,302 @@
# 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 نباید
باشد. تست عجیبی به نظر می‌رسد ولی همان چیزی است که شش ماه بعد جلوی «بهینه‌سازی»
می‌ایستد.
+10
View File
@@ -154,3 +154,13 @@ ddev exec php bin/phpunit tests/Pricing # ۱۲ تست
```
جزئیات دسته‌ها و اثرها: [policy.md](policy.md)
---
## پکیج
`quote` یک `patient_uuid` اختیاری می‌گیرد؛ با آن، پکیج معتبرِ بیمار قیمت پایهٔ سرویس را
می‌پوشاند و `package_will_be_consumed` روشن می‌شود. **پیش‌نمایش هرگز مصرف نمی‌کند**
مصرف در ثبت نهایی است.
جزئیات: [package.md](package.md)
+10
View File
@@ -305,3 +305,13 @@ php bin/console app:tenant:dump --tenant=clinic:12 --output=/tmp/clinic12.sql
| `tests/Patient/PatientWalletTenantTest.php` | دفتر کیف پول per-محیط است ولی موجودی سراسری می‌ماند |
| `tests/Shared/RequestReachableChildTenantTest.php` | فرزندانِ قابل‌دسترس با uuid را **خودِ فیلتر** می‌بندد، بدون گارد دستی |
| `tests/Auth/MultiClinicOwnerContextTest.php` | مالک چند کلینیک به هرکدام می‌تواند سوییچ کند |
## اعتبار جلسه: چرا برخلاف کیف پول جفت محیط می‌گیرد
`wallet_transactions` عمداً در `ENTITIES` است: پول مالِ **شخص** است و در هر محیطی همان
پول است؛ هر ردیف فقط `recorded_entity_*` دارد تا معلوم باشد کجا ثبت شده.
`session_credit_ledger` متفاوت است و جفت محیط واقعی می‌گیرد: «شش جلسه لیزر کلینیک الف»
در کلینیک ب هیچ معنایی ندارد و قابل مصرف نیست. همین تفاوت باعث می‌شود پکیج‌های یک بیمار
در دو کلینیک کاملاً از هم جدا بمانند.
@@ -1,6 +1,6 @@
# چک‌لیست — تسک ۱۱ (پکیج و دفتر اعتبار جلسات)
**وضعیت کلی:** ⏳ شروع نشده · **آخرین بازبینی:**
**وضعیت کلی:** ✅ تمام‌شده با انحراف‌های ثبت‌شده · **آخرین بازبینی:** ۱۴۰۵/۰۵/۰۹
قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) ·
[red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md)
@@ -11,104 +11,108 @@
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۰.۱ | `--group=slot-mode-frozen` سبز | | |
| ۰.۲ | **هیچ ستون `remaining`/`used_count`/`balance` در هیچ جدولی** | | ⭐⭐ `LedgerSchemaTest` اجبار می‌کند |
| ۰.۳ | دفتر append-only هیچ `remove`/`update` روی ردیف‌ها | ⏳ | |
| ۰.۴ | `WalletTransaction` و منطق کیف پول دست‌نخورده | | مفهوم متفاوت |
| ۰.۱ | `--group=slot-mode-frozen` سبز | | |
| ۰.۲ | **هیچ ستون `remaining`/`used_count`/`balance` در هیچ جدولی** | | ⭐⭐ `testNoStoredBalanceColumnExists` روی schema واقعی |
| ۰.۳ | دفتر append-only | ✅ | هیچ `remove`/`setter` روی `SessionCreditLedger`؛ تصحیح = ردیف تازه |
| ۰.۴ | `WalletTransaction` دست‌نخورده | | تفاوتش در `tenancy.md` نوشته شد |
## ۱. بک‌اند
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۱.۱ | `Package` · `PackageService` · `PatientPackage` · `SessionCreditLedger` | | |
| ۱.۲ | `CreditLedgerService` **تنها** نویسندهٔ دفتر | | |
| ۱.۳ | `balance()` = `SUM(delta)`، بدون هیچ مقدار ذخیره‌شده | | ⭐ |
| ۱.۴ | پنج `kind` تعریف شد | ⏳ | |
| ۱.۵ | `quote` **هرگز** مصرف نمی‌کند؛ فقط `confirm` | ⏳ | ⭐⭐ رفرش صفحه = از دست رفتن جلسه |
| ۱.۶ | `PriceQuote` پرچم `packageWillBeConsumed` دارد | ⏳ | |
| ۱.۷ | مانده صفر → `false`، **نه استثنا** | | ⭐ بیمار نقدی بپردازد |
| ۱.۸ | قفل بدبینانه `PESSIMISTIC_WRITE` روی ردیف پکیج | | با جدول مقایسه با تسک ۰۷ |
| ۱.۹ | `catch UniqueConstraintViolationException` روی `consume` → idempotent | ⏳ | |
| ۱.۱۰ | FIFO — قدیمی‌ترین پکیج منقضی‌نشده | | LIFO یعنی پول بیمار سوخته |
| ۱.۱۱ | `valid_to` هنگام **خرید** محاسبه و ذخیره می‌شود | ⏳ | |
| ۱.۱۲ | لغو → ردیف `refund`، نه حذف `consume` | ⏳ | |
| ۱.۱۳ | `TODO` با ارجاع به تسک ۱۳ برای سیاست بازگشت اعتبار | | نه پرچم نیم‌کاره |
| ۱.۱۴ | `adjust` فقط با نقش مدیر و با `reason` اجباری | | |
| ۱.۱۵ | `app:package:expire` روزانه — ردیف `expiry` با `delta = -balance` | ⏳ | |
| ۱.۱۶ | قلاب مرحلهٔ ۴ `PricingEngine` وصل شد | ⏳ | |
| ۱.۱۷ | هشت endpoint | | |
| ۱.۱۸ | `TenantOwnershipChecker` روی هر uuid از request | ⏳ | |
| ۱.۱ | چهار entity | | |
| ۱.۲ | `CreditLedgerService` تنها نویسندهٔ دفتر | | فروش، مصرف، بازگشت و اصلاح همه از همین عبور می‌کنند |
| ۱.۳ | `balance()` = `SUM(delta)` | | ⭐ |
| ۱.۴ | پنج `kind` | ✅ | سازنده `kind` ناشناخته و `delta` صفر را رد می‌کند |
| ۱.۵ | `quote` هرگز مصرف نمی‌کند | ✅ | ⭐⭐ `testQuoteAnnouncesThePackageWithoutConsumingIt` دو بار quote می‌زند و مانده را می‌سنجد |
| ۱.۶ | پرچم `packageWillBeConsumed` | ✅ | + `package_uuid` |
| ۱.۷ | مانده صفر → `false` نه استثنا | | ⭐ |
| ۱.۸ | قفل بدبینانه روی ردیف پکیج | | داخل `wrapInTransaction`؛ جدول مقایسه با تسک ۰۷ در `package.md` |
| ۱.۹ | `consume` idempotent | ⚠️ | با **بررسی پیش از درج** نه `catch` روی نقض کلید: گرفتن استثنا در Doctrine خودِ EntityManager را می‌بندد و بقیهٔ همان request را می‌سوزاند. کلید یکتا آخرین خط دفاع می‌ماند |
| ۱.۱۰ | FIFO | | `testTheOldestUnexpiredPackageIsUsedFirst` |
| ۱.۱۱ | `valid_to` هنگام خرید | ✅ | از `validity_days` لحظهٔ خرید |
| ۱.۱۲ | لغو → ردیف `refund` | ✅ | `BookingService::cancel()` |
| ۱.۱۳ | ارجاع به تسک ۱۳ برای سیاست بازگشت | | در docblock `refund()` |
| ۱.۱۴ | `adjust` فقط نقش مدیر و با `reason` | | منشی `403` |
| ۱.۱۵ | `app:package:expire` | ✅ | `--dry-run` هم دارد |
| ۱.۱۶ | قلاب `PricingEngine` | ✅ | `patient_uuid` اختیاری در `quote` |
| ۱.۱۷ | هشت endpoint | | ۹ تا: `packages` GET/POST · `package/{uuid}` GET/PATCH/DELETE · `patient/{uuid}/package` · `patient/{uuid}/packages` · `patient-package/{uuid}/ledger` · `/adjust` · `/expire` |
| ۱.۱۸ | `TenantOwnershipChecker` روی هر uuid | ✅ | `testAnotherClinicCannotSeeOrTouchThePackage` |
## ۲. دیتابیس
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۲.۱ | چهار جدول | | |
| ۲.۲ | `price_rials` و `price_paid_rials` از نوع **BIGINT** | | پکیج بزرگ |
| ۲.۳ | `UNIQUE(appointment_id, kind)` روی دفتر | | ⭐ جلوگیری از مصرف دوباره |
| ۲.۴ | `session_count`/`price_paid_rials` روی `patient_packages` **snapshot** اند | | قانون پنجم |
| ۲.۵ | `ON DELETE RESTRICT` روی سرویسِ پکیج فروخته‌شده | | |
| ۲.۶ | `package_services` در `AGGREGATE_CHILDREN` | | |
| ۲.۷ | دفتر **جفت tenant** دارد (نه `ENTITIES` مثل کیف پول) | | ⭐ دلیل مکتوب |
| ۲.۸ | `TenantSchemaCoverageTest` سبز | | |
| ۲.۱ | چهار جدول | | `Version20260731072023` |
| ۲.۲ | `bigint` روی هر دو ستون مبلغ | | |
| ۲.۳ | `UNIQUE(appointment_id, kind)` | | ⭐ |
| ۲.۴ | snapshot تعداد و قیمت | | قانون پنجم |
| ۲.۵ | `ON DELETE RESTRICT` روی سرویس و پکیج | | |
| ۲.۶ | `package_services` در `AGGREGATE_CHILDREN` | | |
| ۲.۷ | دفتر جفت tenant دارد | | ⭐ دلیلش در `tenancy.md` کنار کیف پول |
| ۲.۸ | `TenantSchemaCoverageTest` سبز | | |
## ۳. UI
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۳.۱ | `PackagesPage` — تعریف با `PriceInput` و انتخاب سرویس | | |
| ۳.۲ | کارت «پکیج‌ها» در `PatientDetailPage` با مانده و انقضا | ⏳ | |
| ۳.۳ | `PatientPackageLedgerPage` — جدول دفتر | | |
| ۳.۴ | ستون «مانده تجمعی» **محاسبه‌شده در UI**، نه ستون DB | ⏳ | ⭐ به کاربر ثابت می‌کند عدد از کجاست |
| ۳.۵ | ستون‌های دفتر: تاریخ، نوع، تغییر، مانده تجمعی، دلیل، ثبت‌کننده، نوبت | ⏳ | |
| ۳.۶ | پیام «اعتبار پکیج تمام شده؛ این نوبت نقدی محاسبه می‌شود» | | |
| ۳.۷ | `DataTable` با skeleton و empty state | | |
| ۳.۸ | سرویس‌ها با `SearchableSelect` | | |
| ۳.۹ | `backTo`/`BackButton` روی زیرصفحه‌ها | | |
| ۳.۱۰ | هیچ رنگ/شعاع hard-code | | |
| ۳.۱۱ | دارک‌مود و حالت فشرده | ⏳ | |
| ۳.۱۲ | RTL و موبایل | | |
| ۳.۱۳ | مبالغ با `formatRial` · تاریخ با `formatDate` | | |
| ۳.۱۴ | وضعیت لیست در URL با `useUrlState` | ⏳ | |
| ۳.۱۵ | همهٔ رشته‌ها فارسی | | |
| ۳.۱۶ | دکمهٔ `adjust` فقط برای نقش مدیر نمایش داده می‌شود | | `FeatureGate`/بررسی نقش |
| ۳.۱ | `PackagesPage` با `PriceInput` و انتخاب سرویس | | + ورودی منوی تنظیمات |
| ۳.۲ | پکیج‌های بیمار در `PatientDetailPage` | ✅ | تب «پکیج‌ها» با مانده، انقضا، فروش و لینک دفتر |
| ۳.۳ | `PatientPackageLedgerPage` | | |
| ۳.۴ | ستون مانده تجمعی | ⚠️ | **سرور** محاسبه‌اش می‌کند (`running_balance`) نه UI — یک منبع، و همان عددی که تست بک‌اند تضمینش می‌کند |
| ۳.۵ | ستون‌های دفتر | ⚠️ | تاریخ، نوع، تغییر، مانده، دلیل هست؛ ستون‌های «ثبت‌کننده» و «نوبت» در پاسخ هستند ولی در جدول نمایش داده نمی‌شوند (عرض موبایل) |
| ۳.۶ | پیام «اعتبار تمام شده؛ نقدی محاسبه می‌شود» | | در تب پکیج‌های بیمار |
| ۳.۷ | `DataTable` با skeleton و empty state | | |
| ۳.۸ | سرویس‌ها با `SearchableSelect` | | هیچ `<select>` بومی |
| ۳.۹ | `backTo` روی زیرصفحه‌ها | | |
| ۳.۱۰ | هیچ رنگ/شعاع hard-code | | |
| ۳.۱۱ | دارک‌مود و حالت فشرده | ⚠️ | فقط توکن‌های موجود؛ بازبینی چشمی انجام نشد |
| ۳.۱۲ | RTL و موبایل | | جدول دفتر اسکرول افقی داخلی دارد |
| ۳.۱۳ | `formatRial` و `formatDate` | | |
| ۳.۱۴ | وضعیت لیست در URL | ✅ | `useUrlState` در `PackagesPage` |
| ۳.۱۵ | همهٔ رشته‌ها فارسی | | |
| ۳.۱۶ | دکمهٔ `adjust` فقط برای مدیر | | `can('appointment_settings', 'update')` |
## ۴. تست
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۴.۱ | `CreditLedgerTest``SUM(delta)` در همهٔ سناریوها، append-only | | ⭐ |
| ۴.۲ | `LedgerSchemaTest` هیچ ستون مانده در schema | | ⭐⭐ |
| ۴.۳ | `QuoteDoesNotConsumeTest` — ده `quote` مانده بی‌تغییر | ⏳ | ⭐⭐ |
| ۴.۴ | `ConcurrentConsumeTest` — مانده منفی نمی‌شود | ⏳ | |
| ۴.۵ | `IdempotentConsumeTest``confirm` دوبار یک ردیف | | |
| ۴.۶ | `FifoTest` | | |
| ۴.۷ | `ExpiryTest` — ردیف `expiry` و حذف از finder | ⏳ | |
| ۴.۸ | `AdjustmentAuthTest` منشی ۴۰۳، مدیر بی‌دلیل ۴۲۲ | ⏳ | |
| ۴.۹ | `PackageTenantTest` — پکیج محیط دیگر ۴۰۴ | | |
| ۴.۱۰ | `PricingIntegrationTest` — ردیف `package` منفی + invariant تسک ۰۸ حفظ شد | ⏳ | ⭐ |
| ۴.۱ | `SUM(delta)` در همهٔ سناریوها، append-only | | ⭐ ترتیب `purchase → consume → refund` و ماندهٔ تجمعی |
| ۴.۲ | هیچ ستون مانده در schema | | ⭐⭐ |
| ۴.۳ | `quote` مصرف نمی‌کند | ✅ | ⭐⭐ دو quote پشت‌سرهم، مانده بی‌تغییر |
| ۴.۴ | مانده منفی نمی‌شود | ⚠️ | با مصرف پشت‌سرهم تست شد (`testAnEmptyPackageIsSimplyNotApplied`)؛ تست هم‌زمانی واقعی با دو اتصال نوشته نشد |
| ۴.۵ | مصرف دوباره یک ردیف | | |
| ۴.۶ | FIFO | | |
| ۴.۷ | انقضا | ✅ | نمایش صفر + دستور + ردیف `expiry` |
| ۴.۸ | مجوز اصلاح | ✅ | منشی رد، مدیر بدون دلیل ۴۲۲، منفی‌کردن مانده ۴۲۲ |
| ۴.۹ | جداسازی محیط | | |
| ۴.۱۰ | یکپارچگی با قیمت | ✅ | `final_rials` صفر و ردیف `package` در `breakdown` |
| ۴.۱۱ | تست فرانت دفتر | ✅ | `PatientPackageLedgerPage.test.tsx` |
**اجرا:** `ddev exec php bin/phpunit tests/Package` → ۱۶ تست (۱ skip عمدی: تولید خروجی مستندات).
## ۵. مستندات
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۵.۱ | `docs/api/package.md` | | |
| ۵.۲ | جدول مقایسهٔ قفل بدبینانه (این تسک) با سطل زمانی (تسک ۰۷) | | ⭐ وگرنه «یکدست‌سازی» می‌شود |
| ۵.۳ | `docs/architecture/tenancy.md` — تفاوت دفتر اعتبار با کیف پول | | ⭐ اشتباه گرفتنشان = نشتی مالی |
| ۵.۱ | `docs/api/package.md` | | JSON واقعی از اجرای واقعی |
| ۵.۲ | جدول مقایسهٔ قفل بدبینانه با سطل زمانی | | ⭐ در `package.md` |
| ۵.۳ | `tenancy.md` — تفاوت دفتر اعتبار با کیف پول | | ⭐ |
| ۵.۴ | یادداشت متقابل در `pricing.md` و `appointment-booking.md` | ✅ | |
## ۶. بازبینی پایانی
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۶.۱ | هیچ 🔄 و ⏳ بی‌دلیل نمانده | | |
| ۶.۲ | `bin/phpunit` کامل سبز | | |
| ۶.۳ | `--group=slot-mode-frozen` سبز | | |
| ۶.۴ | `PatientWalletTenantTest` موجود سبز ماند | | |
| ۶.۵ | `phpstan` بدون خطای جدید | | |
| ۶.۶ | `npx tsc --noEmit` و `yarn test` سبز | | |
| ۶.۷ | تست‌های tenant سبز | | |
| ۶.۸ | `docs/api/*` به‌روز | | |
| ۶.۹ | چک‌لیست UI کامل | | |
| ۶.۱۰ | دو کلاینت دیگر بررسی شدند | | مبلغ صفر در رزرو درست نمایش داده می‌شود؟ |
| ۶.۱۱ | commit، سپس `graphify update .` | | |
| ۶.۱۲ | موارد به‌تعویق با دلیل و تسک مقصد | | سیاست بازگشت اعتبار → تسک ۱۳ |
| ۶.۱ | هیچ 🔄 و ⏳ بی‌دلیل نمانده | | ۵ مورد ⚠️ همه با دلیل |
| ۶.۲ | `bin/phpunit` کامل سبز | | ۱۲۶۷ تست |
| ۶.۳ | `--group=slot-mode-frozen` سبز | | |
| ۶.۴ | تست‌های کیف پول سبز ماندند | | |
| ۶.۵ | `phpstan` بدون خطای جدید | | ۱۴ = baseline |
| ۶.۶ | `npx tsc --noEmit` و تست‌های فرانت سبز | | ۶۳۰ تست (روی هاست؛ vitest داخل ddev اجرا نمی‌شود) |
| ۶.۷ | تست‌های tenant سبز | | |
| ۶.۸ | `docs/api/*` به‌روز | | |
| ۶.۹ | چک‌لیست UI کامل | | جز ۳.۱۱ |
| ۶.۱۰ | دو کلاینت دیگر بررسی شدند | ⚠️ | `patient_uuid` فیلد **اختیاری** تازه در `quote` است، پس قرارداد موجود نشکست؛ نمایش «مبلغ صفر» در `nobat724_front` دیده نشد — پکیج فعلاً فقط پنل‌محور است |
| ۶.۱۱ | commit، سپس `graphify update .` | | دو کامیت جدا |
| ۶.۱۲ | موارد به‌تعویق با دلیل | | سیاست بازگشت اعتبار → تسک ۱۳ · تست هم‌زمانی واقعی (۴.۴) · بازبینی چشمی (۳.۱۱) |