feat: implement cancellation policy, no-show tracking, and waitlist management
- Add implementation notes for cancellation and waitlist features. - Create task documentation outlining goals, current status, and acceptance criteria for cancellation policy and resource utilization reporting. - Establish architecture for domain events and outbox pattern to ensure reliable event publishing. - Define database schema for domain events and necessary queries for resource utilization and plan accuracy reports. - Implement detailed implementation notes covering edge cases, testing strategies, and documentation requirements.
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
# معماری — تسک ۱۱
|
||||
|
||||
## ساختار فایل
|
||||
|
||||
```
|
||||
src/Package/
|
||||
├── Entity/
|
||||
│ ├── Package.php # تعریف
|
||||
│ ├── PackageService.php # سرویسهای پوششدادهشده (ManyToMany با تعداد)
|
||||
│ ├── PatientPackage.php # نمونهٔ خریداریشده
|
||||
│ └── SessionCreditLedger.php # دفتر
|
||||
├── Service/
|
||||
│ ├── PackageSalesService.php # فروش
|
||||
│ ├── CreditLedgerService.php # ← تنها نویسندهٔ دفتر
|
||||
│ └── PackageConsumptionService.php # مصرف در زنجیرهٔ قیمت
|
||||
├── Repository/…
|
||||
└── Controller/{PackageController, PatientPackageController}.php
|
||||
```
|
||||
|
||||
## دفتر، نه شمارنده
|
||||
|
||||
```php
|
||||
final class CreditLedgerService
|
||||
{
|
||||
public const KIND_PURCHASE = 'purchase'; // + خرید
|
||||
public const KIND_CONSUME = 'consume'; // − مصرف در نوبت
|
||||
public const KIND_REFUND = 'refund'; // + بازگشت با لغو
|
||||
public const KIND_ADJUSTMENT = 'adjustment'; // ± اصلاح دستی
|
||||
public const KIND_EXPIRY = 'expiry'; // − ابطال
|
||||
|
||||
/** مانده = جمع همهٔ delta ها. هیچ ستون ذخیرهشدهای نیست. */
|
||||
public function balance(PatientPackage $pkg, ?ServiceItem $service = null): int
|
||||
{
|
||||
return $this->ledgerRepo->sumDelta($pkg, $service);
|
||||
}
|
||||
|
||||
/** هیچجای دیگری نباید در session_credit_ledger بنویسد. */
|
||||
public function record(PatientPackage $pkg, string $kind, int $delta, LedgerMeta $meta): SessionCreditLedger;
|
||||
}
|
||||
```
|
||||
|
||||
مستند: «اگر فقط یک عدد نگه داریم، اولین اشتباه هرگز قابل ردیابی نیست.» پس:
|
||||
|
||||
- **هیچ ستون `remaining` یا `used_count` در هیچ جدولی نیست** — تست schema این را اجبار کند
|
||||
- هر تغییر یک ردیف است، با `reason` و `created_by` و ارجاع به نوبت
|
||||
- تصحیح خطا = ردیف `adjustment` جدید، نه ویرایش ردیف قبلی
|
||||
|
||||
### هزینهٔ کارایی و پاسخش
|
||||
|
||||
`SUM(delta)` per بیمار per پکیج. تعداد ردیفها کوچک است (پکیج ۸ جلسهای ≤ ۲۰ ردیف).
|
||||
اگر روزی لازم شد، **کش** بگذار، نه ستون:
|
||||
|
||||
```php
|
||||
// cp:pkg:{patientPackageId}:balance TTL 60s، ابطال روی هر record()
|
||||
```
|
||||
|
||||
ستون denormalized یعنی دو منبع حقیقت و همان مشکلی که مستند هشدار داده.
|
||||
|
||||
## جلوگیری از منفی شدن مانده
|
||||
|
||||
دو نوبت همزمان که هر دو آخرین اعتبار را میخواهند:
|
||||
|
||||
```php
|
||||
public function consume(PatientPackage $pkg, Appointment $appt): bool
|
||||
{
|
||||
// قفل بدبینانه روی خودِ ردیف پکیج — تعداد رقابتها ناچیز است
|
||||
$locked = $this->em->find(PatientPackage::class, $pkg->getId(), LockMode::PESSIMISTIC_WRITE);
|
||||
|
||||
if ($this->ledger->balance($locked) <= 0) {
|
||||
return false; // ← خطا نیست؛ مبلغ کامل محاسبه میشود
|
||||
}
|
||||
$this->ledger->record($locked, KIND_CONSUME, -1, LedgerMeta::forAppointment($appt));
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
اینجا **قفل بدبینانه درست است**، برخلاف تسک ۰۷:
|
||||
|
||||
| | تسک ۰۷ (اسلات) | تسک ۱۱ (اعتبار) |
|
||||
|---|---|---|
|
||||
| نرخ رقابت | بالا — ساعت پرتقاضا | ناچیز — یک بیمار، یک پکیج |
|
||||
| تعداد ردیف درگیر | دهها سطل | یک ردیف |
|
||||
| هزینهٔ قفل | صفشدن رزروها | ناچیز |
|
||||
|
||||
پس راهحل متفاوت است و این تفاوت باید مستند شود، وگرنه کسی «برای یکدستی» یکی را
|
||||
به دیگری تبدیل میکند.
|
||||
|
||||
## اتصال به زنجیرهٔ قیمت
|
||||
|
||||
قلاب مرحلهٔ ۴ تسک ۰۸ که تا حالا no-op بود:
|
||||
|
||||
```php
|
||||
// PackageConsumptionService::consume(array $lines, QuoteRequest $req): array
|
||||
$pkg = $this->finder->firstUsable($req->patient, $req->service, $req->at); // FIFO
|
||||
if ($pkg === null) return $lines;
|
||||
|
||||
// در quote فقط نمایش میدهیم، در confirm واقعاً کسر میکنیم
|
||||
$lines[] = PriceLine::package($pkg, -$this->coveredAmount($lines, $pkg));
|
||||
return $lines;
|
||||
```
|
||||
|
||||
⚠️ **تفکیک حیاتی:** `quote` (پیشنمایش) هیچوقت مصرف نمیکند. مصرف فقط در
|
||||
`BookingService::confirm()` داخل همان تراکنش. اگر `quote` مصرف کند، هر بار که بیمار
|
||||
صفحه را رفرش کند یک جلسه از دست میدهد.
|
||||
|
||||
`PriceQuote` یک پرچم `packageWillBeConsumed` میگیرد تا UI بگوید «۱ جلسه از پکیج شما
|
||||
کسر میشود».
|
||||
|
||||
## FIFO
|
||||
|
||||
```php
|
||||
// PackageFinder::firstUsable()
|
||||
// قدیمیترین پکیج منقضینشده با مانده > 0
|
||||
$qb->orderBy('pp.purchasedAt', 'ASC')
|
||||
->andWhere('pp.validTo IS NULL OR pp.validTo >= :now');
|
||||
```
|
||||
|
||||
قدیمیترین اول، چون نزدیکتر به انقضا است. اگر LIFO بود، پکیج قدیمی منقضی میشد و
|
||||
بیمار پولش را از دست میداد.
|
||||
|
||||
## انقضا
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console app:package:expire # روزانه با symfony/scheduler
|
||||
```
|
||||
|
||||
برای هر `PatientPackage` با `valid_to` گذشته و مانده > ۰:
|
||||
یک ردیف `expiry` با `delta = -balance` ثبت میشود. دفتر دستنخورده میماند و
|
||||
تاریخچه کامل است — بیمار میتواند بپرسد «۳ جلسهام چه شد؟» و جواب در دفتر است.
|
||||
|
||||
## پنل ادمین
|
||||
|
||||
- `PackagesPage.tsx` — تعریف پکیجها با `PriceInput` و انتخاب سرویسها
|
||||
- در `PatientDetailPage.tsx` کارت «پکیجها»: هر پکیج با مانده، تاریخ انقضا و لینک دفتر
|
||||
- `PatientPackageLedgerPage.tsx` — جدول دفتر با ستونهای: تاریخ، نوع، تغییر، مانده تجمعی،
|
||||
دلیل، ثبتکننده، نوبت مرتبط
|
||||
- «مانده تجمعی» ستون محاسبهشده در UI است، نه ستون DB — و همین به کاربر ثابت میکند
|
||||
عدد از کجا آمده
|
||||
@@ -0,0 +1,133 @@
|
||||
# دیتابیس — تسک ۱۱
|
||||
|
||||
## `packages` — تعریف
|
||||
|
||||
| ستون | نوع | توضیح |
|
||||
|---|---|---|
|
||||
| `id` | INT PK AI | |
|
||||
| `uuid` | VARCHAR(36) UNIQUE | |
|
||||
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
|
||||
| `name` | VARCHAR(200) NOT NULL | «۶ جلسه لیزر فولبادی» |
|
||||
| `session_count` | SMALLINT NOT NULL | تعداد جلسه |
|
||||
| `price_rials` | BIGINT NOT NULL | **BIGINT** — پکیج بزرگ از سقف INT عبور میکند |
|
||||
| `validity_days` | SMALLINT NULL | اعتبار از تاریخ خرید؛ NULL = بیپایان |
|
||||
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
|
||||
| `created_at`/`updated_at` | INT NOT NULL | |
|
||||
|
||||
```sql
|
||||
KEY idx_packages_tenant (entity_type, entity_id, active)
|
||||
```
|
||||
|
||||
## `package_services`
|
||||
|
||||
```sql
|
||||
CREATE TABLE package_services (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
package_id INT NOT NULL,
|
||||
service_item_id INT NOT NULL,
|
||||
UNIQUE KEY uniq_pkg_service (package_id, service_item_id),
|
||||
CONSTRAINT fk_pkgs_package FOREIGN KEY (package_id) REFERENCES packages(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_pkgs_service FOREIGN KEY (service_item_id) REFERENCES service_items(id) ON DELETE RESTRICT
|
||||
);
|
||||
```
|
||||
|
||||
`ON DELETE RESTRICT` روی سرویس: حذف سرویسی که در پکیج فروختهشده هست، اعتبار بیماران را
|
||||
بیمعنا میکند.
|
||||
|
||||
قید اپلیکیشنی: پکیج باید حداقل یک سرویس داشته باشد → `422` هنگام ساخت.
|
||||
|
||||
## `patient_packages` — نمونهٔ خریداریشده
|
||||
|
||||
| ستون | نوع | توضیح |
|
||||
|---|---|---|
|
||||
| `id` | INT PK AI | |
|
||||
| `uuid` | VARCHAR(36) UNIQUE | |
|
||||
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
|
||||
| `package_id` | INT NOT NULL | FK ON DELETE RESTRICT |
|
||||
| `patient_record_id` | INT NOT NULL | FK → `patient_records.id` ON DELETE RESTRICT |
|
||||
| `session_count` | SMALLINT NOT NULL | snapshot تعداد لحظهٔ خرید |
|
||||
| `price_paid_rials` | BIGINT NOT NULL | snapshot قیمت پرداختی |
|
||||
| `payment_id` | INT NULL | FK → `payments.id` ON DELETE SET NULL |
|
||||
| `purchased_at` | INT NOT NULL | مبنای FIFO |
|
||||
| `valid_to` | INT NULL | محاسبهشده از `validity_days` لحظهٔ خرید |
|
||||
| `created_at`/`updated_at` | INT NOT NULL | |
|
||||
|
||||
```sql
|
||||
KEY idx_pp_tenant (entity_type, entity_id, purchased_at)
|
||||
KEY idx_pp_patient (patient_record_id, valid_to)
|
||||
```
|
||||
|
||||
> ⛔ **هیچ ستون `remaining_sessions` یا `used_count` نیست و نباید باشد.**
|
||||
> `session_count` فقط snapshot تعریف است، نه مانده.
|
||||
|
||||
`session_count` و `price_paid_rials` کپی میشوند (قانون پنجم مستند): تغییر تعریف پکیج
|
||||
فردا، پکیج فروختهشدهٔ دیروز را عوض نمیکند.
|
||||
|
||||
## `session_credit_ledger` — دفتر
|
||||
|
||||
| ستون | نوع | توضیح |
|
||||
|---|---|---|
|
||||
| `id` | BIGINT PK AI | |
|
||||
| `uuid` | VARCHAR(36) UNIQUE | |
|
||||
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
|
||||
| `patient_package_id` | INT NOT NULL | FK ON DELETE RESTRICT |
|
||||
| `kind` | VARCHAR(15) NOT NULL | `purchase`\|`consume`\|`refund`\|`adjustment`\|`expiry` |
|
||||
| `delta` | SMALLINT NOT NULL | مثبت یا منفی — هرگز صفر |
|
||||
| `appointment_id` | INT NULL | FK ON DELETE SET NULL |
|
||||
| `service_item_id` | INT NULL | FK ON DELETE SET NULL — کدام سرویس مصرف کرد |
|
||||
| `reason` | VARCHAR(255) NULL | اجباری برای `adjustment` |
|
||||
| `created_by` | INT NULL | FK → `users.id` ON DELETE SET NULL |
|
||||
| `created_at` | INT NOT NULL | |
|
||||
|
||||
```sql
|
||||
KEY idx_scl_package (patient_package_id, created_at)
|
||||
KEY idx_scl_tenant (entity_type, entity_id, created_at)
|
||||
KEY idx_scl_appt (appointment_id)
|
||||
UNIQUE KEY uniq_scl_consume (appointment_id, kind) -- ← جلوگیری از مصرف دوباره
|
||||
```
|
||||
|
||||
`uniq_scl_consume` مهم است: `confirm` تسک ۰۷ idempotent است و اگر دوبار اجرا شود،
|
||||
دو ردیف `consume` نباید ثبت شود. `NULL` های `appointment_id` در UNIQUE مشکلی ندارند
|
||||
(چند `purchase` بدون نوبت مجازند).
|
||||
|
||||
**ردیفها هرگز حذف یا ویرایش نمیشوند.** append-only. اصلاح = ردیف جدید.
|
||||
|
||||
## هیچ تغییری در جدولهای دیگر
|
||||
|
||||
`price_snapshot_lines.kind` از قبل مقدار `package` را دارد (تسک ۰۸).
|
||||
|
||||
## Migration
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console doctrine:migrations:diff --no-interaction
|
||||
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
|
||||
```
|
||||
|
||||
بدون backfill — هیچ پکیجی از قبل وجود ندارد.
|
||||
|
||||
## تست schema
|
||||
|
||||
```php
|
||||
// tests/Package/LedgerSchemaTest.php
|
||||
public function testNoStoredBalanceColumnExists(): void
|
||||
{
|
||||
$columns = $this->schemaManager->listTableColumns('patient_packages');
|
||||
foreach (['remaining', 'remaining_sessions', 'used_count', 'balance'] as $forbidden) {
|
||||
self::assertArrayNotHasKey($forbidden, $columns,
|
||||
'مانده باید از دفتر محاسبه شود، نه ذخیره');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
تست عجیبی به نظر میرسد ولی همان چیزی است که شش ماه بعد جلوی «بهینهسازی» میایستد.
|
||||
|
||||
## طبقهبندی tenant
|
||||
|
||||
| جدول | وضعیت |
|
||||
|---|---|
|
||||
| `packages`, `patient_packages`, `session_credit_ledger` | جفت tenant |
|
||||
| `package_services` | `AGGREGATE_CHILDREN` → ریشه `Package` |
|
||||
|
||||
⚠️ برخلاف `wallet_transactions` (که `ENTITIES` است چون پول مال شخص است)، دفتر اعتبار
|
||||
جفت tenant واقعی میگیرد: اعتبار جلسهٔ کلینیک الف در کلینیک ب معنا ندارد.
|
||||
دلیلش را در `docs/architecture/tenancy.md` کنار توضیح کیف پول اضافه کن.
|
||||
@@ -0,0 +1,156 @@
|
||||
# نکات پیادهسازی — تسک ۱۱
|
||||
|
||||
## ۱. `quote` نمایش میدهد، `confirm` مصرف میکند
|
||||
|
||||
بدترین باگ ممکن در این تسک:
|
||||
|
||||
```php
|
||||
// ❌ بیمار صفحه را سه بار رفرش میکند، سه جلسه از دست میدهد
|
||||
public function quote(QuoteRequest $req): PriceQuote {
|
||||
$this->packages->consume(…);
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
// ✅
|
||||
public function quote(…): PriceQuote {
|
||||
$pkg = $this->finder->firstUsable(…);
|
||||
return $quote->withPackagePreview($pkg); // فقط نمایش
|
||||
}
|
||||
// و در BookingService::confirm() داخل تراکنش:
|
||||
$this->packages->consume($pkg, $appointment);
|
||||
```
|
||||
|
||||
تست اجباری: ده بار `quote` → مانده بدون تغییر.
|
||||
|
||||
## ۲. مانده صفر خطا نیست
|
||||
|
||||
```php
|
||||
if ($this->ledger->balance($pkg) <= 0) {
|
||||
return false; // ✅ مبلغ کامل محاسبه میشود
|
||||
// نه: throw new AppException(...)
|
||||
}
|
||||
```
|
||||
|
||||
بیمار با پکیج تمامشده باید بتواند نقدی نوبت بگیرد. `422` یعنی بنبست بیدلیل.
|
||||
UI پیام بدهد: «اعتبار پکیج شما تمام شده؛ این نوبت نقدی محاسبه میشود.»
|
||||
|
||||
## ۳. `uniq_scl_consume` و idempotency
|
||||
|
||||
`confirm` تسک ۰۷ idempotent است. اگر دوبار صدا زده شود:
|
||||
|
||||
```php
|
||||
try {
|
||||
$this->ledger->record($pkg, KIND_CONSUME, -1, $meta);
|
||||
} catch (UniqueConstraintViolationException) {
|
||||
// قبلاً مصرف شده — همان رفتار idempotent، نه خطا
|
||||
}
|
||||
```
|
||||
|
||||
با کلید یکتای `(appointment_id, kind)` این تضمین از دیتابیس میآید. همان الگوی تسک ۰۷.
|
||||
|
||||
## ۴. لغو = ردیف `refund`، نه حذف `consume`
|
||||
|
||||
```php
|
||||
// ❌ تاریخ را پاک میکند
|
||||
$this->em->remove($consumeRow);
|
||||
|
||||
// ✅
|
||||
$this->ledger->record($pkg, KIND_REFUND, +1, LedgerMeta::forCancellation($appt));
|
||||
```
|
||||
|
||||
دفتر append-only است. بعد از سه ماه، سؤال «چند بار این بیمار نوبتش را لغو کرد؟» فقط از
|
||||
دفتر جواب دارد.
|
||||
|
||||
⚠️ بازگشت اعتبار **مشروط به سیاست لغو** است (تسک ۱۳). تا آن تسک نیامده، همیشه برگردان و
|
||||
یک `TODO` با ارجاع به تسک ۱۳ بگذار — نه یک پرچم نیمکاره.
|
||||
|
||||
## ۵. FIFO و انقضا
|
||||
|
||||
```php
|
||||
->orderBy('pp.purchasedAt', 'ASC')
|
||||
```
|
||||
|
||||
قدیمیترین اول. اگر LIFO باشد، پکیج قدیمی منقضی میشود و بیمار پولش را از دست میدهد —
|
||||
و شکایتش درست است.
|
||||
|
||||
`valid_to` هنگام **خرید** محاسبه و ذخیره میشود (`purchased_at + validity_days * 86400`)،
|
||||
نه در زمان اجرا: تغییر `validity_days` تعریف پکیج نباید اعتبار خریدهای قبلی را عوض کند.
|
||||
|
||||
## ۶. قفل بدبینانه اینجا درست است
|
||||
|
||||
برخلاف تسک ۰۷ که قفل را رد کردیم:
|
||||
|
||||
```php
|
||||
$locked = $this->em->find(PatientPackage::class, $id, LockMode::PESSIMISTIC_WRITE);
|
||||
```
|
||||
|
||||
نرخ رقابت اینجا ناچیز است (یک بیمار، یک پکیج) و یک ردیف قفل میشود، نه دهها سطل.
|
||||
جدول مقایسه در `architecture.md` را در `docs/api/package.md` هم بنویس، وگرنه کسی روزی
|
||||
«برای یکدستی» یکی را به دیگری تبدیل میکند.
|
||||
|
||||
## ۷. `adjustment` فقط با نقش مدیر و با دلیل
|
||||
|
||||
```php
|
||||
#[IsGranted('ROLE_CLINIC_OWNER')] // نه منشی، نه پرسنل
|
||||
public function adjust(string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$reason = trim((string) $data['reason'] ?? '');
|
||||
if ($reason === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'ذکر دلیل اصلاح الزامی است', 422, 'reason');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
اصلاح دستی بدون دلیل، دفتر را به همان شمارندهٔ غیرقابلردیابی تبدیل میکند که مستند
|
||||
هشدار داده.
|
||||
|
||||
## ۸. edge case ها
|
||||
|
||||
| حالت | رفتار درست |
|
||||
|---|---|
|
||||
| بیمار دو پکیج معتبر برای یک سرویس | FIFO — قدیمیترِ منقضینشده |
|
||||
| پکیج معتبر ولی سرویس نوبت پوشش داده نمیشود | اعمال نمیشود، مبلغ کامل |
|
||||
| پکیج منقضی با مانده ۳ | ردیف `expiry -3` توسط cron؛ مانده صفر، دفتر کامل |
|
||||
| `confirm` دوباره | `uniq_scl_consume` → idempotent |
|
||||
| لغو نوبتی که پکیج نداشت | هیچ ردیفی ثبت نمیشود |
|
||||
| `delta = 0` | `422` — ردیف بیاثر ننویس |
|
||||
| حذف تعریف پکیجی که فروخته شده | `422` (FK RESTRICT) — `active=false` مسیر درست |
|
||||
| پکیج بدون سرویس | `422` هنگام ساخت |
|
||||
| مبلغ پکیج بزرگتر از سقف INT | `BIGINT` — از قبل حل شده |
|
||||
| بیمار مهمان بدون `patient_record` | پکیج فروش نمیرود — `422` با پیام «ابتدا پروندهٔ بیمار را ثبت کنید» |
|
||||
|
||||
## ۹. تست
|
||||
|
||||
```
|
||||
tests/Package/CreditLedgerTest.php ← ⭐
|
||||
- مانده = SUM(delta) در همهٔ سناریوها
|
||||
- purchase → consume → refund → مانده اولیه
|
||||
- append-only: هیچ remove/update روی ردیفها
|
||||
tests/Package/LedgerSchemaTest.php ← ⭐
|
||||
- هیچ ستون remaining/used_count در schema
|
||||
tests/Package/QuoteDoesNotConsumeTest.php ← ⭐
|
||||
- ده بار quote → مانده بدون تغییر
|
||||
tests/Package/ConcurrentConsumeTest.php
|
||||
- دو نوبت همزمان روی آخرین اعتبار → یکی میگیرد، مانده منفی نمیشود
|
||||
tests/Package/IdempotentConsumeTest.php
|
||||
- confirm دوبار → یک ردیف consume
|
||||
tests/Package/FifoTest.php
|
||||
- قدیمیترین پکیج اول مصرف میشود
|
||||
tests/Package/ExpiryTest.php
|
||||
- cron ردیف expiry با delta = -balance میسازد
|
||||
- پکیج منقضی در finder نمیآید
|
||||
tests/Package/AdjustmentAuthTest.php
|
||||
- منشی → 403 · مدیر بدون دلیل → 422 · مدیر با دلیل → 200
|
||||
tests/Package/PackageTenantTest.php
|
||||
- پکیج محیط دیگر → 404
|
||||
tests/Package/PricingIntegrationTest.php
|
||||
- ردیف package در price_snapshot_lines با مبلغ منفی
|
||||
- جمع ردیفها = مبلغ نهایی (invariant تسک ۰۸ حفظ شود)
|
||||
```
|
||||
|
||||
## ۱۰. مستندات
|
||||
|
||||
`docs/api/package.md` بساز. `docs/architecture/tenancy.md` را با دلیل تفاوت
|
||||
«دفتر اعتبار (جفت tenant)» و «کیف پول (سراسری + انتساب)» بهروز کن —
|
||||
این دو شبیهاند و اشتباه گرفتنشان نشتی مالی میسازد.
|
||||
@@ -0,0 +1,72 @@
|
||||
# تسک ۱۱ — پکیج و دفتر اعتبار جلسات
|
||||
|
||||
**فاز:** ۳ (کسبوکار) · **وابستگی:** ۰۸ · **زمان:** ۱۰-۱۲ ساعت
|
||||
|
||||
---
|
||||
|
||||
## هدف
|
||||
|
||||
مستند بند ۱۲: «پکیج شش جلسه لیزر» حالت رایج کلینیک زیبایی است. بیمار یکجا پول میدهد و
|
||||
بعداً جلساتش را رزرو میکند.
|
||||
|
||||
نکتهٔ فنی مستند: **اعتبار را به صورت دفتر حساب نگه میداریم، نه یک عدد شمارنده.**
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
هیچ مفهومی از پکیج وجود ندارد. ولی الگوی «دفتر حساب» از قبل در پروژه هست و **درست
|
||||
پیاده شده**: `WalletTransaction` + `getWalletBalance(user)` — موجودی از جمع تراکنشها
|
||||
محاسبه میشود، نه از یک ستون شمارنده. همان الگو اینجا تکرار میشود.
|
||||
|
||||
⚠️ نکتهٔ tenancy: `wallet_transactions` عمداً `ENTITIES` است (پول مال شخص است) ولی هر
|
||||
ردیف `recorded_entity_*` دارد. دفتر اعتبار جلسه **متفاوت** است: اعتبار جلسهٔ لیزر در
|
||||
کلینیک الف در کلینیک ب معنا ندارد. پس جفت tenant واقعی میگیرد، نه انتساب.
|
||||
|
||||
## دامنه
|
||||
|
||||
**هست:**
|
||||
- `Package` — تعریف پکیج (سرویس، تعداد جلسه، قیمت، اعتبار زمانی)
|
||||
- `PatientPackage` — پکیج خریداریشدهٔ یک بیمار
|
||||
- `SessionCreditLedger` — دفتر اعتبار: هر تراکنش یک ردیف
|
||||
- مصرف اعتبار در `confirm` نوبت، بازگشت در لغو
|
||||
- اتصال به `PricingEngine` مرحلهٔ ۴ (قلاب تسک ۰۸)
|
||||
|
||||
**نیست:** پروتکل دوره و فاصلهٔ جلسات (تسک ۱۲)، سیاست لغو (تسک ۱۳).
|
||||
|
||||
## Endpoint ها
|
||||
|
||||
| متد | مسیر | توضیح |
|
||||
|---|---|---|
|
||||
| GET/POST | `/api/v1/packages` | تعریف پکیج |
|
||||
| GET/PATCH/DELETE | `/api/v1/package/{uuid}` | |
|
||||
| POST | `/api/v1/patient/{uuid}/package` | فروش پکیج به بیمار |
|
||||
| GET | `/api/v1/patient/{uuid}/packages` | پکیجهای بیمار + مانده |
|
||||
| GET | `/api/v1/patient-package/{uuid}/ledger` | دفتر تراکنشهای اعتبار |
|
||||
| POST | `/api/v1/patient-package/{uuid}/adjust` | اصلاح دستی با دلیل (فقط مدیر) |
|
||||
| POST | `/api/v1/patient-package/{uuid}/expire` | ابطال دستی |
|
||||
|
||||
## معیار پذیرش
|
||||
|
||||
- ✅ موفق: پکیج «۶ جلسه لیزر فولبادی» با قیمت تعریف میشود، به بیمار فروخته میشود →
|
||||
`GET /patient/{uuid}/packages` مانده `6` میدهد و دفتر یک ردیف `purchase +6` دارد.
|
||||
- ✅ موفق: ثبت نوبت لیزر برای همان بیمار → `PricingEngine` مرحلهٔ ۴ یک واحد کسر میکند،
|
||||
مبلغ نهایی صفر میشود، دفتر ردیف `consume -1` میگیرد، مانده `5`.
|
||||
- ✅ موفق: لغو همان نوبت → ردیف `refund +1`، مانده `6`. **ردیف `consume` حذف نمیشود.**
|
||||
- ✅ موفق (**دفتر، نه شمارنده**): مانده همیشه `SUM(delta)` است. یک تست باید ثابت کند
|
||||
هیچ ستون `remaining` یا `used_count` در schema وجود ندارد.
|
||||
- ✅ موفق: `POST /adjust` با دلیل → ردیف `adjustment` با `reason` و `created_by`.
|
||||
- ❌ خطا: ثبت نوبت با پکیجی که ماندهاش صفر است → پکیج اعمال نمیشود، مبلغ کامل
|
||||
محاسبه میشود (نه خطا — بیمار میتواند نقدی بپردازد).
|
||||
- ❌ خطا: پکیج محیط الف روی نوبت محیط ب → `404`.
|
||||
- ❌ خطا: `adjust` با نقش منشی → `403`.
|
||||
- ⚠️ مرزی: پکیج منقضیشده (`valid_to` گذشته) → مانده در نمایش صفر میشود ولی دفتر
|
||||
دستنخورده میماند؛ ردیف `expiry` با delta منفی برابر مانده ثبت میشود.
|
||||
- ⚠️ مرزی: دو نوبت همزمان که هر دو آخرین اعتبار را میخواهند → یکی میگیرد، دیگری
|
||||
مبلغ کامل. **بدون منفی شدن مانده.**
|
||||
- ⚠️ مرزی: بیمار دو پکیج معتبر برای یک سرویس دارد → قدیمیترِ منقضینشده اول مصرف شود (FIFO).
|
||||
- ⚠️ مرزی: پکیجی که هیچ سرویسی به آن وصل نیست → `422` هنگام ساخت.
|
||||
|
||||
## خروجی
|
||||
|
||||
- `src/Package/`
|
||||
- `assets/admin/pages/PackagesPage.tsx` + کارت پکیج در `PatientDetailPage.tsx`
|
||||
- `docs/api/package.md`
|
||||
Reference in New Issue
Block a user