diff --git a/docs/api/appointment.md b/docs/api/appointment.md index 5ec46672..2d6df05d 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -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 مدت هر سرویس به‌صورت `{ "": }` — فقط وقتی `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 را نسبت به سایر رزروهای همان پزشک اتمیک می‌کند. diff --git a/docs/new_feture/taskes/task-00b-nobat724-service-mode/checklist.md b/docs/new_feture/taskes/task-00b-nobat724-service-mode/checklist.md index 3618d042..6841b721 100644 --- a/docs/new_feture/taskes/task-00b-nobat724-service-mode/checklist.md +++ b/docs/new_feture/taskes/task-00b-nobat724-service-mode/checklist.md @@ -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` عمداً کامنت شده | ✅ | باز شد: دکمهٔ لغو (با پیش‌نمایش جریمه) و جابه‌جایی، فقط برای نوبتِ آیندهٔ لغونشده | diff --git a/src/Appointment/Controller/AppointmentController.php b/src/Appointment/Controller/AppointmentController.php index 26481b96..79c8b05d 100644 --- a/src/Appointment/Controller/AppointmentController.php +++ b/src/Appointment/Controller/AppointmentController.php @@ -469,30 +469,11 @@ class AppointmentController extends BaseController $slotEnd = (int) ($data['slot_end'] ?? 0); $clinicUuid = $data['clinic_uuid'] ?? null; - // حالت نوبت‌دهی سرویسی: مدت نوبت = مجموع مدت سرویس‌های bookableِ انتخاب‌شده، - // و slot_end سمت سرور محاسبه می‌شود (به مقدار کلاینت اعتماد نمی‌شود). $serviceUuids = array_values(array_filter(array_map('trim', (array) ($data['service_item_uuids'] ?? [])))); - $serviceItem = null; - if (!empty($serviceUuids)) { - $totalMinutes = 0; - foreach ($serviceUuids as $u) { - $item = $this->itemRepo->findByUuid($u); - if ($item === null) { - return $this->error(ErrorCodes::ERR_VALIDATION_002, 'سرویس یافت نشد', 422, 'service_item_uuids'); - } - if (!$item->isBookable()) { - return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این سرویس برای نوبت‌دهی فعال نیست', 422, 'service_item_uuids'); - } - if (($item->getDurationMinutes() ?? 0) <= 0) { - return $this->error(ErrorCodes::ERR_VALIDATION_001, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids'); - } - $totalMinutes += (int) $item->getDurationMinutes(); - $serviceItem ??= $item; - } - $slotEnd = $slotStart + $totalMinutes * 60; - } + $hasServices = $serviceUuids !== []; - if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart) { + // در حالت سرویسی `slot_end` از سرویس‌ها ساخته می‌شود، پس نبودنش خطا نیست. + if (empty($doctorUuid) || $slotStart <= 0 || (!$hasServices && $slotEnd <= $slotStart)) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_uuid، slot_start و slot_end الزامی است', 422); } @@ -507,10 +488,19 @@ class AppointmentController extends BaseController $bookingClinic = $this->bookingClinic($doctor, $clinicUuid); - // سرویس باید متعلق به همان محلی باشد که نوبت در آن ثبت می‌شود؛ وگرنه بیمار - // می‌توانست سرویس کلینیک را روی نوبت مطب شخصی بنشاند. - if ($serviceItem !== null && ($err = $this->assertServicesMatchContext($serviceUuids, $doctor, $bookingClinic)) !== null) { - return $err; + /** + * مدت از {@see ServiceBookingCalculator} می‌آید، نه از جمعِ دستیِ `duration_minutes`. + * + * جمع دستی مدتِ solo/additional را نمی‌دید، پس نوبتِ چندسرویسی اینجا مدتی می‌گرفت + * که با اسلات‌های `appointment-service-slots` یکی نبود — یعنی بیمار وقتی را رزرو + * می‌کرد که سرور جای دیگری آزاد حساب کرده بود. calculator خودش هم مالکیت محیط را + * می‌سنجد (همان بررسی‌ای که قبلاً جداگانه صدا زده می‌شد) و خطاهایش همان کد و پیام + * قبلی را دارند. + */ + $duration = null; + if ($hasServices) { + $duration = $this->serviceCalculator->calculate($doctor, $bookingClinic, $serviceUuids); + $slotEnd = $duration->endFor($slotStart); } $forSelf = (bool) ($data['for_self'] ?? true); @@ -537,7 +527,18 @@ class AppointmentController extends BaseController $appointment = new Appointment($doctor, $user, $slotStart, $slotEnd); $appointment->setPatientNationalCode($nationalCode); $appointment->setPatientGender($gender); - if ($serviceItem !== null) $appointment->setServiceItem($serviceItem); + /** + * سرویس‌ها و مدت **ذخیره** می‌شوند، نه فقط برای حساب‌کردن `slot_end` استفاده. + * + * بدون این، نوبتِ ثبت‌شده از سایت عمومی هیچ ردی از سرویس نداشت: پنل بیمار + * («سرویس: …» و مدت) خالی می‌ماند، گزارش‌ها این نوبت را بی‌سرویس می‌دیدند و + * جابه‌جایی هم مدتی برای حفظ‌کردن پیدا نمی‌کرد. مسیر پنل مدیریت این کار را + * می‌کرد و مسیر عمومی نه. + */ + if ($duration !== null) { + $appointment->replaceServiceItems($duration->serviceItems); + $appointment->setServiceDuration($duration->totalMinutes, $duration->bufferMinutes); + } if (isset($data['note'])) $appointment->setNote($data['note']); // نماینده‌ی دامنه‌ی مبدأ رزرو (از Origin مرورگر)؛ گاردِ نهایی پورسانت در لحظه‌ی @@ -807,22 +808,6 @@ class AppointmentController extends BaseController return $this->accessChecker->canManageContext($user, $doctor, $clinic); } - private function assertServicesMatchContext(array $serviceUuids, Doctor $doctor, ?Clinic $clinic): ?JsonResponse - { - [$type, $id] = $clinic !== null - ? ['clinic', $clinic->getId()] - : ['doctor', $doctor->getId()]; - - foreach ($serviceUuids as $uuid) { - $section = $this->itemRepo->findByUuid($uuid)?->getSection(); - if ($section === null || $section->getEntityType() !== $type || $section->getEntityId() !== $id) { - return $this->error(ErrorCodes::ERR_VALIDATION_001, 'سرویس انتخاب‌شده به این محل نوبت‌دهی تعلق ندارد', 422, 'service_item_uuids'); - } - } - - return null; - } - /** * تاریخ Y-m-d که واقعاً روی تقویم وجود دارد. regex تنها کافی نیست: «2026-13-99» * الگو را پاس می‌کند ولی روزی نیست. diff --git a/tests/Appointment/PublicBookingServicePersistenceTest.php b/tests/Appointment/PublicBookingServicePersistenceTest.php new file mode 100644 index 00000000..e0242627 --- /dev/null +++ b/tests/Appointment/PublicBookingServicePersistenceTest.php @@ -0,0 +1,205 @@ +createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر رزرو عمومی'); + $this->em->persist($doctor); + $this->em->flush(); + + $schedule = $this->newWeeklySchedule($doctor, [ + '0' => ['sessions' => [[ + 'active' => true, 'start_time' => '09:00', 'end_time' => '18:00', + 'duration_per_patient' => 20, 'location_id' => 1, + ]]], + ]); + $schedule->setMeta(['booking_mode' => WeeklySchedule::MODE_SERVICE, 'buffer_minutes' => $buffer]); + $this->em->persist($schedule); + + $section = new ServiceSection('doctor', $doctor->getId(), 'زیبایی'); + $this->em->persist($section); + $this->em->flush(); + + return [$doctor, $section]; + } + + private function service(ServiceSection $section, string $name, int $solo, ?int $additional = null): ServiceItem + { + $item = new ServiceItem($section, $name, 1_200_000); + $item->setDurationMinutes($solo)->setBookable(true); + if ($additional !== null) { + $item->setAdditionalDurationMinutes($additional); + } + $this->em->persist($item); + $this->em->flush(); + + return $item; + } + + /** @param string[] $serviceUuids */ + private function book(Doctor $doctor, array $serviceUuids, ?int $start = null, ?int $end = null): array + { + $patient = $this->createUser(['ROLE_USER']); + $start ??= time() + 86_400 + random_int(1, 5_000) * 60; + + $res = $this->authJson('POST', '/api/v1/appointment', $patient, [ + 'doctor_uuid' => $doctor->getUuid(), + 'clinic_uuid' => null, + 'slot_start' => $start, + 'slot_end' => $end ?? $start + 20 * 60, + 'for_self' => true, + 'patient_national_code' => str_pad((string) random_int(0, 9_999_999_999), 10, '0', STR_PAD_LEFT), + 'patient_gender' => 'female', + 'service_item_uuids' => $serviceUuids, + ]); + + return [$res, $start]; + } + + private function reload(string $uuid): Appointment + { + $this->em->clear(); + + return $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $uuid]); + } + + // ── ✅ موفق ────────────────────────────────────────────────────────────── + + public function testBookingThroughThePublicEndpointStoresTheServicesAndTheDuration(): void + { + [$doctor, $section] = $this->serviceDoctor(buffer: 10); + $laser = $this->service($section, 'لیزر صورت', 20); + + [$res, $start] = $this->book($doctor, [$laser->getUuid()]); + self::assertSame(201, $this->responseCode()); + + $appt = $this->reload($res['data']['data']['uuid']); + + self::assertCount(1, $appt->getServiceItems(), 'سرویس باید به نوبت بچسبد، نه فقط مدت را حساب کند'); + self::assertSame('لیزر صورت', $appt->getServiceItem()?->getName()); + self::assertSame(20, $appt->getServiceTotalMinutes()); + self::assertSame(10, $appt->getServiceBufferMinutes(), 'بافر از تنظیمات محیط، نه صفر'); + // بافر جزو مدت نوبت نیست: پایان = شروع + مدت. + self::assertSame($start + 20 * 60, $appt->getSlotEnd()); + } + + public function testTheResponseCarriesTheServiceFieldsTheSiteReadsBack(): void + { + [$doctor, $section] = $this->serviceDoctor(); + $laser = $this->service($section, 'لیزر بیکینی', 15); + + [$res] = $this->book($doctor, [$laser->getUuid()]); + self::assertSame(201, $this->responseCode()); + + $row = $res['data']['data']; + self::assertSame(15, $row['service_total_minutes']); + self::assertSame(['لیزر بیکینی'], array_column($row['service_items'], 'name')); + } + + /** + * مدت از `DurationCalculator` می‌آید، نه از جمعِ سادهٔ `duration_minutes`. + * + * سرویس دوم «کنار بقیه» ۵ دقیقه است نه ۲۰؛ جمع ساده ۴۰ می‌داد و نوبت با اسلات‌هایی + * که `appointment-service-slots` برمی‌گرداند جور درنمی‌آمد. + */ + public function testTheDurationUsesTheAdditionalMinutesNotANaiveSum(): void + { + [$doctor, $section] = $this->serviceDoctor(buffer: 0); + $first = $this->service($section, 'لیزر زیربغل', 20); + $second = $this->service($section, 'لیزر خط بیکینی', 20, additional: 5); + + [$res, $start] = $this->book($doctor, [$first->getUuid(), $second->getUuid()]); + self::assertSame(201, $this->responseCode()); + + $appt = $this->reload($res['data']['data']['uuid']); + self::assertSame(25, $appt->getServiceTotalMinutes()); + self::assertSame($start + 25 * 60, $appt->getSlotEnd()); + self::assertCount(2, $appt->getServiceItems()); + } + + // ── ❌ خطا ─────────────────────────────────────────────────────────────── + + public function testAnUnknownServiceIsRejected(): void + { + [$doctor] = $this->serviceDoctor(); + + $this->book($doctor, ['00000000-0000-4000-8000-000000000000']); + self::assertSame(422, $this->responseCode()); + } + + public function testAServiceFromAnotherContextIsRejected(): void + { + [$doctor] = $this->serviceDoctor(); + [, $other] = $this->serviceDoctor(); + $foreign = $this->service($other, 'سرویس محیط دیگر', 30); + + $this->book($doctor, [$foreign->getUuid()]); + self::assertSame(422, $this->responseCode()); + } + + public function testAServiceWithoutADurationIsRejected(): void + { + [$doctor, $section] = $this->serviceDoctor(); + $item = new ServiceItem($section, 'بدون مدت', 500_000); + $item->setBookable(true); + $this->em->persist($item); + $this->em->flush(); + + $this->book($doctor, [$item->getUuid()]); + self::assertSame(422, $this->responseCode()); + } + + // ── ⚠️ مرزی ────────────────────────────────────────────────────────────── + + /** بدون سرویس، همان مسیر اسلاتیِ قبلی — ساعت پایانِ ارسالی حفظ می‌شود. */ + public function testABookingWithoutServicesKeepsTheClientEndAndLeavesTheFieldsNull(): void + { + [$doctor] = $this->serviceDoctor(); + $start = time() + 86_400 + random_int(1, 5_000) * 60; + + [$res] = $this->book($doctor, [], $start, $start + 45 * 60); + self::assertSame(201, $this->responseCode()); + + $appt = $this->reload($res['data']['data']['uuid']); + self::assertSame($start + 45 * 60, $appt->getSlotEnd()); + self::assertNull($appt->getServiceTotalMinutes()); + self::assertCount(0, $appt->getServiceItems()); + } + + /** `slot_end` کلاینت در حالت سرویسی نادیده گرفته می‌شود — سرور خودش حساب می‌کند. */ + public function testAWrongClientEndIsOverwrittenInsteadOfTrusted(): void + { + [$doctor, $section] = $this->serviceDoctor(buffer: 0); + $laser = $this->service($section, 'لیزر کامل بدن', 60); + $start = time() + 86_400 + random_int(1, 5_000) * 60; + + [$res] = $this->book($doctor, [$laser->getUuid()], $start, $start + 5 * 60); + self::assertSame(201, $this->responseCode()); + + $appt = $this->reload($res['data']['data']['uuid']); + self::assertSame($start + 60 * 60, $appt->getSlotEnd()); + self::assertSame(60, $appt->getServiceTotalMinutes()); + } +} diff --git a/tests/Shared/TenantLookupInventoryTest.php b/tests/Shared/TenantLookupInventoryTest.php index a2c8a837..c6de6aa8 100644 --- a/tests/Shared/TenantLookupInventoryTest.php +++ b/tests/Shared/TenantLookupInventoryTest.php @@ -35,9 +35,9 @@ class TenantLookupInventoryTest extends TestCase * @var array */ private const REVIEWED = [ - // assertServicesMatchContext (مسیر ثبت نوبت) + خودِ گارد. جست‌وجوی مسیرِ اسلاتِ - // سرویسی به ServiceBookingCalculator منتقل شد و همان‌جا شمرده می‌شود. - 'src/Appointment/Controller/AppointmentController.php' => 2, + // AppointmentController دیگر جست‌وجوی بازبینی‌نشده ندارد: assertServicesMatchContext + // حذف شد و مسیر ثبت نوبت هم مثل مسیر اسلات از ServiceBookingCalculator رد می‌شود، + // که خودش مالکیت محیط را می‌سنجد و پایین‌تر شمرده می‌شود. // TenantOwnershipChecker::belongsToPair() روی هر uuid، پیش از هر بررسی دیگر 'src/Appointment/Service/ServiceBookingCalculator.php' => 2, // TenantOwnershipChecker روی بخش/سرویس/پرسنل و فهرست سرویس‌ها