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:
@@ -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)
|
||||
|
||||
@@ -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 نباید
|
||||
باشد. تست عجیبی به نظر میرسد ولی همان چیزی است که شش ماه بعد جلوی «بهینهسازی»
|
||||
میایستد.
|
||||
@@ -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)
|
||||
|
||||
@@ -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 .` | ✅ | دو کامیت جدا |
|
||||
| ۶.۱۲ | موارد بهتعویق با دلیل | ✅ | سیاست بازگشت اعتبار → تسک ۱۳ · تست همزمانی واقعی (۴.۴) · بازبینی چشمی (۳.۱۱) |
|
||||
|
||||
Reference in New Issue
Block a user