feat(booking): multi-resource holds and confirmation with a database-level guarantee

Section 11 and the third closing rule of the design document: preventing a double
booking is the database's job, not the code's. Any "is it free?" check in PHP has a
race window between the read and the write — two concurrent requests both see free
and both write.

MariaDB has no range EXCLUDE constraint, so every occupied interval is broken into
fixed five-minute buckets under UNIQUE(resource_id, bucket_at, seat). The code only
INSERTs; a rejection from the database *is* the answer. `seat` carries capacity: a
three-bed room has seats 0..2, allocation walks upward on each collision, and the
fourth concurrent hold finds nowhere to sit. Counting capacity in PHP would have
rebuilt the very race this removes.

Buckets are written through DBAL rather than the ORM on purpose: a unique violation
raised inside flush() closes the EntityManager, and the next seat attempt would then
fail with "EntityManager is closed", hiding the real outcome.

Occupancy is one row per (segment × resource). The reference test asserts the payoff
directly: for a 55-minute appointment of numbing / waiting / laser, the room gets
three rows and the operator only two — the operator holds nothing during the wait and
stays bookable for someone else.

A partial hold never survives. If the second resource has no room, the first is
released and the hold itself removed; otherwise a resource stays locked for an
appointment that will never exist.

Confirming does not re-reserve anything — the seats were taken at hold time and only
the label changes. Re-reserving on confirm would reopen the race the hold closed.
Cancelling marks rows `released` instead of deleting them, because the history of
which resource was busy when is the input to the utilisation reports; the uniqueness
buckets *are* deleted, or that interval would stay locked forever.

Expired holds are released by the existing scheduler rather than a new one. That
exposed a bug in my own change: the flush guard used $count, which now includes
released holds, so reset([]) could pass false to save(). It is guarded on $expired.

The appointment itself is still built with the existing constructor, so
active_slot_key, events and the payment path behave exactly as before — the
multi-resource occupancy sits beside them, not instead of them.

12 tests. Two matter most: the second hold on the same resource and interval getting
409, and a test that writes a duplicate bucket row over a *separate connection* and
expects the unique-key violation — if that one ever passes silently, the guarantee
had moved back into the code.

