diff --git a/docs/api/README.md b/docs/api/README.md index cc112b97..e11f2e35 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -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 | diff --git a/docs/api/appointment-booking.md b/docs/api/appointment-booking.md new file mode 100644 index 00000000..1691a617 --- /dev/null +++ b/docs/api/appointment-booking.md @@ -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` می‌گیرد، و تستی که +**مستقیم روی یک اتصال جدا** ردیف تکراری می‌نویسد و انتظار نقض کلید یکتا دارد — اگر آن +یکی بشکند، یعنی تضمین فقط در کد بوده است. diff --git a/docs/new_feture/taskes/task-07-hold-and-book/checklist.md b/docs/new_feture/taskes/task-07-hold-and-book/checklist.md index 22c5cef6..9a71c9d7 100644 --- a/docs/new_feture/taskes/task-07-hold-and-book/checklist.md +++ b/docs/new_feture/taskes/task-07-hold-and-book/checklist.md @@ -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 .` | ✅ | | +| ۷.۱۱ | موارد به‌تعویق با دلیل و تسک مقصد | ✅ | | diff --git a/migrations/Version20260731054324.php b/migrations/Version20260731054324.php new file mode 100644 index 00000000..f31fd346 --- /dev/null +++ b/migrations/Version20260731054324.php @@ -0,0 +1,51 @@ +addSql('CREATE TABLE appointment_holds (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, starts_at INT NOT NULL, ends_at INT NOT NULL, expires_at INT NOT NULL, payload JSON NOT NULL, confirmed_at INT DEFAULT NULL, created_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, user_id INT NOT NULL, UNIQUE INDEX UNIQ_6905A14BD17F50A6 (uuid), INDEX IDX_6905A14BA76ED395 (user_id), INDEX idx_hold_tenant (entity_type, entity_id), INDEX idx_hold_expiry (expires_at), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE appointment_segments (id INT AUTO_INCREMENT NOT NULL, sequence SMALLINT NOT NULL, name VARCHAR(150) NOT NULL, starts_at INT NOT NULL, ends_at INT NOT NULL, patient_present TINYINT DEFAULT 1 NOT NULL, appointment_id INT NOT NULL, INDEX IDX_13EA50E1E5B533F9 (appointment_id), INDEX idx_appointment_segment_seq (appointment_id, sequence), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE resource_occupancy_buckets (id INT AUTO_INCREMENT NOT NULL, bucket_at INT NOT NULL, seat SMALLINT NOT NULL, resource_id INT NOT NULL, occupancy_id INT NOT NULL, INDEX IDX_9BE4732989329D25 (resource_id), INDEX idx_bucket_occupancy (occupancy_id), UNIQUE INDEX uniq_bucket_resource_seat (resource_id, bucket_at, seat), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE appointment_holds ADD CONSTRAINT FK_6905A14BA76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE appointment_segments ADD CONSTRAINT FK_13EA50E1E5B533F9 FOREIGN KEY (appointment_id) REFERENCES appointments (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE resource_occupancy_buckets ADD CONSTRAINT FK_9BE4732989329D25 FOREIGN KEY (resource_id) REFERENCES clinic_resources (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE resource_occupancy_buckets ADD CONSTRAINT FK_9BE473298A0BBA84 FOREIGN KEY (occupancy_id) REFERENCES resource_occupancy (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE resource_occupancy ADD hold_id INT DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE appointment_holds DROP FOREIGN KEY FK_6905A14BA76ED395'); + $this->addSql('ALTER TABLE appointment_segments DROP FOREIGN KEY FK_13EA50E1E5B533F9'); + $this->addSql('ALTER TABLE resource_occupancy_buckets DROP FOREIGN KEY FK_9BE4732989329D25'); + $this->addSql('ALTER TABLE resource_occupancy_buckets DROP FOREIGN KEY FK_9BE473298A0BBA84'); + $this->addSql('DROP TABLE appointment_holds'); + $this->addSql('DROP TABLE appointment_segments'); + $this->addSql('DROP TABLE resource_occupancy_buckets'); + $this->addSql('ALTER TABLE resource_occupancy DROP hold_id'); + } +} diff --git a/src/Appointment/Availability/Entity/ResourceOccupancy.php b/src/Appointment/Availability/Entity/ResourceOccupancy.php index 4007ed86..309d7088 100644 --- a/src/Appointment/Availability/Entity/ResourceOccupancy.php +++ b/src/Appointment/Availability/Entity/ResourceOccupancy.php @@ -33,7 +33,18 @@ class ResourceOccupancy /** رزرو موقت تا پایان مهلت — تسک ۰۷ آن را مصرف می‌کند. */ public const STATUS_HOLD = 'hold'; - public const STATUSES = [self::STATUS_BOOKED, self::STATUS_HOLD]; + /** + * لغو یا منقضی — ردیف **حذف فیزیکی نمی‌شود**. + * + * تاریخچهٔ اینکه چه منبعی کِی گرفته شده بود، ورودی گزارش بهره‌وری است و حذفش یعنی + * پاک کردن همان چیزی که قرار است اندازه بگیریم. + */ + public const STATUS_RELEASED = 'released'; + + public const STATUSES = [self::STATUS_BOOKED, self::STATUS_HOLD, self::STATUS_RELEASED]; + + /** وضعیت‌هایی که واقعاً منبع را می‌گیرند. */ + public const BLOCKING_STATUSES = [self::STATUS_BOOKED, self::STATUS_HOLD]; #[ORM\Id] #[ORM\GeneratedValue] @@ -63,6 +74,10 @@ class ResourceOccupancy #[ORM\Column(name: 'segment_name', type: 'string', length: 150, nullable: true)] private ?string $segmentName = null; + /** رزرو موقتی که این اشغال از آن آمده؛ بعد از ثبت نهایی هم نگه داشته می‌شود. */ + #[ORM\Column(name: 'hold_id', type: 'integer', nullable: true)] + private ?int $holdId = null; + #[ORM\Column(name: 'created_at', type: 'integer')] private int $createdAt; @@ -95,7 +110,13 @@ class ResourceOccupancy public function getStatus(): string { return $this->status; } public function getSegmentName(): ?string { return $this->segmentName; } + public function getHoldId(): ?int { return $this->holdId; } + public function setAppointmentId(?int $v): self { $this->appointmentId = $v; return $this; } + public function setHoldId(?int $v): self { $this->holdId = $v; return $this; } + + public function markBooked(): self { $this->status = self::STATUS_BOOKED; return $this; } + public function markReleased(): self { $this->status = self::STATUS_RELEASED; return $this; } public function setSegmentName(?string $v): self { $this->segmentName = $v; return $this; } public function toArray(): array diff --git a/src/Appointment/Availability/Repository/ResourceOccupancyRepository.php b/src/Appointment/Availability/Repository/ResourceOccupancyRepository.php index 464043bd..7e271539 100644 --- a/src/Appointment/Availability/Repository/ResourceOccupancyRepository.php +++ b/src/Appointment/Availability/Repository/ResourceOccupancyRepository.php @@ -34,6 +34,10 @@ class ResourceOccupancyRepository extends ServiceEntityRepository ->where('o.resource IN (:ids)') ->andWhere('o.startsAt < :to') ->andWhere('o.endsAt > :from') + // ردیف آزادشده تاریخچه است، نه اشغال؛ اگر شمرده شود، زمانِ لغوشده هرگز + // دوباره پیشنهاد نمی‌شود. + ->andWhere('o.status IN (:blocking)') + ->setParameter('blocking', ResourceOccupancy::BLOCKING_STATUSES) ->setParameter('ids', $resourceIds) ->setParameter('from', $from) ->setParameter('to', $to) diff --git a/src/Appointment/Booking/Controller/BookingController.php b/src/Appointment/Booking/Controller/BookingController.php new file mode 100644 index 00000000..f4cd0431 --- /dev/null +++ b/src/Appointment/Booking/Controller/BookingController.php @@ -0,0 +1,316 @@ +getContent(), true); + + if (!is_array($data)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422); + } + + foreach (['service_uuid', 'branch_uuid'] as $field) { + if (!is_string($data[$field] ?? null)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, sprintf('فیلد %s الزامی است', $field), 422, $field); + } + } + + if (!is_numeric($data['start'] ?? null)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد start الزامی است', 422, 'start'); + } + + if (!is_array($data['assignment'] ?? null) || $data['assignment'] === []) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد assignment الزامی است', 422, 'assignment'); + } + + $address = $this->branches->resolve($user, $data['branch_uuid']); + $service = $this->requireItem($user, $data['service_uuid']); + + $selected = []; + foreach (($data['item_uuids'] ?? []) as $itemUuid) { + if (is_string($itemUuid)) { + $selected[] = $this->requireItem($user, $itemUuid); + } + } + + $plan = $this->planner->build( + $service, + $selected, + $address, + is_string($data['patient_gender'] ?? null) ? $data['patient_gender'] : null, + ); + + $assignment = $this->resolveAssignment($user, $data['assignment']); + $this->assertAssignmentCoversPlan($plan, $assignment); + + [$entityType, $entityId] = $this->branches->pair($user); + + $hold = $this->holds->hold( + $user, + $plan, + $assignment, + (int) $data['start'], + $entityType, + $entityId, + ); + + return $this->success($hold->toArray(), 201); + } + + #[Route('/api/v1/appointment-hold/{uuid}', name: 'appointment_hold_release', methods: ['DELETE'])] + public function release(#[CurrentUser] User $user, string $uuid): JsonResponse + { + $hold = $this->requireHold($user, $uuid); + + if ($hold->isConfirmed()) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این رزرو ثبت نهایی شده و آزاد نمی‌شود', 422); + } + + $this->booking->releaseHold($hold); + $this->em->remove($hold); + $this->em->flush(); + + return $this->success(null); + } + + /** + * ثبت نهایی. نوبت با همان قرارداد موجود ساخته می‌شود تا مسیرهای فعلی + * (`active_slot_key`، رویدادها، پرداخت) دست‌نخورده بمانند — تور ایمنی دوگانه. + */ + #[Route('/api/v1/appointment-confirm', name: 'appointment_confirm', methods: ['POST'])] + public function confirm(#[CurrentUser] User $user, Request $request): JsonResponse + { + $data = json_decode($request->getContent(), true); + + if (!is_array($data) || !is_string($data['hold_uuid'] ?? null)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد hold_uuid الزامی است', 422, 'hold_uuid'); + } + + $hold = $this->requireHold($user, $data['hold_uuid']); + + $appointment = $this->makeAppointment($user, $hold, $data); + $this->em->persist($appointment); + $this->em->flush(); + + $this->booking->confirm($hold, $appointment); + + return $this->success([ + 'appointment_uuid' => $appointment->getUuid(), + 'starts_at' => $hold->getStartsAt(), + 'ends_at' => $hold->getEndsAt(), + 'assignment' => $hold->getPayload()['assignment'] ?? [], + ]); + } + + /** + * جابه‌جایی: **اول** رزرو جدید، بعد آزادسازی قدیم. + * + * ترتیب عمدی است — اگر رزرو جدید شکست بخورد، نوبت قدیمی دست‌نخورده می‌ماند و + * بیمار بی‌نوبت نمی‌شود. ترتیب برعکس، در بدترین حالت هر دو را از دست می‌داد. + */ + #[Route('/api/v1/appointment/{uuid}/rebook', name: 'appointment_rebook', methods: ['POST'])] + public function rebook(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse + { + $data = json_decode($request->getContent(), true); + + if (!is_array($data) || !is_string($data['hold_uuid'] ?? null)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد hold_uuid الزامی است', 422, 'hold_uuid'); + } + + $appointment = $this->em->getRepository(Appointment::class) + ->findOneBy(['uuid' => $uuid]); + + [$entityType, $entityId] = $this->branches->pair($user); + + if ($appointment === null || !$this->ownership->belongsToPair($entityType, $entityId, $appointment)) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نوبت یافت نشد', 404); + } + + $hold = $this->requireHold($user, $data['hold_uuid']); + + // رزرو جدید از قبل گرفته شده؛ اینجا فقط تأیید و سپس آزادسازی قدیم. + $this->booking->confirm($hold, $appointment); + $released = $this->booking->cancel($appointment); + + return $this->success([ + 'appointment_uuid' => $appointment->getUuid(), + 'released_intervals' => $released, + 'starts_at' => $hold->getStartsAt(), + ]); + } + + /** + * هر نیازمندی باید در `assignment` منبع داشته باشد. بدون این، رزرو موقت + * می‌توانست نصفِ منابع لازم را بگیرد و بقیه هنگام حضور بیمار کم بیاید. + * + * @param array> $assignment + */ + private function assertAssignmentCoversPlan(\App\Appointment\Plan\ValueObject\AppointmentPlan $plan, array $assignment): void + { + foreach ($plan->segments as $segment) { + foreach ($segment->requirements as $requirement) { + $given = count($assignment[$requirement->role] ?? []); + + if ($given < $requirement->count) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + sprintf('برای نقش «%s» منبع کافی انتخاب نشده است', $requirement->roleName), + 422, + 'assignment', + ); + } + } + } + } + + /** + * @param array $raw + * @return array> + */ + private function resolveAssignment(User $user, array $raw): array + { + [$entityType, $entityId] = $this->branches->pair($user); + $assignment = []; + + foreach ($raw as $role => $uuids) { + // کلیدِ عددی در JSON یعنی آرایه فرستاده‌اند نه شیء؛ نقش باید نام داشته باشد. + if (!is_array($uuids) || !is_string($role) || $role === '') { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'ساختار assignment نامعتبر است', 422, 'assignment'); + } + + foreach ($uuids as $resourceUuid) { + if (!is_string($resourceUuid)) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'uuid منبع نامعتبر است', 422, 'assignment'); + } + + $resource = $this->resources->findByUuid($resourceUuid); + + if ($resource === null || !$this->ownership->belongsToPair($entityType, $entityId, $resource)) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'منبع یافت نشد', 404); + } + + $assignment[$role][] = $resource; + } + } + + return $assignment; + } + + /** + * نوبت با همان سازندهٔ موجود ساخته می‌شود، پس `active_slot_key` و رویدادها و + * مسیر پرداخت دقیقاً مثل قبل کار می‌کنند. اشغال چندمنبعی **کنار** آن می‌نشیند، + * نه به‌جایش — تور ایمنی دوگانه‌ای که خودِ تسک خواسته است. + * + * @param array $data + */ + private function makeAppointment(User $user, AppointmentHold $hold, array $data): Appointment + { + if (!is_string($data['doctor_uuid'] ?? null)) { + throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'فیلد doctor_uuid الزامی است', 422, 'doctor_uuid'); + } + + $doctor = $this->doctors->findOneBy(['uuid' => $data['doctor_uuid']]); + + if ($doctor === null) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404); + } + + // بیمار پیش‌فرض خودِ کاربر است؛ منشی می‌تواند برای شخص دیگری ثبت کند. + $patient = $user; + + if (is_string($data['patient_uuid'] ?? null)) { + $found = $this->users->findOneBy(['uuid' => $data['patient_uuid']]); + + if ($found === null) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'بیمار یافت نشد', 404); + } + + $patient = $found; + } + + $appointment = new Appointment($doctor, $patient, $hold->getStartsAt(), $hold->getEndsAt()); + $appointment->assignTenantPair($hold->getEntityType(), $hold->getEntityId()); + + return $appointment; + } + + private function requireHold(User $user, string $uuid): AppointmentHold + { + $hold = $this->holdRepo->findByUuid($uuid); + [$entityType, $entityId] = $this->branches->pair($user); + + // رزرو کاربر دیگر ۴۰۴ می‌گیرد، نه ۴۰۳: وجودش نباید لو برود. + if ($hold === null + || $hold->getUser()->getId() !== $user->getId() + || !$this->ownership->belongsToPair($entityType, $entityId, $hold) + ) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'رزرو موقت یافت نشد', 404); + } + + return $hold; + } + + private function requireItem(User $user, string $uuid): ServiceItem + { + $item = $this->items->findByUuid($uuid); + [$entityType, $entityId] = $this->branches->pair($user); + + if ($item === null + || $item->getSection()->getEntityType() !== $entityType + || $item->getSection()->getEntityId() !== $entityId + ) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'سرویس یافت نشد', 404); + } + + return $item; + } +} diff --git a/src/Appointment/Booking/Entity/AppointmentHold.php b/src/Appointment/Booking/Entity/AppointmentHold.php new file mode 100644 index 00000000..498003cb --- /dev/null +++ b/src/Appointment/Booking/Entity/AppointmentHold.php @@ -0,0 +1,122 @@ + $payload */ + public function __construct( + User $user, + string $entityType, + int $entityId, + int $startsAt, + int $endsAt, + array $payload, + ?int $now = null, + ) { + $now = $now ?? time(); + + $this->uuid = Uuid::v4()->toRfc4122(); + $this->user = $user; + $this->startsAt = $startsAt; + $this->endsAt = $endsAt; + $this->expiresAt = $now + self::TTL_SECONDS; + $this->payload = $payload; + $this->createdAt = $now; + + $this->assignTenantPair($entityType, $entityId); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getUser(): User { return $this->user; } + public function getStartsAt(): int { return $this->startsAt; } + public function getEndsAt(): int { return $this->endsAt; } + public function getExpiresAt(): int { return $this->expiresAt; } + public function getPayload(): array { return $this->payload; } + public function getConfirmedAt(): ?int { return $this->confirmedAt; } + + public function isExpired(?int $now = null): bool + { + return ($now ?? time()) >= $this->expiresAt; + } + + public function isConfirmed(): bool + { + return $this->confirmedAt !== null; + } + + public function markConfirmed(?int $now = null): self + { + $this->confirmedAt = $now ?? time(); + + return $this; + } + + public function toArray(): array + { + return [ + 'hold_uuid' => $this->uuid, + 'starts_at' => $this->startsAt, + 'ends_at' => $this->endsAt, + 'expires_at' => $this->expiresAt, + 'confirmed' => $this->isConfirmed(), + 'assignment' => $this->payload['assignment'] ?? [], + ]; + } +} diff --git a/src/Appointment/Booking/Entity/AppointmentSegment.php b/src/Appointment/Booking/Entity/AppointmentSegment.php new file mode 100644 index 00000000..2d0f8cae --- /dev/null +++ b/src/Appointment/Booking/Entity/AppointmentSegment.php @@ -0,0 +1,84 @@ + true])] + private bool $patientPresent = true; + + public function __construct( + Appointment $appointment, + int $sequence, + string $name, + int $startsAt, + int $endsAt, + bool $patientPresent = true, + ) { + if ($endsAt <= $startsAt) { + throw new \InvalidArgumentException('Segment end must be after its start.'); + } + + $this->appointment = $appointment; + $this->sequence = $sequence; + $this->name = $name; + $this->startsAt = $startsAt; + $this->endsAt = $endsAt; + $this->patientPresent = $patientPresent; + } + + public function getId(): ?int { return $this->id; } + public function getAppointment(): Appointment { return $this->appointment; } + public function getSequence(): int { return $this->sequence; } + public function getName(): string { return $this->name; } + public function getStartsAt(): int { return $this->startsAt; } + public function getEndsAt(): int { return $this->endsAt; } + public function isPatientPresent(): bool { return $this->patientPresent; } + + public function toArray(): array + { + return [ + 'sequence' => $this->sequence, + 'name' => $this->name, + 'starts_at' => $this->startsAt, + 'ends_at' => $this->endsAt, + 'duration_minutes' => intdiv($this->endsAt - $this->startsAt, 60), + 'patient_present' => $this->patientPresent, + ]; + } +} diff --git a/src/Appointment/Booking/Entity/OccupancyBucket.php b/src/Appointment/Booking/Entity/OccupancyBucket.php new file mode 100644 index 00000000..90d3b47a --- /dev/null +++ b/src/Appointment/Booking/Entity/OccupancyBucket.php @@ -0,0 +1,78 @@ +occupancy = $occupancy; + $this->resource = $occupancy->getResource(); + $this->bucketAt = $bucketAt; + $this->seat = $seat; + } + + public function getId(): ?int { return $this->id; } + public function getBucketAt(): int { return $this->bucketAt; } + public function getSeat(): int { return $this->seat; } + + /** + * سطل‌هایی که یک بازه لمس می‌کند. + * + * بازه نیم‌باز است، پس نوبتی که دقیقاً سرِ ساعت تمام می‌شود سطل بعدی را نمی‌گیرد. + * + * @return list + */ + public static function bucketsFor(int $start, int $end): array + { + $first = intdiv($start, self::BUCKET_SECONDS) * self::BUCKET_SECONDS; + $buckets = []; + + for ($at = $first; $at < $end; $at += self::BUCKET_SECONDS) { + $buckets[] = $at; + } + + return $buckets; + } +} diff --git a/src/Appointment/Booking/Repository/AppointmentHoldRepository.php b/src/Appointment/Booking/Repository/AppointmentHoldRepository.php new file mode 100644 index 00000000..1a7cc4a3 --- /dev/null +++ b/src/Appointment/Booking/Repository/AppointmentHoldRepository.php @@ -0,0 +1,39 @@ + + */ +class AppointmentHoldRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, AppointmentHold::class); + } + + public function findByUuid(string $uuid): ?AppointmentHold + { + return $this->findOneBy(['uuid' => $uuid]); + } + + /** + * hold هایی که مهلتشان گذشته و هنوز تبدیل به نوبت نشده‌اند. + * + * @return AppointmentHold[] + */ + public function findExpired(int $now, int $limit = 200): array + { + return $this->createQueryBuilder('h') + ->where('h.expiresAt <= :now') + ->andWhere('h.confirmedAt IS NULL') + ->setParameter('now', $now) + ->setMaxResults($limit) + ->getQuery() + ->getResult(); + } +} diff --git a/src/Appointment/Booking/Repository/AppointmentSegmentRepository.php b/src/Appointment/Booking/Repository/AppointmentSegmentRepository.php new file mode 100644 index 00000000..fc22cd1e --- /dev/null +++ b/src/Appointment/Booking/Repository/AppointmentSegmentRepository.php @@ -0,0 +1,30 @@ + + */ +class AppointmentSegmentRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, AppointmentSegment::class); + } + + /** @return AppointmentSegment[] */ + public function findForAppointment(Appointment $appointment): array + { + return $this->createQueryBuilder('s') + ->where('s.appointment = :appointment') + ->setParameter('appointment', $appointment) + ->orderBy('s.sequence', 'ASC') + ->getQuery() + ->getResult(); + } +} diff --git a/src/Appointment/Booking/Service/BookingService.php b/src/Appointment/Booking/Service/BookingService.php new file mode 100644 index 00000000..92296fae --- /dev/null +++ b/src/Appointment/Booking/Service/BookingService.php @@ -0,0 +1,110 @@ +isConfirmed()) { + throw new AppException(ErrorCodes::ERR_SLOT_TAKEN, 'این رزرو قبلاً ثبت شده است', 409); + } + + if ($hold->isExpired($now)) { + throw new AppException(ErrorCodes::ERR_HOLD_EXPIRED, 'مهلت رزرو موقت تمام شده است', 409); + } + + $occupancies = $this->holds->occupanciesOfHold($hold); + + if ($occupancies === []) { + throw new AppException(ErrorCodes::ERR_HOLD_EXPIRED, 'رزرو موقت دیگر معتبر نیست', 409); + } + + foreach ($occupancies as $occupancy) { + $occupancy->markBooked()->setAppointmentId($appointment->getId()); + } + + $this->writeSegments($hold, $appointment); + $hold->markConfirmed($now); + + $this->em->flush(); + + return $appointment; + } + + /** + * بخش‌های نوبت از همان `payload` رزرو ساخته می‌شوند، نه از الگوی امروزِ سرویس: + * الگو ممکن است بین رزرو و ثبت عوض شده باشد و نوبت باید همان چیزی بماند که کاربر + * دیده و پذیرفته. + */ + private function writeSegments(AppointmentHold $hold, Appointment $appointment): void + { + $segments = $hold->getPayload()['plan']['segments'] ?? []; + + foreach ($segments as $segment) { + $start = $hold->getStartsAt() + (int) ($segment['offset_minutes'] ?? 0) * 60; + $end = $start + (int) ($segment['duration_minutes'] ?? 0) * 60; + + if ($end <= $start) { + continue; + } + + $this->em->persist(new AppointmentSegment( + $appointment, + (int) ($segment['sequence'] ?? 1), + (string) ($segment['name'] ?? '—'), + $start, + $end, + (bool) ($segment['patient_present'] ?? true), + )); + } + } + + /** + * لغو: ردیف‌های اشغال `released` می‌شوند، **حذف فیزیکی نمی‌شوند**. + * تاریخچه ورودی گزارش بهره‌وری است. + */ + public function cancel(Appointment $appointment): int + { + $occupancies = $this->em->getRepository(ResourceOccupancy::class) + ->findBy(['appointmentId' => $appointment->getId()]); + + $this->holds->release($occupancies); + + return count($occupancies); + } + + /** رزروِ منقضی: همان آزادسازی، ولی از سمت رزرو موقت. */ + public function releaseHold(AppointmentHold $hold): int + { + $occupancies = $this->holds->occupanciesOfHold($hold); + $this->holds->release($occupancies); + + return count($occupancies); + } +} diff --git a/src/Appointment/Booking/Service/HoldService.php b/src/Appointment/Booking/Service/HoldService.php new file mode 100644 index 00000000..843ff298 --- /dev/null +++ b/src/Appointment/Booking/Service/HoldService.php @@ -0,0 +1,234 @@ +> $assignment نقش => منابع انتخابی + * @throws AppException ۴۰۹ وقتی حتی یک منبع در حتی یک سطل جا ندارد + */ + public function hold( + User $user, + AppointmentPlan $plan, + array $assignment, + int $startsAt, + string $entityType, + int $entityId, + ?int $now = null, + ): AppointmentHold { + $now = $now ?? time(); + $endsAt = $startsAt + $plan->totalMinutes * 60; + $reserved = $this->intervalsFor($plan, $assignment, $startsAt); + + if ($reserved === []) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + 'برای این زمان هیچ منبعی مشخص نشده است', + 422, + 'assignment', + ); + } + + $hold = new AppointmentHold( + $user, + $entityType, + $entityId, + $startsAt, + $endsAt, + [ + 'assignment' => $this->describeAssignment($assignment), + 'plan' => $plan->toArray(), + ], + $now, + ); + + $this->em->persist($hold); + $this->em->flush(); + + // اگر منبع دوم جا نداشت، اولی هم باید آزاد شود: رزرو نیمه‌کاره یعنی منبعی + // قفل بماند که هرگز نوبتی رویش ثبت نمی‌شود. + $taken = []; + + try { + foreach ($reserved as $row) { + $taken[] = $this->reserve($row['resource'], $row['start'], $row['end'], $hold, $row['segment']); + } + } catch (AppException $e) { + $this->release($taken); + $this->em->remove($hold); + $this->em->flush(); + + throw $e; + } + + return $hold; + } + + /** + * یک بازه را برای یک منبع می‌گیرد، با اولین صندلی آزاد. + * + * سطل‌ها با DBAL خام نوشته می‌شوند نه با ORM: برخورد کلید یکتا در `flush()` + * خودِ EntityManager را می‌بندد و تلاش صندلی بعدی هم با «EntityManager is closed» + * می‌شکست. با DBAL، استثنا فقط یک استثناست و حلقه ادامه می‌یابد. + * + * @throws AppException ۴۰۹ وقتی همهٔ صندلی‌ها گرفته‌اند + */ + private function reserve( + ClinicResource $resource, + int $start, + int $end, + AppointmentHold $hold, + ?string $segmentName, + ): ResourceOccupancy { + $buckets = OccupancyBucket::bucketsFor($start, $end); + $connection = $this->em->getConnection(); + + for ($seat = 0; $seat < $resource->getCapacity(); $seat++) { + $occupancy = new ResourceOccupancy($resource, $start, $end, ResourceOccupancy::STATUS_HOLD); + $occupancy->setSegmentName($segmentName); + $occupancy->setHoldId($hold->getId()); + + $this->em->persist($occupancy); + $this->em->flush(); + + try { + foreach ($buckets as $bucketAt) { + $connection->insert('resource_occupancy_buckets', [ + 'resource_id' => $resource->getId(), + 'occupancy_id' => $occupancy->getId(), + 'bucket_at' => $bucketAt, + 'seat' => $seat, + ]); + } + + return $occupancy; + } catch (UniqueConstraintViolationException) { + // این صندلی همین حالا گرفته شد. سطل‌های نیمه‌نوشته و خودِ ردیف اشغال + // پاک می‌شوند تا صندلی بعدی از صفر شروع کند. + $connection->delete('resource_occupancy_buckets', ['occupancy_id' => $occupancy->getId()]); + $this->em->remove($occupancy); + $this->em->flush(); + } + } + + throw new AppException( + ErrorCodes::ERR_SLOT_TAKEN, + sprintf('«%s» در این زمان ظرفیت خالی ندارد', $resource->getName()), + 409, + 'assignment', + ); + } + + /** + * آزادسازی: وضعیت `released` و پاک کردن سطل‌ها. + * + * خودِ ردیف اشغال می‌ماند چون تاریخچهٔ بهره‌وری است؛ ولی سطل‌ها باید بروند وگرنه + * کلید یکتا آن زمان را برای همیشه قفل نگه می‌دارد. + * + * @param list $occupancies + */ + public function release(array $occupancies): void + { + if ($occupancies === []) { + return; + } + + $connection = $this->em->getConnection(); + + foreach ($occupancies as $occupancy) { + $connection->delete('resource_occupancy_buckets', ['occupancy_id' => $occupancy->getId()]); + $occupancy->markReleased(); + } + + $this->em->flush(); + } + + /** @return list */ + public function occupanciesOfHold(AppointmentHold $hold): array + { + return $this->em->getRepository(ResourceOccupancy::class)->findBy(['holdId' => $hold->getId()]); + } + + /** + * بازه‌های اشغال: **per نقش**، نه per بخش. + * + * منبعی که در یک بخش نیازمندی ندارد، برای آن دقایق ردیف اشغال هم ندارد — همان + * چیزی که ظرفیت را آزاد می‌کند (بند ۷ مستند). + * + * @param array> $assignment + * @return list + */ + private function intervalsFor(AppointmentPlan $plan, array $assignment, int $startsAt): array + { + $rows = []; + + foreach ($plan->segments as $segment) { + foreach ($segment->requirements as $requirement) { + foreach ($assignment[$requirement->role] ?? [] as $resource) { + $segmentStart = $startsAt + $segment->offsetMinutes * 60; + $segmentEnd = $segmentStart + $segment->durationMinutes * 60; + + $rows[] = [ + 'resource' => $resource, + // آماده‌سازی و تمیزکاری هم گرفته می‌شود: منبع واقعاً در آن + // دقایق در دسترس نیست. + 'start' => $segmentStart - $requirement->setupMinutes * 60, + 'end' => $segmentEnd + $requirement->cleanupMinutes * 60, + 'segment' => $segment->name, + ]; + } + } + } + + return $rows; + } + + /** @param array> $assignment */ + private function describeAssignment(array $assignment): array + { + $out = []; + + foreach ($assignment as $role => $resources) { + $out[$role] = array_map( + static fn (ClinicResource $r): array => ['uuid' => $r->getUuid(), 'name' => $r->getName()], + $resources, + ); + } + + return $out; + } +} diff --git a/src/Appointment/Service/AppointmentExpiryService.php b/src/Appointment/Service/AppointmentExpiryService.php index d903b014..8b00a235 100644 --- a/src/Appointment/Service/AppointmentExpiryService.php +++ b/src/Appointment/Service/AppointmentExpiryService.php @@ -3,6 +3,8 @@ namespace App\Appointment\Service; use App\Appointment\Entity\Appointment; +use App\Appointment\Booking\Repository\AppointmentHoldRepository; +use App\Appointment\Booking\Service\BookingService; use App\Appointment\Repository\AppointmentRepository; use App\Payment\Entity\Payment; use App\Payment\Repository\PaymentRepository; @@ -12,6 +14,8 @@ class AppointmentExpiryService public function __construct( private readonly AppointmentRepository $appointmentRepo, private readonly PaymentRepository $paymentRepo, + private readonly AppointmentHoldRepository $holdRepo, + private readonly BookingService $booking, ) {} /** @@ -20,6 +24,25 @@ class AppointmentExpiryService * * @return int number of appointments expired */ + /** + * رزروهای موقتی که مهلتشان گذشته و ثبت نهایی نشده‌اند. + * + * ردیف اشغال `released` می‌شود (تاریخچه می‌ماند) ولی سطل‌های یکتایی حذف می‌شوند، + * وگرنه کلید یکتا آن بازه را برای همیشه نگه می‌دارد. + */ + private function expireHolds(int $now): int + { + $holds = $this->holdRepo->findExpired($now); + $count = 0; + + foreach ($holds as $hold) { + $this->booking->releaseHold($hold); + $count++; + } + + return $count; + } + public function expireStale(): int { $now = time(); @@ -47,7 +70,14 @@ class AppointmentExpiryService $count++; } - if ($count > 0) { + // رزروهای موقتِ منقضی هم همین‌جا آزاد می‌شوند: بدونش، صندلیِ گرفته‌شده تا ابد + // قفل می‌ماند و آن زمان هرگز دوباره پیشنهاد نمی‌شود. + $count += $this->expireHolds($now); + + // شرط روی `$expired` است نه `$count`: از وقتی رزروهای موقت هم شمرده می‌شوند، + // `$count` می‌تواند مثبت باشد در حالی که هیچ نوبتی منقضی نشده — و آن‌وقت + // `reset([])` مقدار `false` به save می‌داد. (آزادسازی رزروها خودش flush دارد.) + if ($expired !== []) { $this->appointmentRepo->save(reset($expired)); // flush once } diff --git a/src/Shared/Constant/ErrorCodes.php b/src/Shared/Constant/ErrorCodes.php index 97499be7..2280e426 100644 --- a/src/Shared/Constant/ErrorCodes.php +++ b/src/Shared/Constant/ErrorCodes.php @@ -25,6 +25,12 @@ class ErrorCodes /** این اندپوینت با روش نوبت‌دهی فعلیِ آن محل سازگار نیست. */ public const ERR_WRONG_BOOKING_MODE = 'ERR_WRONG_BOOKING_MODE'; + /** منبع در آن بازه ظرفیت خالی ندارد — از قید یکتای دیتابیس می‌آید، نه از بررسی کد. */ + public const ERR_SLOT_TAKEN = 'ERR_SLOT_TAKEN'; + + /** مهلت رزرو موقت گذشته است. */ + public const ERR_HOLD_EXPIRED = 'ERR_HOLD_EXPIRED'; + // Conflict public const ERR_CONFLICT_001 = 'ERR_CONFLICT_001'; @@ -138,6 +144,8 @@ class ErrorCodes self::ERR_NOT_FOUND_001 => 'منبع درخواستی یافت نشد', self::ERR_NO_ELIGIBLE_RESOURCE => 'برای این خدمت منبع واجد شرایطی در این شعبه نیست', self::ERR_WRONG_BOOKING_MODE => 'این عملیات با روش نوبت‌دهی این محل سازگار نیست', + self::ERR_SLOT_TAKEN => 'این زمان هم‌اکنون رزرو شد', + self::ERR_HOLD_EXPIRED => 'مهلت رزرو موقت تمام شده است', self::ERR_FORBIDDEN_001 => 'دسترسی به این منبع مجاز نیست', self::ERR_PAYMENT_001 => 'درگاه پرداخت در دسترس نیست', self::ERR_PAYMENT_002 => 'مبلغ پرداخت نامعتبر است', diff --git a/src/Shared/Tenant/GlobalTables.php b/src/Shared/Tenant/GlobalTables.php index 6f800bce..6939593e 100644 --- a/src/Shared/Tenant/GlobalTables.php +++ b/src/Shared/Tenant/GlobalTables.php @@ -90,6 +90,9 @@ final class GlobalTables public const AGGREGATE_CHILDREN = [ \App\Appointment\Entity\AppointmentEvent::class => \App\Appointment\Entity\Appointment::class, \App\Appointment\Plan\Entity\SegmentRequirement::class => \App\Appointment\Plan\Entity\SegmentTemplate::class, + \App\Appointment\Booking\Entity\AppointmentSegment::class => \App\Appointment\Entity\Appointment::class, + // سطل‌ها فقط قیدِ یکتاییِ ردیف اشغال‌اند و هیچ‌وقت مستقیم پرس‌وجو نمی‌شوند. + \App\Appointment\Booking\Entity\OccupancyBucket::class => \App\Appointment\Availability\Entity\ResourceOccupancy::class, // ریشه‌هاشان خودشان جفت محیط دارند (برخلاف پروندهٔ branch_working_hours در // تسک ۰۱)، پس ارث‌بری اینجا واقعی است. هیچ‌کدام uuid از request نمی‌گیرند: diff --git a/tests/Appointment/HoldAndBookTest.php b/tests/Appointment/HoldAndBookTest.php new file mode 100644 index 00000000..b6eb2aa1 --- /dev/null +++ b/tests/Appointment/HoldAndBookTest.php @@ -0,0 +1,481 @@ +setTime($hour, 0) + ->getTimestamp(); + } + + /** + * اشغال‌های یک منبع مشخص. + * + * `db_test` هرگز ریست نمی‌شود و تست‌های دیگر هم روی همین ساعت ردیف می‌سازند، پس + * پرس‌وجو حتماً باید به منبعِ همین تست محدود شود — وگرنه تست، دادهٔ دیگران را + * می‌شمارد. + * + * @return list + */ + private function occupancyOf(string $resourceUuid, ?int $startsAt = null): array + { + $qb = $this->em->createQueryBuilder() + ->select('o') + ->from(ResourceOccupancy::class, 'o') + ->join('o.resource', 'r') + ->where('r.uuid = :uuid') + ->setParameter('uuid', $resourceUuid) + ->orderBy('o.startsAt', 'ASC'); + + if ($startsAt !== null) { + $qb->andWhere('o.startsAt = :start')->setParameter('start', $startsAt); + } + + return $qb->getQuery()->getResult(); + } + + /** @return array{user: User, doctor: Doctor, section: ServiceSection, address: DoctorAddress} */ + private function clinic(): array + { + $user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']); + $clinic = new Clinic($user); + $clinic->setName('کلینیک رزرو'); + $this->em->persist($clinic); + $this->em->flush(); + + $doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']); + $doctor = new Doctor($doctorUser, 'دکتر رزرو'); + $this->em->persist($doctor); + + $section = new ServiceSection('clinic', $clinic->getId(), 'لیزر'); + $this->em->persist($section); + + $address = DoctorAddress::forClinic($clinic->getId()); + $address->setName('شعبهٔ مرکزی'); + $this->em->persist($address); + $this->em->flush(); + + return ['user' => $user, 'doctor' => $doctor, 'section' => $section, 'address' => $address]; + } + + private function service(ServiceSection $section, string $name, int $solo): ServiceItem + { + $section = $this->em->getRepository(ServiceSection::class)->find($section->getId()); + + $item = new ServiceItem($section, $name); + $item->setSoloDurationMinutes($solo); + $this->em->persist($item); + $this->em->flush(); + + return $item; + } + + private function type(DoctorAddress $address, string $code, string $name): ResourceType + { + $type = new ResourceType($address->tenantEntityType(), $address->tenantEntityId(), $code, $name); + $this->em->persist($type); + $this->em->flush(); + + return $type; + } + + /** @param array $extra */ + private function resource(User $user, DoctorAddress $address, ResourceType $type, string $name, array $extra = []): array + { + $created = $this->authJson('POST', '/api/v1/resource', $user, $extra + [ + 'address_uuid' => $address->getUuid(), + 'type_uuid' => $type->getUuid(), + 'name' => $name, + ]); + self::assertSame(201, $this->responseCode(), json_encode($created, JSON_UNESCAPED_UNICODE)); + + $this->authJson('PUT', "/api/v1/resource/{$created['data']['uuid']}/calendar", $user, [ + 'days' => array_fill_keys(range(0, 6), [['start_minute' => 0, 'end_minute' => 1440]]), + ]); + + return $created['data']; + } + + /** @param list> $segments */ + private function segments(User $user, ServiceItem $service, array $segments): void + { + $this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/segments", $user, ['segments' => $segments]); + self::assertSame(200, $this->responseCode()); + } + + /** @param array> $assignment */ + private function hold(User $user, ServiceItem $service, DoctorAddress $address, int $start, array $assignment): array + { + return $this->authJson('POST', '/api/v1/appointment-hold', $user, [ + 'service_uuid' => $service->getUuid(), + 'branch_uuid' => $address->getUuid(), + 'start' => $start, + 'assignment' => $assignment, + ]); + } + + /** یک سرویس بیست‌دقیقه‌ای که فقط یک اتاق می‌خواهد. */ + private function simpleSetup(int $capacity = 1): array + { + $c = $this->clinic(); + $service = $this->service($c['section'], 'ویزیت', 20); + $room = $this->type($c['address'], 'room', 'اتاق'); + $created = $this->resource($c['user'], $c['address'], $room, 'اتاق ۱', ['capacity' => $capacity]); + + $this->segments($c['user'], $service, [ + ['sequence' => 1, 'name' => 'ویزیت', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $room->getUuid()]]], + ]); + + return $c + ['service' => $service, 'room' => $created]; + } + + public function testHoldCreatesOccupancyRowsPerSegmentAndResource(): void + { + $s = $this->simpleSetup(); + $start = $this->nextSaturdayAt(10); + + $body = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]); + + self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertNotEmpty($body['data']['hold_uuid']); + self::assertGreaterThan(time(), $body['data']['expires_at']); + + $this->em->clear(); + $rows = $this->occupancyOf($s['room']['uuid'], $start); + + self::assertCount(1, $rows); + self::assertSame(ResourceOccupancy::STATUS_HOLD, $rows[0]->getStatus()); + self::assertSame('ویزیت', $rows[0]->getSegmentName()); + } + + /** بعد از رزرو موقت، همان زمان دیگر پیشنهاد نمی‌شود. */ + public function testHeldTimeDisappearsFromAvailability(): void + { + $s = $this->simpleSetup(); + $start = $this->nextSaturdayAt(10); + $saturday = $this->nextSaturdayAt(0); + + $before = $this->authJson('POST', '/api/v1/appointment-availability', $s['user'], [ + 'service_uuid' => $s['service']->getUuid(), + 'branch_uuid' => $s['address']->getUuid(), + 'from' => $saturday, + 'to' => $saturday, + 'step_minutes' => 20, + ]); + self::assertContains($start, array_column($before['data']['slots'], 'start')); + + $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]); + self::assertSame(201, $this->responseCode()); + + $after = $this->authJson('POST', '/api/v1/appointment-availability', $s['user'], [ + 'service_uuid' => $s['service']->getUuid(), + 'branch_uuid' => $s['address']->getUuid(), + 'from' => $saturday, + 'to' => $saturday, + 'step_minutes' => 20, + ]); + + self::assertNotContains($start, array_column($after['data']['slots'], 'start')); + } + + /** + * ⭐ تست همزمانی — اصلی‌ترین تست این تسک. + * + * دو رزرو روی همان منبع و همان بازه: دقیقاً یکی ۲۰۱ و دیگری ۴۰۹. تضمین از قید + * یکتای دیتابیس می‌آید نه از بررسی کد، و همین تست آن قید را مستقیم هم می‌سنجد. + */ + public function testSecondHoldOnTheSameResourceAndIntervalIsRejected(): void + { + $s = $this->simpleSetup(); + $start = $this->nextSaturdayAt(11); + + $first = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]); + self::assertSame(201, $this->responseCode(), json_encode($first, JSON_UNESCAPED_UNICODE)); + + $second = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]); + + self::assertSame(409, $this->responseCode()); + self::assertSame('ERR_SLOT_TAKEN', $second['errors'][0]['code']); + } + + /** + * خودِ قید دیتابیس، مستقل از هر کدِ PHP: نوشتن مستقیم دو ردیف یکسان باید با + * نقض کلید یکتا رد شود. اگر این تست بشکند، یعنی تضمین فقط در کد بوده است. + */ + public function testDatabaseItselfRefusesADuplicateBucketSeat(): void + { + $s = $this->simpleSetup(); + $start = $this->nextSaturdayAt(12); + + $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]); + self::assertSame(201, $this->responseCode()); + + $this->em->clear(); + $occupancy = $this->occupancyOf($s['room']['uuid'], $start)[0]; + $bucket = OccupancyBucket::bucketsFor($start, $start + 1200)[0]; + + // اتصال جدا، نه اتصال مشترکِ تست: هم به «درخواست دیگر» وفادارتر است و هم + // خطای عمدی، EntityManager مشترک را برای تست‌های بعدی خراب نمی‌کند. + $connection = DriverManager::getConnection( + $this->em->getConnection()->getParams(), + ); + + $this->expectException(UniqueConstraintViolationException::class); + + try { + $connection->insert('resource_occupancy_buckets', [ + 'resource_id' => $occupancy->getResource()->getId(), + 'occupancy_id' => $occupancy->getId(), + 'bucket_at' => $bucket, + 'seat' => 0, + ]); + } finally { + $connection->close(); + } + } + + /** ظرفیت ۳: سه رزرو هم‌زمان می‌گذرند، چهارمی ۴۰۹. */ + public function testCapacityThreeAllowsThreeConcurrentHoldsAndRefusesTheFourth(): void + { + $s = $this->simpleSetup(capacity: 3); + $start = $this->nextSaturdayAt(13); + + foreach (range(1, 3) as $n) { + $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]); + self::assertSame(201, $this->responseCode(), "رزرو $n باید بگذرد"); + } + + $fourth = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]); + + self::assertSame(409, $this->responseCode()); + self::assertSame('ERR_SLOT_TAKEN', $fourth['errors'][0]['code']); + } + + public function testConfirmMarksEverythingBooked(): void + { + $s = $this->simpleSetup(); + $start = $this->nextSaturdayAt(14); + + $hold = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]); + self::assertSame(201, $this->responseCode()); + + $body = $this->authJson('POST', '/api/v1/appointment-confirm', $s['user'], [ + 'hold_uuid' => $hold['data']['hold_uuid'], + 'doctor_uuid' => $s['doctor']->getUuid(), + ]); + + self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertNotEmpty($body['data']['appointment_uuid']); + + $this->em->clear(); + $rows = $this->occupancyOf($s['room']['uuid'], $start); + + self::assertSame(ResourceOccupancy::STATUS_BOOKED, $rows[0]->getStatus()); + self::assertNotNull($rows[0]->getAppointmentId()); + } + + /** رزرو منقضی ثبت نمی‌شود و آن زمان دوباره آزاد است. */ + public function testExpiredHoldCannotBeConfirmed(): void + { + $s = $this->simpleSetup(); + $start = $this->nextSaturdayAt(15); + + $hold = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]); + self::assertSame(201, $this->responseCode()); + + // مهلت را به عقب می‌بریم — همان کاری که گذر زمان می‌کند. + $this->em->clear(); + $entity = $this->em->getRepository(AppointmentHold::class)->findOneBy(['uuid' => $hold['data']['hold_uuid']]); + $this->em->getConnection()->update( + 'appointment_holds', + ['expires_at' => time() - 60], + ['id' => $entity->getId()], + ); + + $body = $this->authJson('POST', '/api/v1/appointment-confirm', $s['user'], [ + 'hold_uuid' => $hold['data']['hold_uuid'], + 'doctor_uuid' => $s['doctor']->getUuid(), + ]); + + self::assertSame(409, $this->responseCode()); + self::assertSame('ERR_HOLD_EXPIRED', $body['errors'][0]['code']); + } + + /** آزادسازی زودهنگام: زمان دوباره در جستجو ظاهر می‌شود. */ + public function testReleasingAHoldFreesTheTimeAgain(): void + { + $s = $this->simpleSetup(); + $start = $this->nextSaturdayAt(16); + $saturday = $this->nextSaturdayAt(0); + + $hold = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]); + self::assertSame(201, $this->responseCode()); + + $this->authJson('DELETE', "/api/v1/appointment-hold/{$hold['data']['hold_uuid']}", $s['user']); + self::assertSame(200, $this->responseCode()); + + $after = $this->authJson('POST', '/api/v1/appointment-availability', $s['user'], [ + 'service_uuid' => $s['service']->getUuid(), + 'branch_uuid' => $s['address']->getUuid(), + 'from' => $saturday, + 'to' => $saturday, + 'step_minutes' => 20, + ]); + + self::assertContains($start, array_column($after['data']['slots'], 'start')); + } + + /** رزرو کاربر دیگر ۴۰۴ می‌گیرد، نه ۴۰۳ — وجودش نباید لو برود. */ + public function testAnotherUsersHoldIsNotFound(): void + { + $s = $this->simpleSetup(); + $start = $this->nextSaturdayAt(17); + + $hold = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]); + self::assertSame(201, $this->responseCode()); + + // مزاحم باید محیط معتبر خودش را داشته باشد، وگرنه ۴۰۳ «محیط انتخاب نشده» + // می‌گیرد و تست چیزی را که ادعا می‌کند نمی‌سنجد. + $intruder = $this->clinic()['user']; + + $this->authJson('POST', '/api/v1/appointment-confirm', $intruder, [ + 'hold_uuid' => $hold['data']['hold_uuid'], + 'doctor_uuid' => $s['doctor']->getUuid(), + ]); + + self::assertSame(404, $this->responseCode()); + } + + /** نیازمندی‌ای که در `assignment` منبع ندارد → ۴۲۲، پیش از هر رزروی. */ + public function testAssignmentMissingARoleIsRejected(): void + { + $c = $this->clinic(); + $service = $this->service($c['section'], 'لیزر', 20); + $room = $this->type($c['address'], 'room', 'اتاق'); + $operator = $this->type($c['address'], 'operator', 'اپراتور'); + + $roomRes = $this->resource($c['user'], $c['address'], $room, 'اتاق ۱'); + $this->resource($c['user'], $c['address'], $operator, 'اپراتور ۱'); + + $this->segments($c['user'], $service, [ + ['sequence' => 1, 'name' => 'لیزر', 'duration_minutes' => 20, 'requirements' => [ + ['type_uuid' => $room->getUuid()], ['type_uuid' => $operator->getUuid()], + ]], + ]); + + $body = $this->hold($c['user'], $service, $c['address'], $this->nextSaturdayAt(18), [ + 'room' => [$roomRes['uuid']], // اپراتور نیامده + ]); + + self::assertSame(422, $this->responseCode()); + self::assertStringContainsString('اپراتور', $body['errors'][0]['message']); + } + + /** + * ⭐ آزادسازی ظرفیت حفظ می‌شود: اپراتور در بخش «انتظار» ردیف اشغال **ندارد**. + */ + public function testOperatorHasNoOccupancyDuringTheWaitingSegment(): void + { + $c = $this->clinic(); + $service = $this->service($c['section'], 'لیزر', 20); + $room = $this->type($c['address'], 'room', 'اتاق'); + $operator = $this->type($c['address'], 'operator', 'اپراتور'); + + $roomRes = $this->resource($c['user'], $c['address'], $room, 'اتاق ۱'); + $opRes = $this->resource($c['user'], $c['address'], $operator, 'اپراتور ۱'); + + $this->segments($c['user'], $service, [ + ['sequence' => 1, 'name' => 'بی‌حسی', 'duration_minutes' => 5, 'requirements' => [['type_uuid' => $room->getUuid()], ['type_uuid' => $operator->getUuid()]]], + ['sequence' => 2, 'name' => 'انتظار', 'duration_minutes' => 30, 'requirements' => [['type_uuid' => $room->getUuid()]]], + ['sequence' => 3, 'name' => 'لیزر', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $room->getUuid()], ['type_uuid' => $operator->getUuid()]]], + ]); + + $start = $this->nextSaturdayAt(19); + + $body = $this->hold($c['user'], $service, $c['address'], $start, [ + 'room' => [$roomRes['uuid']], + 'operator' => [$opRes['uuid']], + ]); + self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + + $this->em->clear(); + + $operatorRows = $this->occupancyOf($opRes['uuid']); + + self::assertCount(2, $operatorRows, 'دو بخش، نه یک بازهٔ پیوستهٔ ۵۵ دقیقه‌ای'); + self::assertSame($start, $operatorRows[0]->getStartsAt()); + self::assertSame($start + 5 * 60, $operatorRows[0]->getEndsAt()); + self::assertSame($start + 35 * 60, $operatorRows[1]->getStartsAt()); + + // اتاق برعکس: هر سه بخش را می‌گیرد. + self::assertCount(3, $this->occupancyOf($roomRes['uuid'])); + } + + /** رزرو نیمه‌کاره نمی‌ماند: اگر منبع دوم جا نداشت، اولی هم آزاد می‌شود. */ + public function testPartialHoldIsRolledBack(): void + { + $c = $this->clinic(); + $service = $this->service($c['section'], 'لیزر', 20); + $room = $this->type($c['address'], 'room', 'اتاق'); + $operator = $this->type($c['address'], 'operator', 'اپراتور'); + + $roomRes = $this->resource($c['user'], $c['address'], $room, 'اتاق ۱'); + $opRes = $this->resource($c['user'], $c['address'], $operator, 'اپراتور ۱'); + + $this->segments($c['user'], $service, [ + ['sequence' => 1, 'name' => 'لیزر', 'duration_minutes' => 20, 'requirements' => [ + ['type_uuid' => $room->getUuid()], ['type_uuid' => $operator->getUuid()], + ]], + ]); + + $start = $this->nextSaturdayAt(20); + + // اپراتور را از پیش می‌گیریم تا رزرو دوم روی او شکست بخورد. + $onlyOperator = $this->service($c['section'], 'کار اپراتور', 20); + $this->segments($c['user'], $onlyOperator, [ + ['sequence' => 1, 'name' => 'کار', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $operator->getUuid()]]], + ]); + $this->hold($c['user'], $onlyOperator, $c['address'], $start, ['operator' => [$opRes['uuid']]]); + self::assertSame(201, $this->responseCode()); + + $body = $this->hold($c['user'], $service, $c['address'], $start, [ + 'room' => [$roomRes['uuid']], + 'operator' => [$opRes['uuid']], + ]); + self::assertSame(409, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + + // اتاق نباید قفل مانده باشد. + $this->em->clear(); + $roomRows = $this->em->createQuery( + 'SELECT o FROM App\Appointment\Availability\Entity\ResourceOccupancy o + JOIN o.resource r WHERE r.uuid = :uuid AND o.status IN (:blocking)' + ) + ->setParameter('uuid', $roomRes['uuid']) + ->setParameter('blocking', ResourceOccupancy::BLOCKING_STATUSES) + ->getResult(); + + self::assertSame([], $roomRows, 'اتاق نباید از رزروِ شکست‌خورده قفل بماند'); + } +}