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

## ۱. اول تست خط سرخ، بعد هر چیز دیگر

ترتیب کار:

```
۱. tests/Appointment/SlotModeFrozenTest.php + سه fixture   ← اول این
۲. ddev exec php bin/phpunit --group=slot-mode-frozen      ← باید سبز باشد قبل از هر تغییری
۳. بقیهٔ تسک
۴. دوباره گام ۲ — باید همچنان سبز باشد
```

اگر fixture را بعد از تغییرات بسازی، هیچ چیزی را تضمین نکرده‌ای — snapshot وضعیت
تغییریافته را گرفته‌ای.

## ۲. `isServiceMode` گِیت همه‌چیز است

هر خط کد جدید در مسیر مشترک باید داخل این شرط باشد:

```php
if ($this->serviceCalc->isServiceMode($doctor, $clinic)) {
    // … منطق جدید
}
```

نه بیرونش، نه با `??`، نه با «اگر سرویس دارد». معیار **فقط** `booking_mode` است.
نوبت اسلاتی هم می‌تواند `service_item_id` داشته باشد (فیلدهای Figma نوبت‌ها) — آن دلیل
سرویسی بودن نیست.

اشتباه رایج:

```php
// ❌ نوبت اسلاتیِ دارای سرویس را وارد مسیر جدید می‌کند
if ($appointment->getServiceItems()->count() > 0) { … }
```

## ۳. جمع سادهٔ مدت را همین‌جا اصلاح نکن

```php
$totalMinutes += $duration;   // ← اشتباه است، ولی دست نزن
```

مستند بند ۵ می‌گوید این فرمول ظرفیت را الکی پر می‌کند و راه‌حلش «زمان تنها / زمان اضافه»
است — که تسک ۰۴ می‌سازد. اصلاحش اینجا یعنی:

- مدت همهٔ نوبت‌های چندسرویسیِ در حال رزرو یک‌شبه کم می‌شود
- سایت و اپ دسکتاپ عدد متفاوت می‌بینند بدون اینکه چیزی در build بشکند
- و هیچ داده‌ای برای «زمان اضافه» وجود ندارد، پس اصلاح بی‌ورودی غیرممکن است

کاری که این تسک می‌کند: محاسبه را به **یک نقطه** منتقل می‌کند تا تسک ۰۴ یک خط عوض کند.

## ۴. `excludeAppointmentId` — الگوی موجود را تکرار کن

`AppointmentRepository::isSlotTaken($doctor, $start, $end, $excludeId)` از قبل این پارامتر
را دارد. `findBusyIntervals` هم همان را بگیرد، با همان نام و همان جای پارامتر و همان
پیش‌فرض `null`.

دو الگوی متفاوت برای یک کار (مثلاً یکی `?int $excludeId`، دیگری `array $excludeIds`)
یعنی اولین کسی که هر دو را می‌بیند یکی را اشتباه صدا می‌زند.

## ۵. اعتبارسنجی زمان: عضویت در فهرست، نه «اشغال نبودن»

```php
// ❌ ناکافی
if ($this->appointmentRepo->isSlotTaken($doctor, $start, $end, $excludeId)) { /* 409 */ }

// ✅
$starts = $this->slotCalculator->getServiceStartTimes(…);
if (!in_array($req->start, array_column($starts, 'start'), true)) { /* 422 */ }
```

`isSlotTaken` فقط تداخل با نوبت دیگر را می‌گوید. `getServiceStartTimes` علاوه بر آن
شیفت، تعطیلی، `date_override`، پنجرهٔ رزرو و بافر را هم اعمال می‌کند. با شرط اول،
منشی می‌تواند نوبت را ساعت ۳ بامداد بگذارد.

## ۶. `warnings[]` به‌جای `422` برای سرویس غیرفعال در نوبت موجود

```php
$duration = $this->serviceCalc->calculate(…, allowInactive: true);
// $duration->warnings === ['سرویس «لیزر صورت» دیگر برای نوبت‌دهی فعال نیست']
```

نوبت موجود با سرویسی که کلینیک غیرفعالش کرده، باید قابل جابه‌جایی و لغو بماند. `422`
یعنی آن نوبت برای همیشه قفل می‌شود و منشی هیچ کاری نمی‌تواند بکند.

ولی **افزودن** سرویس غیرفعال به نوبت → `422`. تفاوتش `allowInactive` است که فقط برای
uuid های موجودِ نوبت `true` می‌شود، نه برای uuid های تازه‌ی درخواست.

## ۷. `replaceServiceItems` باید `serviceItem` تکی را هم‌گام کند

```php
$this->serviceItem = $items[0] ?? null;
```

چهار مصرف‌کننده روی `service_item` تکی خوانده‌اند (`AppointmentsPage`،
`ReserveAppointmentsPage`، `nobat724_front/services/response.js`،
`clinic-pro-tauri/src/service/response.js`). این دقیقاً همان الگویی است که
`ServiceItem::setStaffMembers()` برای `staff` تکی دارد — تکرارش کن.

## ۸. `refreshActiveSlotKey` پس از `setIsReserve(false)`

```php
public function setIsReserve(bool $v): self
{
    $this->isReserve = $v;
    $this->refreshActiveSlotKey();      // ← اگر نیست، اضافه کن
    $this->updatedAt = time();
    return $this;
}
```

بدون آن، رزروِ تبدیل‌شده `active_slot_key = NULL` می‌ماند و دو نفر می‌توانند همان ساعت
را بگیرند. **این تنها تغییر مجاز در مکانیزم `active_slot_key` است** و فقط چون یک شرط
موجود را اعمال می‌کند، نه عوضش می‌کند. تست: `ConvertReserveSlotKeyTest`.

