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:
@@ -14,7 +14,7 @@ import {
|
|||||||
WalletIcon,
|
WalletIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 ReactDOM from "react-dom";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -27,6 +27,9 @@ import Modal from "./ui/Modal";
|
|||||||
import PersianDateInput from "./ui/PersianDateInput";
|
import PersianDateInput from "./ui/PersianDateInput";
|
||||||
import PriceInput from "./ui/PriceInput";
|
import PriceInput from "./ui/PriceInput";
|
||||||
import SearchableSelect from "./ui/SearchableSelect";
|
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). */
|
/** Row actions for the appointments table (Figma عملیات menu). */
|
||||||
type ModalKind = null | "info" | "move" | "transfer" | "replace";
|
type ModalKind = null | "info" | "move" | "transfer" | "replace";
|
||||||
@@ -573,26 +576,79 @@ export function TransferReserveModal({
|
|||||||
const [date, setDate] = useState(a.appointment_date);
|
const [date, setDate] = useState(a.appointment_date);
|
||||||
const toReserve = !a.is_reserve;
|
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({
|
const transfer = useMutation({
|
||||||
mutationFn: () => {
|
mutationFn: async () => {
|
||||||
const day = toEpoch(date, "00:00");
|
const day = toEpoch(date, "00:00");
|
||||||
return api.patch(
|
|
||||||
`/api/v1/appointment/${a.uuid}`,
|
if (toReserve) {
|
||||||
toReserve
|
// reserve entries are day-level: midnight-to-midnight, no slot occupation
|
||||||
? // reserve entries are day-level: midnight-to-midnight, no slot occupation
|
return api.patch(`/api/v1/appointment/${a.uuid}`, {
|
||||||
{
|
is_reserve: true,
|
||||||
is_reserve: true,
|
slot_start: day,
|
||||||
slot_start: day,
|
slot_end: day,
|
||||||
slot_end: day,
|
version: a.version,
|
||||||
version: a.version,
|
});
|
||||||
}
|
}
|
||||||
: {
|
|
||||||
is_reserve: false,
|
if (serviceMode) {
|
||||||
slot_start: toEpoch(date, a.appointment_time),
|
// ابتدا زماندار شود (رزرو زمان ندارد و service-reschedule رزرو را رد میکند)،
|
||||||
slot_end: toEpoch(date, a.end_time),
|
// سپس مدت و سرویسها با endpoint سرویسآگاه تثبیت شوند.
|
||||||
version: a.version,
|
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: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey });
|
qc.invalidateQueries({ queryKey });
|
||||||
@@ -640,11 +696,60 @@ export function TransferReserveModal({
|
|||||||
<div style={{ margin: "6px 0 16px" }}>
|
<div style={{ margin: "6px 0 16px" }}>
|
||||||
<PersianDateInput value={date} onChange={setDate} />
|
<PersianDateInput value={date} onChange={setDate} />
|
||||||
</div>
|
</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 }}>
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
<button
|
<button
|
||||||
className="btn primary"
|
className="btn primary"
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
disabled={!date || transfer.isPending}
|
disabled={!canSubmit || transfer.isPending}
|
||||||
onClick={() => transfer.mutate()}
|
onClick={() => transfer.mutate()}
|
||||||
>
|
>
|
||||||
انتقال و حذف از لیست
|
انتقال و حذف از لیست
|
||||||
|
|||||||
@@ -180,7 +180,18 @@ export default function ReserveAppointmentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...td, fontWeight: 600 }}>{formatDate(a.slot_start)}</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}>{a.staff?.full_name || '—'}</td>
|
||||||
<td style={td}>
|
<td style={td}>
|
||||||
<AppointmentStatusDropdown uuid={a.uuid} currentStatus={a.status} version={a.version} queryKey={queryKey} />
|
<AppointmentStatusDropdown uuid={a.uuid} currentStatus={a.status} version={a.version} queryKey={queryKey} />
|
||||||
|
|||||||
@@ -120,6 +120,11 @@ export interface Appointment {
|
|||||||
insurance_service_category?: string | null;
|
insurance_service_category?: string | null;
|
||||||
insurance_service_category_label?: string | null;
|
insurance_service_category_label?: string | null;
|
||||||
insurance_base_id?: number | 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 {
|
export interface AppointmentEvent {
|
||||||
|
|||||||
@@ -326,6 +326,8 @@ class MyAppointmentsController extends BaseController
|
|||||||
'DISTINCT a.uuid, a.slotStart, a.slotEnd, a.status, a.createdAt, a.version',
|
'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.isReserve, a.depositRequired, a.depositAmountRials, a.note, a.patientName as override_name',
|
||||||
'a.patientNationalCode as national_code, a.patientGender as gender',
|
'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',
|
'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',
|
'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',
|
'ss.uuid as section_uuid, ss.name as section_name',
|
||||||
@@ -338,6 +340,7 @@ class MyAppointmentsController extends BaseController
|
|||||||
->leftJoin('a.serviceSection', 'ss')
|
->leftJoin('a.serviceSection', 'ss')
|
||||||
->leftJoin('a.serviceItem', 'si')
|
->leftJoin('a.serviceItem', 'si')
|
||||||
->leftJoin('a.staff', 'st')
|
->leftJoin('a.staff', 'st')
|
||||||
|
->leftJoin('a.clinic', 'cl')
|
||||||
->andWhere('a.isReserve = :reserveOnly')
|
->andWhere('a.isReserve = :reserveOnly')
|
||||||
->setParameter('reserveOnly', $reserveOnly)
|
->setParameter('reserveOnly', $reserveOnly)
|
||||||
->orderBy('a.slotStart', 'ASC');
|
->orderBy('a.slotStart', 'ASC');
|
||||||
@@ -410,6 +413,10 @@ class MyAppointmentsController extends BaseController
|
|||||||
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
|
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
|
||||||
->getQuery()->getArrayResult();
|
->getQuery()->getArrayResult();
|
||||||
|
|
||||||
|
// سرویسهای چندگانه در یک کوئری جدا: JOIN زدنشان به کوئری بالا ردیفها را ضرب
|
||||||
|
// میکند و pagination را میشکند.
|
||||||
|
$serviceItemsByAppointment = $this->serviceItemsFor(array_column($rows, 'uuid'));
|
||||||
|
|
||||||
$items = array_map(fn(array $a) => [
|
$items = array_map(fn(array $a) => [
|
||||||
'uuid' => $a['uuid'],
|
'uuid' => $a['uuid'],
|
||||||
'patient_name' => $a['override_name'] ?: ($a['patient_name'] ?? ''),
|
'patient_name' => $a['override_name'] ?: ($a['patient_name'] ?? ''),
|
||||||
@@ -433,12 +440,51 @@ class MyAppointmentsController extends BaseController
|
|||||||
'note' => $a['note'],
|
'note' => $a['note'],
|
||||||
'service_section' => $a['section_uuid'] ? ['uuid' => $a['section_uuid'], 'name' => $a['section_name']] : null,
|
'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' => $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,
|
'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);
|
], $rows);
|
||||||
|
|
||||||
return $this->paginated($items, (int) $total, $page, $limit);
|
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'])]
|
#[Route('/api/v1/my/appointments/today-stats', methods: ['GET'])]
|
||||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
public function todayStats(Request $request, #[CurrentUser] User $user): JsonResponse
|
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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user