From 4395eea56e597c962d8ba1563ec6d07a51305335 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 31 Jul 2026 09:28:55 +0330 Subject: [PATCH] feat(booking): multi-resource holds and confirmation with a database-level guarantee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/api/README.md | 1 + docs/api/appointment-booking.md | 159 ++++++ .../taskes/task-07-hold-and-book/checklist.md | 150 +++--- migrations/Version20260731054324.php | 51 ++ .../Availability/Entity/ResourceOccupancy.php | 23 +- .../ResourceOccupancyRepository.php | 4 + .../Booking/Controller/BookingController.php | 316 ++++++++++++ .../Booking/Entity/AppointmentHold.php | 122 +++++ .../Booking/Entity/AppointmentSegment.php | 84 +++ .../Booking/Entity/OccupancyBucket.php | 78 +++ .../Repository/AppointmentHoldRepository.php | 39 ++ .../AppointmentSegmentRepository.php | 30 ++ .../Booking/Service/BookingService.php | 110 ++++ .../Booking/Service/HoldService.php | 234 +++++++++ .../Service/AppointmentExpiryService.php | 32 +- src/Shared/Constant/ErrorCodes.php | 8 + src/Shared/Tenant/GlobalTables.php | 3 + tests/Appointment/HoldAndBookTest.php | 481 ++++++++++++++++++ 18 files changed, 1848 insertions(+), 77 deletions(-) create mode 100644 docs/api/appointment-booking.md create mode 100644 migrations/Version20260731054324.php create mode 100644 src/Appointment/Booking/Controller/BookingController.php create mode 100644 src/Appointment/Booking/Entity/AppointmentHold.php create mode 100644 src/Appointment/Booking/Entity/AppointmentSegment.php create mode 100644 src/Appointment/Booking/Entity/OccupancyBucket.php create mode 100644 src/Appointment/Booking/Repository/AppointmentHoldRepository.php create mode 100644 src/Appointment/Booking/Repository/AppointmentSegmentRepository.php create mode 100644 src/Appointment/Booking/Service/BookingService.php create mode 100644 src/Appointment/Booking/Service/HoldService.php create mode 100644 tests/Appointment/HoldAndBookTest.php 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, 'اتاق نباید از رزروِ شکست‌خورده قفل بماند'); + } +}