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,
|
||||
} 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()}
|
||||
>
|
||||
انتقال و حذف از لیست
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user