Files
clinicpro/docs/new_feture/taskes/task-07-hold-and-book/implementation_notes.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

184 lines
8.9 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.
# نکات پیاده‌سازی — تسک ۰۷
## ۱. تست همزمانی واقعی، نه 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` بگیرد که راه‌حل سطل زمانی و
دلیل رد گزینه‌های دیگر را ثبت کند — این تصمیمی است که شش ماه بعد کسی زیر سؤال می‌برد.