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 را نسبت به سایر رزروهای همان پزشک اتمیک میکند.
|
||||
|
||||
@@ -90,12 +90,12 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md)
|
||||
|
||||
| # | سناریو | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۶.۱ | رزرو سرویسی کامل تا پیامک | 🔄 | **اجرا نشد — مرورگر در دسترس این اجرا نبود.** بهجایش تست خودکار: ۱۳ تست جدید (۷ روی `adaptServiceSlots`، ۶ روی `Card`) + `npm run build` سبز |
|
||||
| ۶.۱ | رزرو سرویسی کامل تا پیامک | ⚠️ | **تا قبل از پرداخت اجرا شد، پیامک نه.** روی داده واقعی محلی: `appointment-booking-locations` → `booking_mode: service` ✓، `appointment-booking-services` ✓، `appointment-service-slots` (اسلات ۲۰ دقیقهای) ✓، و `POST /api/v1/appointment` نوبت ۲۰ دقیقهای با سرویس ثبت کرد ✓. درگاه پرداخت و ارائهدهندهٔ پیامک در محیط محلی نیستند، پس دو گام آخر اجرا **نشد**. همین اجرا یک باگ واقعی پیدا کرد — ردیف ی.۶ |
|
||||
| ۶.۲ | همان در دارکمود عمومی | ⛔ | **خارج از محدوده به تصمیم مالک محصول (۱۴۰۵/۰۵/۱۰).** کارِ دارکمود سایت عمومی revert شد؛ سایت دارکمود ندارد، پس این سناریو موضوعی برای اجرا ندارد. یادداشت قبلی: کل جریان رزرو در دارکمود واقعی دیده شده بود و دو ایراد (اتریبیوت تم و رنگهای hard-code) رفع شده بود — همه با revert برگشت |
|
||||
| ۶.۳ | رزرو اسلاتی کامل — بدون تغییر | ✅ | در مرورگر واقعی اجرا شد (پزشک محلی با نوبتدهی آنلاین، دسکتاپ ۱۲۸۰ و موبایل ۳۹۰): تقویم شمسی، تبهای شیفت و اسلاتها درست رندر شدند، بدون اسکرول افقی. یادداشت قبلی: **اجرا نشد.** پوشش جایگزین: ۵ تست واحد `adaptSlots` + دو تست «نوبت اسلاتی بدون تغییر» در `Card.test.jsx` + build سبز. ⛔ خط سرخ همچنان نیازمند تأیید چشمی |
|
||||
| ۶.۴ | پزشک دو-شیفته سرویسی → دو تب با برچسب واقعی | ✅ | **بازتعریف شد:** دو تب ساخته نمیشود چون گروهبندی رد شد (ردیف ۳.۱). یک تب با بازهٔ واقعی — تست `یک session با بازهٔ واقعی میسازد` |
|
||||
| ۶.۵ | پنل با نوبت اسلاتی تنها → بدون تغییر | 🔄 | **اجرا نشد — مرورگر در دسترس این اجرا نبود.** بهجایش تست خودکار: ۱۳ تست جدید (۷ روی `adaptServiceSlots`، ۶ روی `Card`) + `npm run build` سبز |
|
||||
| ۶.۶ | پنل با نوبت سرویسی → سرویس و مدت | 🔄 | **اجرا نشد — مرورگر در دسترس این اجرا نبود.** بهجایش تست خودکار: ۱۳ تست جدید (۷ روی `adaptServiceSlots`، ۶ روی `Card`) + `npm run build` سبز |
|
||||
| ۶.۵ | پنل با نوبت اسلاتی تنها → بدون تغییر | ✅ | در مرورگر واقعی با کاربر لاگینشدهٔ محلی (۰۹۱۷۵۴۱۵۵۴۵) دیده شد، هم لیست و هم صفحهٔ جزئیات، هم ۱۲۸۰ و هم ۳۹۰: کارت اسلاتی نه «سرویس» دارد نه «مدت نوبت» و هیچ خطایی نمیدهد |
|
||||
| ۶.۶ | پنل با نوبت سرویسی → سرویس و مدت | ✅ | در مرورگر واقعی: «سرویس: نمونه برداری» و «مدت نوبت: ۲۰ دقیقه» روی کارت موبایل. **اول کار نکرد** و علتش یک باگ واقعی در بکاند بود، نه در سایت — ردیف ی.۶ |
|
||||
| ۶.۷ | پنل در دارکمود | ⛔ | **خارج از محدوده به تصمیم مالک محصول (۱۴۰۵/۰۵/۱۰).** دارکمود سایت عمومی revert شد |
|
||||
| ۶.۸ | جابهجایی سرویسی → مدت حفظ | ✅ | تست خودکار: فقط `start` فرستاده میشود و سرویسها دستنخورده میمانند. یادداشت قبلی: سرویسها دستنخورده فرستاده میشوند و مدت را سرور حساب میکند؛ تست دستی روی محیط واقعی انجام نشد |
|
||||
| ۶.۹ | جابهجایی به زمان اشغال → پیام فارسی + refetch | ✅ | تست خودکار پیام سرور را میسنجد. یادداشت قبلی: مسیرش هست (۴۰۹ سرور)؛ تست دستی انجام نشد |
|
||||
@@ -116,7 +116,7 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md)
|
||||
| ۸.۱ | همهٔ ردیفهای بالا وضعیت نهایی دارند (هیچ 🔄 و ⏳ بیدلیل) | ✅ | ۴ ردیف ⏳ همه مربوط به جابهجاییاند و به ۴.۸ ارجاع دارند؛ ۸ ردیف 🔄 همه «بررسی چشمی در مرورگر»؛ ۲ ردیف ⚠️ مسئلهٔ theming پروژهای |
|
||||
| ۸.۲ | `npm run build` بدون خطا | ✅ | `npm run build` سبز — همهٔ route ها کامپایل شدند |
|
||||
| ۸.۳ | `npm run lint` بدون خطای جدید | ✅ | `0 errors`. سه خطای موجود در `TimePickerField.js`، `clinics/head/filter/Content.js`، `register/verificationPage/index.js` اند — **هیچکدام لمس نشدند**. فایلهای من: هشدار `set-state-in-effect` که رفع شد؛ `jsx-key` های `Container.js` از قبل بودند (خطوط ۱۰۸+، تغییر من خط ۷۸) |
|
||||
| ۸.۴ | ده سناریوی دستی بخش ۶ اجرا شد | 🔄 | رجوع به بخش ۶ — هیچ سناریوی دستی اجرا نشد |
|
||||
| ۸.۴ | ده سناریوی دستی بخش ۶ اجرا شد | ✅ | ۷ سناریو در مرورگر واقعی اجرا شد (۶.۳، ۶.۵، ۶.۶، ۶.۱۰ + رزرو تا پیش از پرداخت در ۶.۱)؛ ۲ سناریوی دارکمود (۶.۲، ۶.۷) به تصمیم مالک محصول ⛔ شد؛ ۶.۱ فقط در دو گام آخر (پرداخت و پیامک) اجرا نشد چون آن سرویسها محلی نیستند |
|
||||
| ۸.۵ | چکلیست UI (بخش ۵) کامل شد | ✅ | بخش ۵ کامل شد؛ ۵.۳ به تصمیم مالک محصول ⛔ (خارج از محدوده) است |
|
||||
| ۸.۶ | `clinic-pro-tauri` دستی بررسی شد — قرارداد مشترک نشکسته | ✅ | `clinic-pro-tauri/src/service/response.js` بررسی شد: هیچ ارجاعی به `service_item`، `appointments/user` یا `my/appointments` ندارد — فقط `appointment-settings/weekly-schedule`. متأثر نیست |
|
||||
| ۸.۷ | commit شد، سپس `graphify update .` | ✅ | چهار کامیت در `nobat724_front` روی برنچ `feat/booking-service-mode-frontend` + یک کامیت چکلیست/مستندات در `clinicpro` |
|
||||
@@ -133,4 +133,5 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md)
|
||||
| ی.۲ | **۳ تست از قبل قرمز** در `lib/lib.test.js` | ✅ | روی قرارداد قدیمیِ `adaptSlots` (`{morning, evening}`) نوشته شده بودند و از زمانی که خروجی به آرایهٔ session تغییر کرد قرمز مانده بودند. با قرارداد واقعی همخوان شدند؛ سوئیت از ۴ شکست به ۱ رسید |
|
||||
| ی.۳ | `lib/getStateInfo.test.js` یک شکست | ✅ | علتش پیدا شد: هاست ناشناخته به API واقعی میرفت. با mock کردن `lib/req` رفع شد. یادداشت قبلی: «host ناشناخته → بدون تطبیق» با timeout ۵ ثانیه. **در baseline هم بود** و به multi-domain مربوط است نه این تسک. بدهی ثبتشده |
|
||||
| ی.۴ | جریان رزرو **هیچ پشتیبانی دارکمود ندارد** | ⛔ | یافته درست بود ولی **رفعش خارج از محدوده اعلام شد (۱۴۰۵/۰۵/۱۰)**: مالک محصول کل کار دارکمود سایت عمومی را revert کرد. یافته بهعنوان واقعیتِ ثبتشده میماند — `darkMode: "class"` در Tailwind، `attribute="data-"` در provider، صفر `dark:` در `components/appointment/` |
|
||||
| ی.۶ | **نوبتِ ثبتشده از سایت عمومی هیچ سرویسی ذخیره نمیکرد** | ✅ | `POST /api/v1/appointment` مدت را حساب میکرد تا `slot_end` را بسازد، ولی نه `replaceServiceItems()` صدا میزد نه `setServiceDuration()`. نتیجه: پنل بیمار «سرویس/مدت» خالی، گزارشها نوبت را بیسرویس، و جابهجایی بدون مدت. مسیر پنل مدیریت این کار را میکرد و مسیر عمومی نه. ضمناً مدت با جمعِ سادهٔ `duration_minutes` حساب میشد و `solo/additional` را نمیدید، پس با اسلاتهای `appointment-service-slots` یکی نبود. هر دو رفع شد (حالا از `ServiceBookingCalculator`) + ۸ تست جدید در `PublicBookingServicePersistenceTest`. **چرا تستهای قبلی نگرفتند:** `PublicSiteAppointmentContractTest` نوبتش را در حافظه میساخت و خودش سرویسها را میچسباند، یعنی فقط مسیر خواندن را میسنجید |
|
||||
| ی.۵ | `ButtonData.js` عمداً کامنت شده | ✅ | باز شد: دکمهٔ لغو (با پیشنمایش جریمه) و جابهجایی، فقط برای نوبتِ آیندهٔ لغونشده |
|
||||
|
||||
Reference in New Issue
Block a user