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 را نسبت به سایر رزروهای همان پزشک اتمیک می‌کند.
@@ -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` عمداً کامنت شده | ✅ | باز شد: دکمهٔ لغو (با پیش‌نمایش جریمه) و جابه‌جایی، فقط برای نوبتِ آیندهٔ لغونشده |
@@ -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»
* الگو را پاس می‌کند ولی روزی نیست.
@@ -0,0 +1,205 @@
<?php
namespace App\Tests\Appointment;
use App\Appointment\Entity\Appointment;
use App\Appointment\Entity\WeeklySchedule;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\Doctor;
use App\Tests\ApiTestCase;
/**
* `POST /api/v1/appointment` — همان endpointی که **سایت عمومی** با آن نوبت ثبت می‌کند
* (`nobat724_front/components/appointment/detail/SubmitData.js`).
*
* چرا این تست هست: {@see PublicSiteAppointmentContractTest} قرارداد *خواندن* را می‌سنجد
* ولی نوبتش را در حافظه می‌ساخت و خودش `replaceServiceItems()` و `setServiceDuration()`
* را صدا می‌زد. برای همین وقتی مسیر *نوشتن* هیچ‌کدام را صدا نمی‌زد، همهٔ تست‌ها سبز
* بودند و نوبتِ واقعیِ ثبت‌شده از سایت هیچ سرویسی نداشت. اینجا از خود endpoint شروع
* می‌کنیم تا آن شکاف دوباره باز نشود.
*/
class PublicBookingServicePersistenceTest extends ApiTestCase
{
/** @return array{0:Doctor,1:ServiceSection} */
private function serviceDoctor(int $buffer = 10): array
{
$owner = $this->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());
}
}
+3 -3
View File
@@ -35,9 +35,9 @@ class TenantLookupInventoryTest extends TestCase
* @var array<string, int>
*/
private const REVIEWED = [
// assertServicesMatchContext (مسیر ثبت نوبت) + خودِ گارد. جست‌وجوی مسیرِ اسلاتِ
// سرویسی به ServiceBookingCalculator منتقل شد و همان‌جا شمرده می‌شود.
'src/Appointment/Controller/AppointmentController.php' => 2,
// AppointmentController دیگر جست‌وجوی بازبینی‌نشده ندارد: assertServicesMatchContext
// حذف شد و مسیر ثبت نوبت هم مثل مسیر اسلات از ServiceBookingCalculator رد می‌شود،
// که خودش مالکیت محیط را می‌سنجد و پایین‌تر شمرده می‌شود.
// TenantOwnershipChecker::belongsToPair() روی هر uuid، پیش از هر بررسی دیگر
'src/Appointment/Service/ServiceBookingCalculator.php' => 2,
// TenantOwnershipChecker روی بخش/سرویس/پرسنل و فهرست سرویس‌ها