feat: support multiple services per appointment (checkbox selection)

Appointments could only reference a single service (ManyToOne). Add an
appointment_service_items join table (ManyToMany) so an appointment can
carry several services; the first stays the primary service_item for
backward compatibility, and toArray now also returns service_items[].

Both create endpoints (my/appointment, admin/appointment) accept
service_item_uuids[] and attach all of them. A new duration_from_services
flag gates the slot_end recompute: service-booking mode sends it true
(slot_end = start + Σ durations); slot mode omits it so the manual end
time is preserved. The admin endpoint previously ignored services entirely.

Frontend: in slot mode the single service dropdown becomes a checkbox list
filtered by the selected section (multi-select); service mode sends the
duration flag. Migration + backend/entity tests + docs updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-16 10:10:47 +03:30
co-authored by Claude Opus 4.8
parent d76fd73529
commit 4642506c0f
9 changed files with 315 additions and 65 deletions
@@ -71,8 +71,12 @@ class MyAppointmentsController extends BaseController
// حالت نوبت‌دهی سرویسی: مدت نوبت از مجموعِ مدت سرویس‌های انتخاب‌شده تعیین
// و slot_end سمت سرور محاسبه می‌شود (به مقدار کلاینت اعتماد نمی‌شود).
$serviceUuids = array_values(array_filter(array_map('trim', (array) ($data['service_item_uuids'] ?? []))));
$serviceItems = [];
// `duration_from_services` فقط در نوبت‌دهی سرویسی true است: آنجا مدت نوبت از
// مجموع سرویس‌ها محاسبه و slot_end بازنویسی می‌شود. در حالت اسلاتی، سرویس‌ها
// فقط به نوبت پیوست می‌شوند و ساعت پایانِ دستی حفظ می‌شود.
$serviceUuids = array_values(array_filter(array_map('trim', (array) ($data['service_item_uuids'] ?? []))));
$computeDuration = (bool) ($data['duration_from_services'] ?? false);
$serviceItems = [];
if (!empty($serviceUuids) && !$isReserve) {
$totalMinutes = 0;
foreach ($serviceUuids as $u) {
@@ -80,16 +84,20 @@ class MyAppointmentsController extends BaseController
if ($item === null) {
return $this->error(ErrorCodes::VALIDATION, 'سرویس یافت نشد', 422, 'service_item_uuids');
}
if (!$item->isBookable()) {
return $this->error(ErrorCodes::VALIDATION, 'این سرویس برای نوبت‌دهی فعال نیست', 422, 'service_item_uuids');
if ($computeDuration) {
if (!$item->isBookable()) {
return $this->error(ErrorCodes::VALIDATION, 'این سرویس برای نوبت‌دهی فعال نیست', 422, 'service_item_uuids');
}
if (($item->getDurationMinutes() ?? 0) <= 0) {
return $this->error(ErrorCodes::VALIDATION, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids');
}
$totalMinutes += (int) $item->getDurationMinutes();
}
if (($item->getDurationMinutes() ?? 0) <= 0) {
return $this->error(ErrorCodes::VALIDATION, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids');
}
$totalMinutes += (int) $item->getDurationMinutes();
$serviceItems[] = $item;
}
$slotEnd = $slotStart + $totalMinutes * 60;
if ($computeDuration) {
$slotEnd = $slotStart + $totalMinutes * 60;
}
}
if (empty($doctorUuid) || $slotStart <= 0 || (!$isReserve && $slotEnd <= $slotStart) || empty($mobile) || empty($patientName)) {
@@ -140,9 +148,9 @@ class MyAppointmentsController extends BaseController
}
$appointment->$setter($entity);
}
// در حالت سرویسی، سرویسِ اصلیِ نوبت = اولین سرویسِ انتخاب‌شده.
if (!empty($serviceItems)) {
$appointment->setServiceItem($serviceItems[0]);
// پیوستِ همهٔ سرویس‌های انتخاب‌شده؛ سرویسِ اصلی = اولین سرویس (addServiceItem).
foreach ($serviceItems as $si) {
$appointment->addServiceItem($si);
}
if (!empty($data['deposit_required'])) {
$appointment->setDepositRequired(true);