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
@@ -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»
* الگو را پاس می‌کند ولی روزی نیست.