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
+125 -20
View File
@@ -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<ServicePick | null>(null);
const [start, setStart] = useState("");
const [end, setEnd] = useState("");
const initialSelection = useMemo<PickedService[]>(() => {
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({
<div style={{ margin: "6px 0 16px" }}>
<PersianDateInput value={date} onChange={setDate} />
</div>
{/* بازگشت به لیست نوبت‌ها زمان لازم دارد؛ رزرو زمانی ندارد که ارث ببرد. */}
{!toReserve && serviceMode && date && (
<div style={{ marginBottom: 16 }}>
<ServiceSlotPicker
doctorUuid={a.doctor_uuid}
date={date}
services={services}
clinicUuidOverride={a.clinic_uuid ?? null}
excludeAppointmentUuid={a.uuid}
initialSelection={initialSelection}
onSelect={setPick}
/>
</div>
)}
{!toReserve && !serviceMode && (
<div style={{ display: "flex", gap: 10, marginBottom: 16 }}>
<div style={{ flex: 1 }}>
<label style={{ fontSize: 12.5, color: "var(--text-3)" }}>
ساعت شروع
</label>
<div className="field" style={{ marginTop: 6 }}>
<input
aria-label="ساعت شروع"
type="time"
value={start}
onChange={(e) => setStart(e.target.value)}
dir="ltr"
/>
</div>
</div>
<div style={{ flex: 1 }}>
<label style={{ fontSize: 12.5, color: "var(--text-3)" }}>
ساعت پایان
</label>
<div className="field" style={{ marginTop: 6 }}>
<input
aria-label="ساعت پایان"
type="time"
value={end}
onChange={(e) => setEnd(e.target.value)}
dir="ltr"
/>
</div>
</div>
</div>
)}
<div style={{ display: "flex", gap: 8 }}>
<button
className="btn primary"
style={{ flex: 1 }}
disabled={!date || transfer.isPending}
disabled={!canSubmit || transfer.isPending}
onClick={() => transfer.mutate()}
>
انتقال و حذف از لیست
+12 -1
View File
@@ -180,7 +180,18 @@ export default function ReserveAppointmentsPage() {
</div>
</td>
<td style={{ ...td, fontWeight: 600 }}>{formatDate(a.slot_start)}</td>
<td style={td}>{a.service_item?.name || '—'}</td>
<td style={td}>
{/* چند-سرویسی: نگه‌داشتن فقط سرویس تکی یعنی بقیه دیده نمی‌شوند. */}
{a.service_items?.length
? a.service_items.map(s => s.name).join('، ')
: a.service_item?.name || '—'}
{a.service_total_minutes ? (
<span style={{ color: 'var(--text-3)', fontSize: 12, marginInlineStart: 6 }}>
({a.service_total_minutes} دقیقه
{a.service_buffer_minutes ? ` +${a.service_buffer_minutes} فاصله` : ''})
</span>
) : null}
</td>
<td style={td}>{a.staff?.full_name || '—'}</td>
<td style={td}>
<AppointmentStatusDropdown uuid={a.uuid} currentStatus={a.status} version={a.version} queryKey={queryKey} />
+5
View File
@@ -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 {
@@ -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
@@ -0,0 +1,171 @@
<?php
namespace App\Tests\Appointment;
use App\Appointment\Entity\Appointment;
use App\Appointment\Entity\WeeklySchedule;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\Doctor;
use App\Tests\ApiTestCase;
/**
* `GET /api/v1/my/appointments` سریالایزر خودش را دارد (array hydration)، نه
* `Appointment::toArray()`. پس فیلدهای سرویسی باید صریحاً همان‌جا اضافه شوند —
* وگرنه پنل ادمین و پنل کاربر سایت داده‌ای برای نمایش ندارند.
*/
class MyAppointmentsServiceFieldsTest extends ApiTestCase
{
/** @return array{0:\App\Auth\Entity\User,1:Doctor,2:ServiceSection} */
private function serviceDoctor(): array
{
$owner = $this->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']);
}
}