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,183 @@
# نکات پیاده‌سازی — تسک ۰۷
## ۱. تست همزمانی واقعی، نه mock
این تسک بدون یک تست همزمانی واقعی تمام نیست. mock کردن `UniqueConstraintViolationException`
هیچ چیزی را اثبات نمی‌کند — چیزی که باید ثابت شود این است که **دیتابیس** جلویش را می‌گیرد.
```php
// tests/Appointment/ConcurrentHoldTest.php
$conn1 = $this->newConnection(); // دو اتصال مجزا، نه دو EntityManager روی یک اتصال
$conn2 = $this->newConnection();
$conn1->beginTransaction();
$conn2->beginTransaction();
$r1 = $this->tryHold($conn1, $resourceId, $bucket, 0);
$r2 = $this->tryHold($conn2, $resourceId, $bucket, 0); // باید بلاک یا شکست بخورد
$conn1->commit();
// دقیقاً یکی موفق
self::assertSame(1, (int) $r1['ok'] + (int) $r2['ok']);
```
اگر اجرای موازی واقعی در محیط CI سخت است، حداقل دو اتصال DBAL مجزا با تراکنش‌های
باز هم‌زمان استفاده کن. `assertSame(1, …)` تنها معیار قبولی است.
## ۲. به `assignment` کلاینت اعتماد نکن
```php
// ❌ فاجعه
foreach ($request['assignment'] as $role => $resourceUuid) {
$occupancy->setResource($this->resourceRepo->findByUuid($resourceUuid));
}
// ✅
$plan = $this->planBuilder->build(); // برنامه را خودت بساز
foreach ($plan->requirements() as $req) {
$chosen = $request['assignment'][$req->key()] ?? null;
if ($chosen === null || !in_array($chosen, $req->candidateUuids, true)) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'منبع انتخابی برای این خدمت معتبر نیست', 422);
}
}
```
سه نشتی ثبت‌شده در `docs/architecture/tenancy.md` همگی از همین شکل بودند: uuid از بدنه
آمد و کسی محیطش را نسنجید. اینجا حتی سنجیدن محیط کافی نیست — منبع باید **کاندید همان
نیازمندی** باشد.
## ۳. ترتیب نوشتن سطل‌ها — جلوگیری از deadlock
دو تراکنش که سطل‌ها را به ترتیب متفاوت `INSERT` کنند، deadlock می‌سازند.
قاعده: **همیشه مرتب بر اساس `(resource_id, bucket, unit_index)` صعودی**.
```php
$rows = $this->buildSlotRows($assignment, $segments);
usort($rows, fn($a, $b) => [$a['resource_id'], $a['bucket'], $a['unit_index']]
<=> [$b['resource_id'], $b['bucket'], $b['unit_index']]);
foreach ($rows as $row) { $this->insertOrFail($row); }
```
بدون این، تست همزمانی گاهی `Deadlock found when trying to get lock` می‌دهد و کسی
فکر می‌کند تست flaky است.
## ۴. `unit_index` — جستجوی خطی، نه `SELECT`
```php
for ($idx = 0; $idx < $capacity; $idx++) {
try { $this->conn->insert(, ['unit_index' => $idx]); return $idx; }
catch (UniqueConstraintViolationException) { continue; }
}
throw new SlotTakenException();
```
وسوسه می‌شود اول `SELECT` بزنی که کدام index آزاد است. نکن — بین `SELECT` و `INSERT`
پنجرهٔ رقابت باز می‌شود و کل مزیت این طراحی از بین می‌رود. با `capacity` معمول (۱ تا ۵)
حلقه ارزان است.
## ۵. `setup/cleanup` در بازهٔ اشغال، نه بخش
```php
$occStart = $segment->getStartAt() - $resource->getSetupMinutes() * 60;
$occEnd = $segment->getEndAt() + $resource->getCleanupMinutes() * 60;
```
و `appointment_segments.start_at/end_at` بدون آن‌ها. بیمار ساعت ۱۰:۰۰ می‌آید؛ یونیت از
۹:۵۵ اشغال است. دو عدد متفاوت، دو ستون متفاوت.
## ۶. رویدادها بعد از commit
```php
// ❌ اگر تراکنش rollback شود، پیامک رفته و نوبتی وجود ندارد
$this->bus->dispatch(new AppointmentBooked($appointment));
$this->em->flush();
// ✅
$this->bus->dispatch(
(new Envelope(new AppointmentBooked($appointment->getUuid())))
->with(new DispatchAfterCurrentBusStamp())
);
```
و در payload رویداد **uuid** بفرست، نه entity — تسک ۱۴ همین قرارداد را دارد.
## ۷. لغو = آزادسازی، نه حذف
```php
// همهٔ ردیف‌های اشغال نوبت
$occupancy->setStatus(ResourceOccupancy::STATUS_RELEASED);
// ولی ردیف‌های سطل حذف فیزیکی می‌شوند تا جا آزاد شود
$this->conn->delete('resource_occupancy_slot', ['occupancy_id' => $occupancy->getId()]);
```
`resource_occupancy` برای آدیت و گزارش بهره‌وری می‌ماند. `resource_occupancy_slot` فقط
مکانیزم قفل است و ردیف مرده در آن یعنی ظرفیت مسدود.
## ۸. `reschedule` اتمی
```php
$this->em->wrapInTransaction(function () use ($appointment, $newStart) {
$newHold = $this->holdService->hold(); // ۱ اگر شکست بخورد، همه‌چیز rollback
$this->occupancyWriter->release($appointment); // ۲
$this->transition($appointment, STATUS_RESCHEDULED);// ۳
$this->linkReschedule($appointment, $newHold); // ۴
});
```
ترتیب مهم است: **اول hold جدید، بعد آزادسازی قدیم**. برعکسش یعنی اگر hold جدید شکست
بخورد، بیمار هم نوبت قدیم را از دست داده هم جدید نگرفته.
## ۹. edge case ها
| حالت | رفتار درست |
|---|---|
| hold روی نوبتی که همان لحظه cron منقضی‌اش کرد | `409 ERR_HOLD_EXPIRED` — نه ۵۰۰ |
| `confirm` دوباره روی همان hold | idempotent: نوبت قبلاً `confirmed` → همان را برگردان، نه خطا |
| منبع بین hold و confirm غیرفعال شد | `confirm` موفق — اشغال گرفته شده و کلینیک باید دستی حل کند. لاگ هشدار |
| نوبت `is_reserve=true` (لیست رزرو موجود) | هیچ ردیف اشغالی نمی‌سازد — روزی است، نه ساعتی |
| ظرفیت ۳، سه hold، یکی منقضی | سطل آزاد می‌شود و چهارمی می‌تواند بگیرد |
| بازهٔ اشغال دقیقاً روی مرز سطل | `intdiv($end - 1, 300)` — تست مرزی اجباری |
| نوبت گذشته | hold روی زمان گذشته → `422` |
| `capacity` منبع بعد از ثبت کم شد | اشغال‌های موجود می‌مانند (over-subscription موقت)، جدید رد می‌شود. در پنل هشدار |
| دو بخش مجاور یک نوبت روی یک منبع | دو ردیف اشغال، سطل‌های متمایز (به لطف `-1`) |
## ۱۰. تست
```
tests/Appointment/Booking/OccupancyWriterTest.php
- سطل‌های [10:00, 10:30) و [10:30, 11:00) تداخل ندارند
- capacity=3 → سه unit_index، چهارمی SlotTakenException
- ترتیب مرتب INSERT
tests/Appointment/ConcurrentHoldTest.php ← ⭐ اجباری
- دو تراکنش موازی → دقیقاً یکی موفق
tests/Appointment/HoldLifecycleTest.php
- hold → زمان از availability حذف می‌شود
- انقضا → دوباره ظاهر می‌شود
- DELETE hold → فوری آزاد
tests/Appointment/BookingConfirmTest.php
- confirm موفق → همهٔ اشغال‌ها booked و expires_at null
- confirm hold دیگری → 404
- confirm منقضی → 409
- confirm دوباره → idempotent
tests/Appointment/CapacityReleaseIntegrationTest.php ← ⭐
- بعد از ثبت نوبت لیزر، اپراتور در بازهٔ انتظار هیچ ردیف اشغالی ندارد
- و جستجوی بیمار دوم آن بازه را پیدا می‌کند
tests/Appointment/RescheduleTest.php
- شکست hold جدید → نوبت قدیم دست‌نخورده
tests/Appointment/OccupancyBackfillTest.php
- نوبت‌های موجود ردیف اشغال می‌گیرند؛ idempotent
tests/Appointment/LegacyBookingUnchangedTest.php
- POST /api/v1/appointment قدیمی دقیقاً مثل قبل کار کند
tests/Appointment/BookingTenantTest.php ← موجود، باید سبز بماند
```
## ۱۱. مستندات
`docs/api/appointment-booking.md` بساز. حتماً بنویس:
- گرانولاریتی ۵ دقیقه‌ای و محدودیتش
- قرارداد `hold_uuid` و TTL
- کدهای خطا: `ERR_SLOT_TAKEN` (409)، `ERR_HOLD_EXPIRED` (409)
- در `ErrorCodes.php` هر دو کد با پیام فارسی ثبت شوند
`docs/architecture/` یک سند جدید `booking-concurrency.md` بگیرد که راه‌حل سطل زمانی و
دلیل رد گزینه‌های دیگر را ثبت کند — این تصمیمی است که شش ماه بعد کسی زیر سؤال می‌برد.