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