1208 tests / 3495 assertions. phpstan at its 14-error baseline. Frozen slot contract
green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-31 09:28:55 +03:30
co-authored by Claude Opus 5
parent 5d93208383
commit 4395eea56e
18 changed files with 1848 additions and 77 deletions
+1
View File
@@ -87,6 +87,7 @@ Only **digits** are translated — no characters are stripped, so `IR` in a sheb
| [resource-calendar.md](resource-calendar.md) | Resource calendars, exceptions, national holidays | 9 |
| [appointment-plan.md](appointment-plan.md) | Appointment segments and plan preview | 3 |
| [appointment-availability.md](appointment-availability.md) | Multi-resource availability search | 2 |
| [appointment-booking.md](appointment-booking.md) | Holds, confirmation and multi-resource occupancy | 4 |
| [appointment.md](appointment.md) | Appointments & slot booking | 6 |
| [appointment-settings.md](appointment-settings.md) | Weekly schedule, date overrides, holidays | 14 |
| [payment.md](payment.md) | Payments (Mellat / Sep) | 5 |
+159
View File
@@ -0,0 +1,159 @@
# Appointment Booking API — رزرو موقت و ثبت نهایی چندمنبعی
> **Base:** `/api/v1` · **Auth:** JWT
> دنبالهٔ [appointment-availability.md](appointment-availability.md).
---
## سه مرحله
```
جستجو → رزرو موقت (hold) → ثبت نهایی (confirm)
```
مرحلهٔ میانی لازم است چون بین دیدن یک زمان و ثبتش، فاصله هست: بیمار فرم پر می‌کند،
پرداخت می‌کند، مردد می‌شود. بدون رزرو موقت، همان زمان به چند نفر پیشنهاد می‌شود و
آخری خطا می‌گیرد.
## چرا تضمین در دیتابیس است، نه در کد
قانون سوم جمع‌بندی مستند: **«جلوگیری از رزرو تکراری کار دیتابیس است، نه کار کد.»**
هر بررسیِ «آیا آزاد است؟» در PHP یک پنجرهٔ مسابقه بین خواندن و نوشتن دارد؛ دو درخواست
هم‌زمان هر دو «آزاد» می‌بینند و هر دو می‌نویسند.
MariaDB قید `EXCLUDE` بازه‌ای ندارد، پس هر بازهٔ اشغال به **سطل‌های ثابت پنج‌دقیقه‌ای**
شکسته می‌شود و کلید یکتای زیر تداخل را غیرممکن می‌کند:
```sql
UNIQUE (resource_id, bucket_at, seat)
```
کد فقط `INSERT` می‌زند؛ اگر دیتابیس ردش کرد، همان یعنی «گرفته شده».
**`seat` ظرفیت را بیان می‌کند.** اتاق سه‌تخته صندلی‌های ۰ تا ۲ دارد؛ تلاش از صندلی ۰
شروع می‌شود و با هر برخورد یکی جلو می‌رود. چهارمین رزروِ هم‌زمان جایی برای نشستن پیدا
نمی‌کند و `409` می‌گیرد. شمردن ظرفیت در PHP دقیقاً همان مسابقه‌ای را می‌ساخت که این
طراحی حذفش می‌کند.
---
## `POST /api/v1/appointment-hold`
```json
{
"service_uuid": "…",
"branch_uuid": "…",
"start": 1785562200,
"item_uuids": ["…"],
"patient_gender": "female",
"assignment": { "room": ["…"], "operator": ["…"], "device": ["…"] }
}
```
`assignment` همان چیزی است که جستجوی وقت پیشنهاد داده. **هر نیازمندی باید منبع داشته
باشد**؛ وگرنه `422` — رزروی که نصف منابع لازم را بگیرد، هنگام حضور بیمار کم می‌آورد.
**۲۰۱:**
```json
{
"success": true,
"data": {
"hold_uuid": "…",
"starts_at": 1785562200,
"ends_at": 1785565800,
"expires_at": 1785563100,
"confirmed": false,
"assignment": { "room": [{ "uuid": "…", "name": "اتاق ۲" }] }
}
}
```
مهلت **۹۰۰ ثانیه** است — همان مهلتی که پرداخت نوبت دارد؛ دو عدد متفاوت یعنی دو حقیقت
متفاوت.
بلافاصله پس از رزرو، `POST /appointment-availability` آن زمان را دیگر برنمی‌گرداند.
**۴۰۹ `ERR_SLOT_TAKEN`:** منبع در آن بازه ظرفیت خالی ندارد.
**۴۲۲:** نبودِ منبع برای یک نقش · `assignment` خالی.
**۴۰۴:** سرویس، شعبه یا منبع محیط دیگر.
> **رزرو نیمه‌کاره نمی‌ماند.** اگر منبع دوم جا نداشت، منبع اول هم آزاد می‌شود و خودِ
> رزرو حذف — وگرنه منبعی قفل می‌ماند که هرگز نوبتی رویش ثبت نمی‌شود.
## `DELETE /api/v1/appointment-hold/{uuid}`
آزادسازی زودهنگام؛ آن زمان بلافاصله دوباره در جستجو ظاهر می‌شود.
رزروی که ثبت نهایی شده آزاد نمی‌شود (`422`).
## `POST /api/v1/appointment-confirm`
```json
{ "hold_uuid": "…", "doctor_uuid": "…", "patient_uuid": "…" }
```
`patient_uuid` اختیاری است؛ نبودش یعنی خودِ کاربر (منشی می‌تواند برای دیگری ثبت کند).
**۲۰۰:** `appointment_uuid` + بازه + تخصیص.
تبدیل `hold → booked` **هیچ منبعی را دوباره نمی‌گیرد**: صندلی‌ها از لحظهٔ رزرو موقت
گرفته شده‌اند و اینجا فقط برچسبشان عوض می‌شود. اگر ثبت نهایی دوباره رزرو می‌کرد، همان
پنجرهٔ مسابقه‌ای که رزرو موقت حذفش کرده بود برمی‌گشت.
**۴۰۹ `ERR_HOLD_EXPIRED`** روی رزروِ منقضی · **۴۰۹ `ERR_SLOT_TAKEN`** روی رزروِ
قبلاً ثبت‌شده · **۴۰۴** روی رزرو کاربر دیگر (نه ۴۰۳ — وجودش نباید لو برود).
## `POST /api/v1/appointment/{uuid}/rebook`
جابه‌جایی: **اول** رزرو جدید، بعد آزادسازی قدیم. ترتیب عمدی است — اگر رزرو جدید شکست
بخورد، نوبت قدیمی دست‌نخورده می‌ماند و بیمار بی‌نوبت نمی‌شود.
---
## اشغال: یک ردیف per (بخش × منبع)
نه یکی per نوبت. همین ریزدانگی ظرفیت آزاد می‌کند.
مثال واقعی (و تستِ مرجع): نوبت ۵۵ دقیقه‌ای با بخش‌های بی‌حسی ۵ · انتظار ۳۰ · لیزر ۲۰:
| منبع | تعداد ردیف اشغال |
|---|---|
| اتاق | **۳** — هر سه بخش |
| اپراتور | **۲** — فقط بی‌حسی و لیزر |
اپراتور در بازهٔ انتظار **هیچ ردیفی ندارد** و برای بیمار دیگری آزاد است.
بازهٔ ثبت‌شده گسترده‌تر از بازهٔ بخش است: زمان آماده‌سازی و تمیزکاری منبع هم درونش
می‌آید.
### `status`
| مقدار | یعنی |
|---|---|
| `hold` | رزرو موقت، تا پایان مهلت |
| `booked` | نوبت قطعی |
| `released` | لغو یا منقضی |
**لغو، ردیف را حذف فیزیکی نمی‌کند.** تاریخچهٔ اینکه چه منبعی کِی گرفته شده بود ورودی
گزارش بهره‌وری است؛ حذفش یعنی پاک کردن همان چیزی که قرار است اندازه بگیریم. ولی
سطل‌های یکتایی حذف می‌شوند، وگرنه آن زمان برای همیشه قفل می‌ماند.
---
## تور ایمنی دوگانه
نوبت با همان سازندهٔ موجود ساخته می‌شود، پس `active_slot_key`، رویدادها و مسیر پرداخت
دقیقاً مثل قبل کار می‌کنند. اشغال چندمنبعی **کنار** آن می‌نشیند، نه به‌جایش.
---
## تست‌ها
```bash
ddev exec php bin/phpunit tests/Appointment/HoldAndBookTest.php # ۱۲ تست
```
دو تست از همه مهم‌ترند: رزرو دوم روی همان منبع و بازه که `409` می‌گیرد، و تستی که
**مستقیم روی یک اتصال جدا** ردیف تکراری می‌نویسد و انتظار نقض کلید یکتا دارد — اگر آن
یکی بشکند، یعنی تضمین فقط در کد بوده است.
@@ -1,6 +1,6 @@
# چک‌لیست — تسک ۰۷ (رزرو موقت و ثبت نهایی چندمنبعی)
**وضعیت کلی:** ⏳ شروع نشده · **آخرین بازبینی:**
**وضعیت کلی:** ✅ بک‌اند، تضمین دیتابیسی و مستندات تکمیل (UI ⏳) · **آخرین بازبینی:**
قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) ·
[red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md)
@@ -11,112 +11,112 @@
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۰.۱ | `--group=slot-mode-frozen` سبز | | |
| ۰.۲ | `active_slot_key` و `refreshActiveSlotKey()` دست‌نخورده و **فعال** | | دو تور ایمنی موازی |
| ۰.۳ | `slot_start`/`slot_end` باقی ماندند | | چهار مصرف‌کننده رویشان کوئری می‌زنند |
| ۰.۴ | `is_reserve` دست‌نخورده — رزرو هیچ ردیف اشغالی نمی‌سازد | | |
| ۰.۵ | `POST /api/v1/appointment` قدیمی بیت‌به‌بیت کار می‌کند | | `LegacyBookingUnchangedTest` |
| ۰.۶ | `PAYMENT_TTL` و رفتار انقضای موجود حفظ شد | | |
| ۰.۱ | `--group=slot-mode-frozen` سبز | | |
| ۰.۲ | `active_slot_key` و `refreshActiveSlotKey()` دست‌نخورده و **فعال** | | دو تور ایمنی موازی |
| ۰.۳ | `slot_start`/`slot_end` باقی ماندند | | چهار مصرف‌کننده رویشان کوئری می‌زنند |
| ۰.۴ | `is_reserve` دست‌نخورده — رزرو هیچ ردیف اشغالی نمی‌سازد | | |
| ۰.۵ | `POST /api/v1/appointment` قدیمی بیت‌به‌بیت کار می‌کند | | `LegacyBookingUnchangedTest` |
| ۰.۶ | `PAYMENT_TTL` و رفتار انقضای موجود حفظ شد | | |
## ۱. تضمین همزمانی — قلب تسک
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۱.۱ | `resource_occupancy_slot` با `UNIQUE(resource_id, bucket, unit_index)` | | ⭐ کل تضمین اینجاست |
| ۱.۲ | `BUCKET_SECONDS = 300` ثابت + کامنت هشدار تغییرش | | |
| ۱.۳ | سطل‌ها با `intdiv($end - 1, 300)` — نه بدون `-1` | | ⭐ وگرنه نوبت مجاور رد می‌شود |
| ۱.۴ | `unit_index` با **INSERT پشت‌سرهم**، نه `SELECT` قبلش | | ⭐ پنجرهٔ رقابت |
| ۱.۵ | ردیف‌ها مرتب بر `(resource_id, bucket, unit_index)` درج می‌شوند | | ⭐ جلوگیری از deadlock |
| ۱.۶ | `SlotTakenException` موجود بازاستفاده شد | | |
| ۱.۷ | محدودیت گرانولاریتی ۵ دقیقه در مستندات صریح | | |
| ۱.۱ | `resource_occupancy_slot` با `UNIQUE(resource_id, bucket, unit_index)` | | ⭐ کل تضمین اینجاست |
| ۱.۲ | `BUCKET_SECONDS = 300` ثابت + کامنت هشدار تغییرش | | |
| ۱.۳ | سطل‌ها با `intdiv($end - 1, 300)` — نه بدون `-1` | | ⭐ وگرنه نوبت مجاور رد می‌شود |
| ۱.۴ | `unit_index` با **INSERT پشت‌سرهم**، نه `SELECT` قبلش | | ⭐ پنجرهٔ رقابت |
| ۱.۵ | ردیف‌ها مرتب بر `(resource_id, bucket, unit_index)` درج می‌شوند | | ⭐ جلوگیری از deadlock |
| ۱.۶ | `SlotTakenException` موجود بازاستفاده شد | | |
| ۱.۷ | محدودیت گرانولاریتی ۵ دقیقه در مستندات صریح | | |
## ۲. بک‌اند
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۲.۱ | `ResourceOccupancy` · `AppointmentSegment` | | |
| ۲.۲ | `OccupancyWriter`**تنها** نویسندهٔ `resource_occupancy` | | |
| ۲.۳ | `HoldService` · `BookingService` · `RescheduleService` | | |
| ۲.۴ | **یک ردیف per (بخش × منبع)** — نه per نوبت | | ⭐ آزادسازی ظرفیت |
| ۲.۵ | برنامه در `hold` **دوباره ساخته می‌شود**؛ `assignment` کلاینت فقط اعتبارسنجی می‌شود | | ⭐ سه نشتی ثبت‌شده از همین شکل بودند |
| ۲.۶ | منبع باید **کاندید همان نیازمندی** باشد، نه فقط هم‌محیط | | |
| ۲.۷ | `confirm` هفت مرحله در **یک** تراکنش | | |
| ۲.۸ | `confirm` idempotent — دوباره روی همان hold خطا نمی‌دهد | | |
| ۲.۹ | رویداد **بعد از** commit (`DispatchAfterCurrentBusStamp`) با uuid در payload | | ⭐ تسک ۱۲ رویش حساب می‌کند |
| ۲.۱۰ | `reschedule`: اول hold جدید، بعد آزادسازی قدیم | | ⭐ ترتیب |
| ۲.۱۱ | لغو = `status='released'` + **حذف فیزیکی** ردیف‌های سطل | | |
| ۲.۱۲ | `setup/cleanup` در بازهٔ اشغال، نه در `appointment_segments` | | |
| ۲.۱۳ | `STATUS_RESCHEDULED` + گذارهای مجاز | | |
| ۲.۱۴ | `ExpireAppointmentsHandler` موجود توسعه یافت | | آزادسازی + حذف سطل |
| ۲.۱۵ | قلاب‌های تسک ۰۸ و ۰۹ در `confirm` (مراحل ۳ و ۶) | | |
| ۲.۱۶ | چهار endpoint | | |
| ۲.۱۷ | دو کد خطا در `ErrorCodes.php` با پیام فارسی | | `ERR_SLOT_TAKEN` · `ERR_HOLD_EXPIRED` |
| ۲.۱ | `ResourceOccupancy` · `AppointmentSegment` | | |
| ۲.۲ | `OccupancyWriter`**تنها** نویسندهٔ `resource_occupancy` | | |
| ۲.۳ | `HoldService` · `BookingService` · `RescheduleService` | | |
| ۲.۴ | **یک ردیف per (بخش × منبع)** — نه per نوبت | | ⭐ آزادسازی ظرفیت |
| ۲.۵ | برنامه در `hold` **دوباره ساخته می‌شود**؛ `assignment` کلاینت فقط اعتبارسنجی می‌شود | | ⭐ سه نشتی ثبت‌شده از همین شکل بودند |
| ۲.۶ | منبع باید **کاندید همان نیازمندی** باشد، نه فقط هم‌محیط | | |
| ۲.۷ | `confirm` هفت مرحله در **یک** تراکنش | | |
| ۲.۸ | `confirm` idempotent — دوباره روی همان hold خطا نمی‌دهد | | |
| ۲.۹ | رویداد **بعد از** commit (`DispatchAfterCurrentBusStamp`) با uuid در payload | | ⭐ تسک ۱۲ رویش حساب می‌کند |
| ۲.۱۰ | `reschedule`: اول hold جدید، بعد آزادسازی قدیم | | ⭐ ترتیب |
| ۲.۱۱ | لغو = `status='released'` + **حذف فیزیکی** ردیف‌های سطل | | |
| ۲.۱۲ | `setup/cleanup` در بازهٔ اشغال، نه در `appointment_segments` | | |
| ۲.۱۳ | `STATUS_RESCHEDULED` + گذارهای مجاز | | |
| ۲.۱۴ | `ExpireAppointmentsHandler` موجود توسعه یافت | | `AppointmentExpiryService::expireHolds()` — همان زمان‌بند موجود |
| ۲.۱۵ | قلاب‌های تسک ۰۸ و ۰۹ در `confirm` (مراحل ۳ و ۶) | | |
| ۲.۱۶ | چهار endpoint | | |
| ۲.۱۷ | دو کد خطا در `ErrorCodes.php` با پیام فارسی | | `ERR_SLOT_TAKEN` · `ERR_HOLD_EXPIRED` |
## ۳. دیتابیس
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۳.۱ | `resource_occupancy` (BIGINT id) با چهار ایندکس | | |
| ۳.۲ | `resource_occupancy_slot` با UNIQUE | | |
| ۳.۳ | `appointment_segments` با snapshot `name`/`segment_type` | | قانون پنجم |
| ۳.۴ | سه ستون تهی‌پذیر روی `appointments` | | `branch_id` · `plan_total_minutes` · `patient_facing_minutes` |
| ۳.۵ | ترتیب ستون ایندکس‌ها **دستی** در migration | | |
| ۳.۶ | `app:occupancy:backfill --force` — idempotent، نوبت‌های بی‌منبع را گزارش می‌کند | | ⭐ بدون آن رزرو جدید روی نوبت قدیم می‌نشیند |
| ۳.۷ | `app:occupancy:prune --older-than=90d` | | |
| ۳.۸ | `resource_occupancy_slot` در `AGGREGATE_CHILDREN` + هرگز کوئری مستقیم | | |
| ۳.۹ | `TenantSchemaCoverageTest` سبز | | |
| ۳.۱ | `resource_occupancy` (BIGINT id) با چهار ایندکس | | |
| ۳.۲ | `resource_occupancy_slot` با UNIQUE | | |
| ۳.۳ | `appointment_segments` با snapshot `name`/`segment_type` | | قانون پنجم |
| ۳.۴ | سه ستون تهی‌پذیر روی `appointments` | | `branch_id` · `plan_total_minutes` · `patient_facing_minutes` |
| ۳.۵ | ترتیب ستون ایندکس‌ها **دستی** در migration | | |
| ۳.۶ | `app:occupancy:backfill --force` — idempotent، نوبت‌های بی‌منبع را گزارش می‌کند | | ⭐ بدون آن رزرو جدید روی نوبت قدیم می‌نشیند |
| ۳.۷ | `app:occupancy:prune --older-than=90d` | | |
| ۳.۸ | `resource_occupancy_slot` در `AGGREGATE_CHILDREN` + هرگز کوئری مستقیم | | |
| ۳.۹ | `TenantSchemaCoverageTest` سبز | | |
## ۴. UI
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۴.۱ | تایمر شمارش معکوس hold در UI رزرو | ⏳ | |
| ۴.۲ | خطای `409` با پیام «این ساعت همین لحظه رزرو شد» + **لیست جایگزین خودکار** | ⏳ | ⭐ مستند بند ۱۷ |
| ۴.۳ | خطای `reschedule` شامل «نوبت فعلی تغییری نکرد» | ⏳ | |
| ۴.۴ | مسدودسازی موردی منبع از صفحهٔ منابع | ⏳ | |
| ۴.۵ | تفکیک «مسدودسازی موردی» (occupancy) از «بلندمدت» (exception) در UI روشن است | ⏳ | دو راه یک کار گیج‌کننده است |
| ۴.۶ | هیچ رنگ/شعاع hard-code | ⏳ | |
| ۴.۷ | دارک‌مود و حالت فشرده | ⏳ | |
| ۴.۸ | RTL و موبایل | ⏳ | |
| ۴.۹ | همهٔ رشته‌ها فارسی | ⏳ | |
| ۴.۱۰ | `AppointmentDetailPage` بخش بخش‌های نوبت (فقط حالت `resource`) | ⏳ | |
| ۴.۱ | تایمر شمارش معکوس hold در UI رزرو | ⏳ | UI این تسک ساخته نشد — چهار اندپوینت کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI رزرو چندمنبعی |
| ۴.۲ | خطای `409` با پیام «این ساعت همین لحظه رزرو شد» + **لیست جایگزین خودکار** | ⏳ | UI این تسک ساخته نشد — چهار اندپوینت کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI رزرو چندمنبعی |
| ۴.۳ | خطای `reschedule` شامل «نوبت فعلی تغییری نکرد» | ⏳ | UI این تسک ساخته نشد — چهار اندپوینت کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI رزرو چندمنبعی |
| ۴.۴ | مسدودسازی موردی منبع از صفحهٔ منابع | ⏳ | UI این تسک ساخته نشد — چهار اندپوینت کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI رزرو چندمنبعی |
| ۴.۵ | تفکیک «مسدودسازی موردی» (occupancy) از «بلندمدت» (exception) در UI روشن است | ⏳ | UI این تسک ساخته نشد — چهار اندپوینت کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI رزرو چندمنبعی |
| ۴.۶ | هیچ رنگ/شعاع hard-code | ⏳ | UI این تسک ساخته نشد — چهار اندپوینت کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI رزرو چندمنبعی |
| ۴.۷ | دارک‌مود و حالت فشرده | ⏳ | UI این تسک ساخته نشد — چهار اندپوینت کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI رزرو چندمنبعی |
| ۴.۸ | RTL و موبایل | ⏳ | UI این تسک ساخته نشد — چهار اندپوینت کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI رزرو چندمنبعی |
| ۴.۹ | همهٔ رشته‌ها فارسی | ⏳ | UI این تسک ساخته نشد — چهار اندپوینت کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI رزرو چندمنبعی |
| ۴.۱۰ | `AppointmentDetailPage` بخش بخش‌های نوبت (فقط حالت `resource`) | ⏳ | UI این تسک ساخته نشد — چهار اندپوینت کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI رزرو چندمنبعی |
## ۵. تست
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۵.۱ | `ConcurrentHoldTest`**دو اتصال واقعی**، دقیقاً یکی موفق | | ⭐⭐ mock قبول نیست |
| ۵.۲ | `OccupancyWriterTest` — بازهٔ مماس، capacity، ترتیب INSERT | | |
| ۵.۳ | `HoldLifecycleTest` — hold/انقضا/آزادسازی زودهنگام | | |
| ۵.۴ | `BookingConfirmTest` — hold دیگری ۴۰۴، منقضی ۴۰۹، idempotent | | |
| ۵.۵ | `CapacityReleaseIntegrationTest` | | ⭐⭐ اپراتور در بازهٔ انتظار ردیف ندارد |
| ۵.۶ | `RescheduleTest` — شکست hold جدید → نوبت قدیم سالم | | |
| ۵.۷ | `OccupancyBackfillTest` — idempotent | | |
| ۵.۸ | `LegacyBookingUnchangedTest` | | ⭐ |
| ۵.۹ | `BookingTenantTest` موجود سبز | | |
| ۵.۱ | `ConcurrentHoldTest`**دو اتصال واقعی**، دقیقاً یکی موفق | | ⭐⭐ mock قبول نیست |
| ۵.۲ | `OccupancyWriterTest` — بازهٔ مماس، capacity، ترتیب INSERT | | |
| ۵.۳ | `HoldLifecycleTest` — hold/انقضا/آزادسازی زودهنگام | | |
| ۵.۴ | `BookingConfirmTest` — hold دیگری ۴۰۴، منقضی ۴۰۹، idempotent | | |
| ۵.۵ | `CapacityReleaseIntegrationTest` | | ⭐⭐ اپراتور در بازهٔ انتظار ردیف ندارد |
| ۵.۶ | `RescheduleTest` — شکست hold جدید → نوبت قدیم سالم | | |
| ۵.۷ | `OccupancyBackfillTest` — idempotent | | |
| ۵.۸ | `LegacyBookingUnchangedTest` | | ⭐ |
| ۵.۹ | `BookingTenantTest` موجود سبز | | |
## ۶. مستندات
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۶.۱ | `docs/api/appointment-booking.md` | | |
| ۶.۲ | گرانولاریتی ۵ دقیقه و محدودیتش | | |
| ۶.۳ | قرارداد `hold_uuid` و TTL | | |
| ۶.۴ | تفکیک مسدودسازی موردی/بلندمدت | | |
| ۶.۵ | `docs/architecture/booking-concurrency.md` — سطل زمانی + دلیل رد دو گزینهٔ دیگر | | ⭐ شش ماه بعد زیر سؤال می‌رود |
| ۶.۱ | `docs/api/appointment-booking.md` | | |
| ۶.۲ | گرانولاریتی ۵ دقیقه و محدودیتش | | |
| ۶.۳ | قرارداد `hold_uuid` و TTL | | |
| ۶.۴ | تفکیک مسدودسازی موردی/بلندمدت | | |
| ۶.۵ | `docs/architecture/booking-concurrency.md` — سطل زمانی + دلیل رد دو گزینهٔ دیگر | | ⭐ شش ماه بعد زیر سؤال می‌رود |
## ۷. بازبینی پایانی
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۷.۱ | هیچ 🔄 و ⏳ بی‌دلیل نمانده | | |
| ۷.۲ | `bin/phpunit` کامل سبز | | |
| ۷.۳ | `--group=slot-mode-frozen` سبز | | |
| ۷.۴ | `phpstan` بدون خطای جدید | | |
| ۷.۵ | `npx tsc --noEmit` و `yarn test` سبز | | |
| ۷.۶ | تست‌های tenant سبز | | |
| ۷.۷ | `docs/api/*` به‌روز | | |
| ۷.۸ | چک‌لیست UI کامل | | |
| ۷.۹ | دو کلاینت دیگر بررسی شدند | | `slot_start/slot_end` سالم است؟ |
| ۷.۱۰ | commit، سپس `graphify update .` | | |
| ۷.۱۱ | موارد به‌تعویق با دلیل و تسک مقصد | | |
| ۷.۱ | هیچ 🔄 و ⏳ بی‌دلیل نمانده | | |
| ۷.۲ | `bin/phpunit` کامل سبز | | |
| ۷.۳ | `--group=slot-mode-frozen` سبز | | |
| ۷.۴ | `phpstan` بدون خطای جدید | | |
| ۷.۵ | `npx tsc --noEmit` و `yarn test` سبز | | |
| ۷.۶ | تست‌های tenant سبز | | |
| ۷.۷ | `docs/api/*` به‌روز | | |
| ۷.۸ | چک‌لیست UI کامل | | |
| ۷.۹ | دو کلاینت دیگر بررسی شدند | | `slot_start/slot_end` سالم است؟ |
| ۷.۱۰ | commit، سپس `graphify update .` | | |
| ۷.۱۱ | موارد به‌تعویق با دلیل و تسک مقصد | | |