fix(booking): store the services and duration a public booking was made with
POST /api/v1/appointment resolved the selected services, summed their minutes, used that to compute slot_end — and then dropped the result. It never called replaceServiceItems() or setServiceDuration(), so an appointment booked from the public site kept no record of what it was booked for: - the patient panel showed neither the service nor the duration - reports counted the appointment as having no services - a later reschedule had no duration to preserve The management path did all of this correctly; only the public path did not. Found by booking through the real endpoint and looking at the panel, which is the one thing no test did. The duration was also computed as a naive sum of duration_minutes, ignoring the solo/additional split. That made a multi-service booking's length disagree with the slots appointment-service-slots had just offered the patient — the booking would occupy a different span than the one shown. Both paths now go through ServiceBookingCalculator, which is what builds those slots. For data that only sets duration_minutes, the calculator returns the same total as the old sum, so existing services are unaffected. assertServicesMatchContext() is gone: the calculator performs the identical ownership check with the same error code and message, and the tenant-lookup inventory is updated to match. Tests: PublicBookingServicePersistenceTest starts at the endpoint rather than building an appointment in memory — the gap that let this ship. Verified it fails (4 of 8) with the fix disabled. Full suite 1433 green, slot-mode-frozen green, phpstan at its 14-error baseline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -248,10 +248,8 @@ Book an appointment slot.
|
||||
|-------|------|----------|-------------|
|
||||
| `doctor_uuid` | string (UUID) | ✅ | Doctor UUID |
|
||||
| `slot_start` | integer | ✅ | Slot start (Unix timestamp) |
|
||||
| `slot_end` | integer | ⚠️ | Slot end (Unix timestamp). فقط وقتی `duration_from_services=true` باشد سرور آن را از `slot_start + Σ duration_minutes` بازمحاسبه میکند؛ در غیر این صورت مقدار کلاینت حفظ میشود |
|
||||
| `service_item_uuids` | string[] | ❌ | یک یا چند UUID سرویس که به نوبت **پیوست** میشوند (چند سرویس). اولین سرویس بهعنوان سرویسِ اصلی (`service_item`) ثبت و همه در `service_items` برمیگردند. UUID ناموجود ⇒ `422` |
|
||||
| `duration_from_services` | boolean | ❌ | `true` = حالت نوبتدهی سرویسی: مدت نوبت از مجموع `duration_minutes` سرویسها محاسبه و `slot_end` بازنویسی میشود؛ در این حالت سرویسِ غیرbookable یا بدون مدت ⇒ `422`. پیشفرض `false` (حالت اسلاتی: فقط پیوست، ساعت پایانِ دستی حفظ میشود) |
|
||||
| `service_durations` | object | ❌ | override مدت هر سرویس بهصورت `{ "<service_uuid>": <minutes> }` — فقط وقتی `duration_from_services=true`. برای نوبتدهیِ منشی که مدت را برای همان نوبت تغییر میدهد؛ در محاسبهٔ `slot_end` لحاظ میشود و **مقدار پیشفرضِ سرویس تغییر نمیکند**. مقدار ≤ 0 یا غایب ⇒ مدت پیشفرض |
|
||||
| `slot_end` | integer | ⚠️ | Slot end (Unix timestamp). **با `service_item_uuids` نادیده گرفته میشود** و سرور خودش حساب میکند (به مقدار کلاینت اعتماد نمیشود)؛ در آن حالت الزامی هم نیست. بدون سرویس، مقدار کلاینت حفظ میشود و الزامی است |
|
||||
| `service_item_uuids` | string[] | ❌ | یک یا چند UUID سرویس. سرویسها **ذخیره** میشوند (`service_items`)، اولین سرویس سرویسِ اصلی (`service_item`) است، و مدت/بافر روی نوبت ثبت میشود (`service_total_minutes` / `service_buffer_minutes`). UUID ناموجود، سرویسِ غیرbookable، سرویس بدون مدت، یا سرویسِ محیطی دیگر ⇒ `422` |
|
||||
| `for_self` | boolean | ❌ | `true` (default) = patient is the logged-in payer; `false` = booking for someone else |
|
||||
| `patient_name` | string | ⚠️ | Required when `for_self=false`; otherwise filled from the payer's profile |
|
||||
| `patient_mobile` | string | ⚠️ | Required when `for_self=false`; otherwise the payer's mobile |
|
||||
@@ -261,6 +259,13 @@ Book an appointment slot.
|
||||
| `note` | string | ❌ | Patient note |
|
||||
| `city_id` | integer | ❌ | شناسهی شهرِ دامنهی جاری (از `city.json` سایت). برای گاردِ پورسانت نماینده: اگر شهر نمایندهی فعال داشته باشد، `booking_representation_id` نوبت ست میشود. پورسانت فقط وقتی واریز میشود که این نماینده با نمایندهی پزشک یکی باشد. خالی/ناموجود ⇒ بدون پورسانت |
|
||||
|
||||
> **مدت در حالت سرویسی:** مدت از `ServiceBookingCalculator` میآید — همان مؤلفهای که
|
||||
> `GET /api/v1/appointment-service-slots` هم با آن اسلاتها را میسازد. یعنی `solo` و
|
||||
> `additional` سرویسها لحاظ میشوند و نه جمعِ سادهٔ `duration_minutes`؛ وگرنه نوبتِ ثبتشده
|
||||
> با اسلاتی که به بیمار نشان داده شده جور درنمیآمد. بافر **جزو مدت نوبت نیست**:
|
||||
> `slot_end = slot_start + service_total_minutes`، و بافر جدا در `service_buffer_minutes`
|
||||
> ذخیره میشود.
|
||||
>
|
||||
> **آدرس نوبت:** آدرس (`address_id`) ارسالی نیست؛ سرور آن را از روی `location_id` همان session در برنامهی هفتگی که اسلات در آن قرار دارد، خودکار تعیین و ذخیره میکند. در پاسخ بهصورت `address_id` برمیگردد. همهی مسیرهای رزرو (آنلاین `POST /api/v1/appointment`، منشی `POST /api/v1/my/appointment`، ادمین) آدرس را به همین شکل ست میکنند.
|
||||
|
||||
> **تضمین عدم رزرو دوگانه:** هر سه مسیر رزرو از `AppointmentRepository::bookAtomically()` عبور میکنند و یک قید یکتای دیتابیسی (`active_slot_key`) پشت آن قرار دارد؛ بنابراین حتی در شرایط رقابتی (race) فقط یک نوبتِ زنده روی هر `(doctor, slot_start)` ممکن است و درخواست بازنده `409 SLOT_TAKEN` میگیرد. نوبتهای لغو/منقضی اسلات را آزاد میکنند (کلید `NULL`). علاوه بر این، `bookAtomically` داخل تراکنش یک قفلِ per-doctor (`PESSIMISTIC_WRITE` روی ردیف پزشک) میگیرد؛ چون در **حالت سرویسی** نوبتها طول متغیر و شروعِ متفاوت دارند و قید یکتای `(doctor, slot_start)` تداخلِ بازهایِ دو رزروِ همزمان با شروعِ متفاوت را نمیگیرد. این قفل بررسیِ overlap و insert را نسبت به سایر رزروهای همان پزشک اتمیک میکند.
|
||||
|
||||
Reference in New Issue
Block a user