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:
@@ -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»
|
||||
* الگو را پاس میکند ولی روزی نیست.
|
||||
|
||||
Reference in New Issue
Block a user