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:
hamed
2026-07-30 11:43:58 +03:30
parent 1d338503c8
commit 021d0eb6b2
62 changed files with 8098 additions and 0 deletions
@@ -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 — و همین به کاربر ثابت می‌کند
عدد از کجا آمده