## ۹. تبدیل رزرو، اتمی

```php
$this->em->wrapInTransaction(function () use ($reserve, $req) {
    $duration = …;                     // یا اسلات اسلاتی
    $reserve->setIsReserve(false);
    $reserve->reschedule($req->start, $end);
    $reserve->replaceServiceItems($duration->serviceItems);
    // UniqueConstraintViolationException روی active_slot_key → 409
});
```

`try/catch` روی `UniqueConstraintViolationException` و ترجمه به `409 ERR_SLOT_TAKEN` —
همان چیزی که `SlotTakenException` موجود در `src/Appointment/Repository/` انجام می‌دهد.
از همان استفاده کن.

## ۱۰. `ReserveAppointmentsPage` → `DataTable`

صفحه امروز جدول خام با `<td style={td}>` دارد. چون در این تسک دستش می‌زنیم، همان‌جا به
`DataTable` مهاجرت کند: توکن inline خلاف [ui-conventions](../_shared/ui-conventions.md)
است و در دارک‌مود می‌شکند.

این «scope creep» نیست — قاعدهٔ پروژه است که صفحهٔ دست‌خورده باید با دیزاین‌سیستم بخواند.

## ۱۱. edge case ها

| حالت | رفتار درست |
|---|---|
| نوبت سرویسی بدون هیچ سرویس (داده قدیمی) | مدت موجود حفظ · `warnings[]` · **رد نمی‌شود** |
| `PATCH` فقط یادداشت روی نوبت سرویسی | بدون اعتبارسنجی مدت — مثل امروز |
| `service-reschedule` روی نوبت اسلاتی | `422 ERR_WRONG_BOOKING_MODE` |
| `service-reschedule` روی نوبت رزرو | `422` — مسیرش `convert-reserve` است |
| زمان فعلی نوبت با سرویس جدید | با `excludeAppointmentId` در فهرست می‌آید |
| بافر عوض شد بعد از ثبت | نوبت موجود سالم؛ فقط جابه‌جایی جدید بافر جدید می‌گیرد |
| سرویس محیط دیگر در `service_item_uuids[]` | `404` — `TenantOwnershipChecker` **پیش از** هر بررسی دیگر |
| `durations` override با مقدار ۰ یا منفی | نادیده گرفته شود (رفتار موجود `serviceSlots`) |
| نوبت گذشته | `service-reschedule` → `422` |
| دو درخواست جابه‌جایی هم‌زمان به یک زمان | `active_slot_key` → یکی `409` |
| نوبت در محیطی که وسط کار به اسلاتی برگشت | `booking_mode` قفل است پس رخ نمی‌دهد؛ ولی اگر داده دستی عوض شد → `422` روشن |

## ۱۲. تست

```
tests/Appointment/SlotModeFrozenTest.php                    ← ⭐ اول از همه
  - قرارداد appointment-slots بیت‌به‌بیت
  - قرارداد month-availability
  - امضای متدهای عمومی SlotCalculatorService
tests/Appointment/ServiceBookingCalculatorTest.php
  - جمع مدت چند سرویس (رفتار فعلی حفظ شود)
  - override منشی
  - سرویس بدون مدت → 422
  - سرویس غیرفعال با allowInactive → warning نه خطا
  - سرویس محیط دیگر → استثنا، بدون افشای وجود
tests/Appointment/ServiceRescheduleTest.php                 ← ⭐
  - جابه‌جایی با همان سرویس‌ها → مدت یکسان
  - حذف یک سرویس → مدت خودکار کم می‌شود، کلاینت عددی نفرستاده
  - زمان بیرون getServiceStartTimes → 422
  - زمان فعلی خود نوبت با excludeAppointmentId در فهرست است
  - روی نوبت اسلاتی → 422 ERR_WRONG_BOOKING_MODE
tests/Appointment/PatchServiceDurationTest.php
  - slot_end ناسازگار → 422 ERR_SERVICE_DURATION_MISMATCH با مدت درست در پیام
  - service_item_uuids[] جایگزینی کامل می‌کند و serviceItem تکی هم‌گام می‌شود
  - در حالت اسلاتی هیچ‌کدام از این‌ها اجرا نمی‌شود (رفتار امروز)
tests/Appointment/ConvertReserveTest.php
  - رزرو سرویسی → نوبت زمان‌دار با مدت درست
  - رزرو اسلاتی → مسیر اسلاتی، بدون تغییر
  - active_slot_key بعد از تبدیل پر می‌شود
  - دو تبدیل هم‌زمان → یکی 409
tests/Appointment/ServiceModeSectionDurationTest.php        ← موجود، باید سبز بماند
tests/Appointment/BookingTenantTest.php                     ← موجود، باید سبز بماند
assets/admin/pages/AppointmentEditPage.test.tsx
  - حالت اسلاتی: سه فیلد ساعت هست، ServiceSlotPicker نیست
  - حالت سرویسی: ServiceSlotPicker هست، فیلد ساعت پنهان است
  - تغییر سرویس‌ها، slot انتخابی را باطل می‌کند
```

## ۱۳. مستندات

`docs/api/appointment.md`:
- بخش «روش‌های نوبت‌دهی» با ماتریس «کدام endpoint در کدام حالت»
- دو endpoint جدید
- توسعهٔ `PATCH` و پارامتر `exclude_appointment_uuid`
- دو کد خطای جدید

`docs/api/appointment-settings.md`: یادآوری قفل بودن `booking_mode` پس از اولین ثبت.

و یک سند کوتاه `docs/architecture/booking-modes.md` که ماتریس کامل را ثبت کند —
تسک ۰۶ حالت سومی به همین ماتریس اضافه می‌کند.
