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 — و همین به کاربر ثابت میکند
|
||||
عدد از کجا آمده
|
||||
Reference in New Issue
Block a user