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:
hamed
2026-08-01 17:43:23 +03:30
co-authored by Claude Opus 5
parent df07d00adc
commit 03f09637ed
5 changed files with 250 additions and 54 deletions
+9 -4
View File
@@ -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 را نسبت به سایر رزروهای همان پزشک اتمیک می‌کند.