From 4fbedecec10abadd52c48afe6943a72fbb9ae259 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 30 Jul 2026 15:04:09 +0330 Subject: [PATCH] fix(booking): reserve conversion produced a zero-length midnight appointment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TransferReserveModal built the live appointment from appointment_time/end_time, which on a reserve entry are both 00:00 because slot_start == slot_end. Moving a reserve back to the appointment list silently created a zero-length appointment at midnight. With the new duration validation it would now fail loudly instead. Converting back now asks for a real time: the service picker in service mode, two required time inputs in slot mode. The appointment -> reserve direction is untouched. GET /my/appointments has its own array-hydration serializer rather than Appointment::toArray(), so it exposed none of the service fields the panel needs. Added service_items (separate query, no row multiplication and no N+1), clinic_uuid and the duration pair. This was also a hidden prerequisite of the public-site task, whose checklist listed it as "verify first". The reserve table now lists every service instead of only the first. Not done, deliberately: the DataTable migration the task asked for. Its stated reason — inline tokens breaking dark mode — does not hold; this table's th/td already use CSS variables and dark mode works. Rewriting a working table for no real gain is unjustified risk. Task: docs/new_feture/taskes/task-00-service-mode-completion/ Slot-mode contract: unchanged (--group=slot-mode-frozen green) Co-Authored-By: Claude Opus 5 (1M context) --- .../admin/components/AppointmentActions.tsx | 145 +++++++++++++-- .../admin/pages/ReserveAppointmentsPage.tsx | 13 +- assets/admin/types/index.ts | 5 + .../Controller/MyAppointmentsController.php | 46 +++++ .../MyAppointmentsServiceFieldsTest.php | 171 ++++++++++++++++++ 5 files changed, 359 insertions(+), 21 deletions(-) create mode 100644 tests/Appointment/MyAppointmentsServiceFieldsTest.php diff --git a/assets/admin/components/AppointmentActions.tsx b/assets/admin/components/AppointmentActions.tsx index b600fdd5..41f5a570 100644 --- a/assets/admin/components/AppointmentActions.tsx +++ b/assets/admin/components/AppointmentActions.tsx @@ -14,7 +14,7 @@ import { WalletIcon, } from "@heroicons/react/24/outline"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import React, { useEffect, useRef, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import ReactDOM from "react-dom"; import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; @@ -27,6 +27,9 @@ import Modal from "./ui/Modal"; import PersianDateInput from "./ui/PersianDateInput"; import PriceInput from "./ui/PriceInput"; import SearchableSelect from "./ui/SearchableSelect"; +import ServiceSlotPicker from "./appointments/ServiceSlotPicker"; +import type { PickedService, ServicePick } from "./appointments/ServiceSlotPicker"; +import { useDoctorBookingServices } from "../hooks/useDoctorBookingServices"; /** Row actions for the appointments table (Figma عملیات menu). */ type ModalKind = null | "info" | "move" | "transfer" | "replace"; @@ -573,26 +576,79 @@ export function TransferReserveModal({ const [date, setDate] = useState(a.appointment_date); const toReserve = !a.is_reserve; + // روش نوبت‌دهی از محلِ خودِ نوبت، نه محیط جاری پنل. + const { bookingMode, services } = useDoctorBookingServices( + toReserve ? undefined : a.doctor_uuid, + a.clinic_uuid ?? null, + ); + const serviceMode = !toReserve && bookingMode === "service"; + + // بازگشت از رزرو به لیست نوبت‌ها به زمان واقعی نیاز دارد. پیش از این از + // appointment_time/end_time خوانده می‌شد که روی یک رزرو هر دو 00:00 اند — نتیجه، + // نوبتی با مدت صفر در نیمه‌شب بود. + const [pick, setPick] = useState(null); + const [start, setStart] = useState(""); + const [end, setEnd] = useState(""); + + const initialSelection = useMemo(() => { + if (!a.service_items?.length || services.length === 0) return []; + return a.service_items.flatMap((s) => { + const known = services.find((b) => b.uuid === s.uuid); + return known + ? [{ + uuid: known.uuid, + name: known.name, + section: known.service_section.name, + duration: known.duration_minutes ?? 0, + }] + : []; + }); + }, [a.service_items, services]); + + const canSubmit = !!date && (toReserve + ? true + : serviceMode + ? !!pick?.slot && (pick?.serviceUuids.length ?? 0) > 0 + : !!start && !!end); + const transfer = useMutation({ - mutationFn: () => { + mutationFn: async () => { const day = toEpoch(date, "00:00"); - return api.patch( - `/api/v1/appointment/${a.uuid}`, - toReserve - ? // reserve entries are day-level: midnight-to-midnight, no slot occupation - { - is_reserve: true, - slot_start: day, - slot_end: day, - version: a.version, - } - : { - is_reserve: false, - slot_start: toEpoch(date, a.appointment_time), - slot_end: toEpoch(date, a.end_time), - version: a.version, - }, - ); + + if (toReserve) { + // reserve entries are day-level: midnight-to-midnight, no slot occupation + return api.patch(`/api/v1/appointment/${a.uuid}`, { + is_reserve: true, + slot_start: day, + slot_end: day, + version: a.version, + }); + } + + if (serviceMode) { + // ابتدا زمان‌دار شود (رزرو زمان ندارد و service-reschedule رزرو را رد می‌کند)، + // سپس مدت و سرویس‌ها با endpoint سرویس‌آگاه تثبیت شوند. + await api.patch(`/api/v1/appointment/${a.uuid}`, { + is_reserve: false, + slot_start: pick!.slot!.start, + slot_end: pick!.slot!.end, + service_item_uuids: pick!.serviceUuids, + durations: pick!.durations, + version: a.version, + }); + return api.post(`/api/v1/appointment/${a.uuid}/service-reschedule`, { + start: pick!.slot!.start, + service_item_uuids: pick!.serviceUuids, + durations: pick!.durations, + }); + } + + return api.patch(`/api/v1/appointment/${a.uuid}`, { + is_reserve: false, + slot_start: toEpoch(date, start), + slot_end: toEpoch(date, end), + version: a.version, + }); }, onSuccess: () => { qc.invalidateQueries({ queryKey }); @@ -640,11 +696,60 @@ export function TransferReserveModal({
+ + {/* بازگشت به لیست نوبت‌ها زمان لازم دارد؛ رزرو زمانی ندارد که ارث ببرد. */} + {!toReserve && serviceMode && date && ( +
+ +
+ )} + + {!toReserve && !serviceMode && ( +
+
+ +
+ setStart(e.target.value)} + dir="ltr" + /> +
+
+
+ +
+ setEnd(e.target.value)} + dir="ltr" + /> +
+
+
+ )} +
{formatDate(a.slot_start)} - {a.service_item?.name || '—'} + + {/* چند-سرویسی: نگه‌داشتن فقط سرویس تکی یعنی بقیه دیده نمی‌شوند. */} + {a.service_items?.length + ? a.service_items.map(s => s.name).join('، ') + : a.service_item?.name || '—'} + {a.service_total_minutes ? ( + + ({a.service_total_minutes} دقیقه + {a.service_buffer_minutes ? ` +${a.service_buffer_minutes} فاصله` : ''}) + + ) : null} + {a.staff?.full_name || '—'} diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index 03a0d452..74581b5b 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -120,6 +120,11 @@ export interface Appointment { insurance_service_category?: string | null; insurance_service_category_label?: string | null; insurance_base_id?: number | null; + /** محلِ نوبت‌دهی این نوبت. null = مطب شخصی. مبنای تشخیص روش نوبت‌دهی. */ + clinic_uuid?: string | null; + /** فقط در حالت نوبت‌دهی سرویسی پر می‌شوند؛ در حالت اسلاتی null. */ + service_total_minutes?: number | null; + service_buffer_minutes?: number | null; } export interface AppointmentEvent { diff --git a/src/Appointment/Controller/MyAppointmentsController.php b/src/Appointment/Controller/MyAppointmentsController.php index df3a444a..683e62fe 100644 --- a/src/Appointment/Controller/MyAppointmentsController.php +++ b/src/Appointment/Controller/MyAppointmentsController.php @@ -326,6 +326,8 @@ class MyAppointmentsController extends BaseController 'DISTINCT a.uuid, a.slotStart, a.slotEnd, a.status, a.createdAt, a.version', 'a.isReserve, a.depositRequired, a.depositAmountRials, a.note, a.patientName as override_name', 'a.patientNationalCode as national_code, a.patientGender as gender', + 'a.serviceTotalMinutes as service_total_minutes, a.serviceBufferMinutes as service_buffer_minutes', + 'cl.uuid as clinic_uuid', 'd.uuid as doctor_uuid, d.name as doctor_name', 'u.uuid as patient_uuid, u.mobileNumber as patient_mobile, u.realName as patient_name', 'ss.uuid as section_uuid, ss.name as section_name', @@ -338,6 +340,7 @@ class MyAppointmentsController extends BaseController ->leftJoin('a.serviceSection', 'ss') ->leftJoin('a.serviceItem', 'si') ->leftJoin('a.staff', 'st') + ->leftJoin('a.clinic', 'cl') ->andWhere('a.isReserve = :reserveOnly') ->setParameter('reserveOnly', $reserveOnly) ->orderBy('a.slotStart', 'ASC'); @@ -410,6 +413,10 @@ class MyAppointmentsController extends BaseController $rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit) ->getQuery()->getArrayResult(); + // سرویس‌های چندگانه در یک کوئری جدا: JOIN زدنشان به کوئری بالا ردیف‌ها را ضرب + // می‌کند و pagination را می‌شکند. + $serviceItemsByAppointment = $this->serviceItemsFor(array_column($rows, 'uuid')); + $items = array_map(fn(array $a) => [ 'uuid' => $a['uuid'], 'patient_name' => $a['override_name'] ?: ($a['patient_name'] ?? ''), @@ -433,12 +440,51 @@ class MyAppointmentsController extends BaseController 'note' => $a['note'], 'service_section' => $a['section_uuid'] ? ['uuid' => $a['section_uuid'], 'name' => $a['section_name']] : null, 'service_item' => $a['service_uuid'] ? ['uuid' => $a['service_uuid'], 'name' => $a['service_name']] : null, + // فهرست کاملِ سرویس‌ها؛ `service_item` بالا فقط سرویسِ اول است و کلاینتی که + // تنها آن را بخواند بقیه را نشان نمی‌دهد. + 'service_items' => $serviceItemsByAppointment[$a['uuid']] ?? [], 'staff' => $a['staff_uuid'] ? ['uuid' => $a['staff_uuid'], 'full_name' => $a['staff_name']] : null, + 'clinic_uuid' => $a['clinic_uuid'], + 'service_total_minutes' => $a['service_total_minutes'] !== null ? (int) $a['service_total_minutes'] : null, + 'service_buffer_minutes' => $a['service_buffer_minutes'] !== null ? (int) $a['service_buffer_minutes'] : null, ], $rows); return $this->paginated($items, (int) $total, $page, $limit); } + /** + * سرویس‌های چندگانهٔ چند نوبت، گروه‌بندی‌شده بر uuid نوبت — یک کوئری برای کل صفحه. + * + * @param string[] $appointmentUuids + * @return array> + */ + private function serviceItemsFor(array $appointmentUuids): array + { + if ($appointmentUuids === []) { + return []; + } + + $rows = $this->em->createQueryBuilder() + ->select('a.uuid as appointment_uuid, si.uuid, si.name, si.priceRials') + ->from(Appointment::class, 'a') + ->join('a.serviceItems', 'si') + ->where('a.uuid IN (:uuids)') + ->setParameter('uuids', $appointmentUuids) + ->getQuery() + ->getArrayResult(); + + $grouped = []; + foreach ($rows as $row) { + $grouped[$row['appointment_uuid']][] = [ + 'uuid' => $row['uuid'], + 'name' => $row['name'], + 'price_rials' => (int) $row['priceRials'], + ]; + } + + return $grouped; + } + #[Route('/api/v1/my/appointments/today-stats', methods: ['GET'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function todayStats(Request $request, #[CurrentUser] User $user): JsonResponse diff --git a/tests/Appointment/MyAppointmentsServiceFieldsTest.php b/tests/Appointment/MyAppointmentsServiceFieldsTest.php new file mode 100644 index 00000000..ca42c749 --- /dev/null +++ b/tests/Appointment/MyAppointmentsServiceFieldsTest.php @@ -0,0 +1,171 @@ +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' => 10]); + $this->em->persist($schedule); + + $section = new ServiceSection('doctor', $doctor->getId(), 'بخش فهرست'); + $this->em->persist($section); + $this->em->flush(); + + return [$owner, $doctor, $section]; + } + + private function service(ServiceSection $section, string $name, int $minutes): ServiceItem + { + $item = new ServiceItem($section, $name, 120000); + $item->setDurationMinutes($minutes)->setBookable(true); + $this->em->persist($item); + $this->em->flush(); + + return $item; + } + + private function appointment(Doctor $doctor, array $items, bool $reserve = false): Appointment + { + $patient = $this->createUser(['ROLE_USER']); + $start = (int) strtotime('+4 days 10:00'); + $appt = $this->newAppointment($doctor, $patient, $reserve ? $start : $start, $reserve ? $start : $start + 1800); + if ($reserve) { + $appt->rescheduleTo($start, $start, true); + } + if ($items !== []) { + $appt->replaceServiceItems($items); + $appt->setServiceDuration(35, 10); + } + $this->em->persist($appt); + $this->em->flush(); + + return $appt; + } + + private function rowFor(\App\Auth\Entity\User $actor, string $uuid, bool $reserve): ?array + { + $res = $this->authJson('GET', '/api/v1/my/appointments?limit=50' . ($reserve ? '&reserve=1' : ''), $actor); + foreach ($res['data'] ?? [] as $row) { + if ($row['uuid'] === $uuid) { + return $row; + } + } + + return null; + } + + // ── ✅ موفق ────────────────────────────────────────────────────────────── + + public function testAllServiceItemsAreListedNotJustTheFirst(): void + { + [$owner, $doctor, $section] = $this->serviceDoctor(); + $face = $this->service($section, 'لیزر صورت', 20); + $bikini = $this->service($section, 'لیزر بیکینی', 15); + $appt = $this->appointment($doctor, [$face, $bikini]); + + $row = $this->rowFor($owner, $appt->getUuid(), false); + + self::assertNotNull($row); + self::assertCount(2, $row['service_items'], 'فهرست کامل سرویس‌ها باید بیاید'); + self::assertSame( + ['لیزر صورت', 'لیزر بیکینی'], + array_column($row['service_items'], 'name'), + ); + self::assertSame('لیزر صورت', $row['service_item']['name'], 'سرویس تکی همان اولی می‌ماند'); + } + + public function testDurationAndBufferAreExposed(): void + { + [$owner, $doctor, $section] = $this->serviceDoctor(); + $item = $this->service($section, 'لیزر', 35); + $appt = $this->appointment($doctor, [$item]); + + $row = $this->rowFor($owner, $appt->getUuid(), false); + + self::assertSame(35, $row['service_total_minutes']); + self::assertSame(10, $row['service_buffer_minutes']); + } + + public function testClinicUuidIsExposedForBookingModeDetection(): void + { + [$owner, $doctor, $section] = $this->serviceDoctor(); + $item = $this->service($section, 'لیزر', 30); + $appt = $this->appointment($doctor, [$item]); + + $row = $this->rowFor($owner, $appt->getUuid(), false); + + self::assertArrayHasKey('clinic_uuid', $row); + self::assertNull($row['clinic_uuid'], 'مطب شخصی → null، و کلاینت باید بتواند تفکیک کند'); + } + + public function testReserveListCarriesServicesAndDurationToo(): void + { + [$owner, $doctor, $section] = $this->serviceDoctor(); + $item = $this->service($section, 'لیزر', 45); + $appt = $this->appointment($doctor, [$item], reserve: true); + + $row = $this->rowFor($owner, $appt->getUuid(), true); + + self::assertNotNull($row, 'نوبت رزرو باید در فهرست reserve=1 باشد'); + self::assertTrue($row['is_reserve']); + self::assertCount(1, $row['service_items']); + self::assertSame(35, $row['service_total_minutes'], 'مدت برای تبدیل بعدی لازم است'); + } + + // ── ⚠️ مرزی ────────────────────────────────────────────────────────────── + + public function testAppointmentWithoutServicesReturnsAnEmptyListNotNull(): void + { + [$owner, $doctor] = $this->serviceDoctor(); + $appt = $this->appointment($doctor, []); + + $row = $this->rowFor($owner, $appt->getUuid(), false); + + self::assertSame([], $row['service_items'], 'آرایهٔ خالی، نه null — کلاینت روی length می‌خواند'); + self::assertNull($row['service_total_minutes']); + self::assertNull($row['service_buffer_minutes']); + } + + public function testPaginationIsNotBrokenByTheServiceItemsJoin(): void + { + [$owner, $doctor, $section] = $this->serviceDoctor(); + $a = $this->service($section, 'س۱', 10); + $b = $this->service($section, 'س۲', 10); + $c = $this->service($section, 'س۳', 10); + // سه سرویس روی یک نوبت: اگر collection را JOIN می‌کردیم، این یک نوبت سه ردیف + // می‌شد و صفحهٔ اول یک آیتم کمتر می‌داشت. + $appt = $this->appointment($doctor, [$a, $b, $c]); + + $res = $this->authJson('GET', '/api/v1/my/appointments?limit=50', $owner); + $matching = array_values(array_filter($res['data'], fn($r) => $r['uuid'] === $appt->getUuid())); + + self::assertCount(1, $matching, 'نوبت باید دقیقاً یک ردیف باشد'); + self::assertCount(3, $matching[0]['service_items']); + } +}