fix(booking): reserve conversion produced a zero-length midnight appointment

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) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-30 15:04:09 +03:30
co-authored by Claude Opus 5
parent 96095c05f3
commit 4fbedecec1
5 changed files with 359 additions and 21 deletions
@@ -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<string, array<int, array{uuid:string, name:string, price_rials:int}>>
*/
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