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

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