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

8.9 KiB
Raw Blame History

نکات پیاده‌سازی — تسک ۰۷

۱. تست همزمانی واقعی، نه mock

این تسک بدون یک تست همزمانی واقعی تمام نیست. mock کردن UniqueConstraintViolationException هیچ چیزی را اثبات نمی‌کند — چیزی که باید ثابت شود این است که دیتابیس جلویش را می‌گیرد.

// 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 کلاینت اعتماد نکن

// ❌ فاجعه
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) صعودی.

$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

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 در بازهٔ اشغال، نه بخش

$occStart = $segment->getStartAt() - $resource->getSetupMinutes() * 60;
$occEnd   = $segment->getEndAt()   + $resource->getCleanupMinutes() * 60;

و appointment_segments.start_at/end_at بدون آن‌ها. بیمار ساعت ۱۰:۰۰ می‌آید؛ یونیت از ۹:۵۵ اشغال است. دو عدد متفاوت، دو ستون متفاوت.

۶. رویدادها بعد از commit

// ❌ اگر تراکنش 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 — تسک ۱۴ همین قرارداد را دارد.

۷. لغو = آزادسازی، نه حذف

// همهٔ ردیف‌های اشغال نوبت
$occupancy->setStatus(ResourceOccupancy::STATUS_RELEASED);
// ولی ردیف‌های سطل حذف فیزیکی می‌شوند تا جا آزاد شود
$this->conn->delete('resource_occupancy_slot', ['occupancy_id' => $occupancy->getId()]);

resource_occupancy برای آدیت و گزارش بهره‌وری می‌ماند. resource_occupancy_slot فقط مکانیزم قفل است و ردیف مرده در آن یعنی ظرفیت مسدود.

۸. reschedule اتمی

$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 بگیرد که راه‌حل سطل زمانی و دلیل رد گزینه‌های دیگر را ثبت کند — این تصمیمی است که شش ماه بعد کسی زیر سؤال می‌برد.