Files
clinicpro/docs/new_feture/taskes/task-11-package-credit-ledger/architecture.md
T
hamed 021d0eb6b2 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.
2026-07-30 11:43:58 +03:30

139 lines
6.2 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.
# معماری — تسک ۱۱
## ساختار فایل
```
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 — و همین به کاربر ثابت می‌کند
عدد از کجا آمده