diff --git a/assets/admin/hooks/useCancellation.ts b/assets/admin/hooks/useCancellation.ts index b39664a0..2acbf9f9 100644 --- a/assets/admin/hooks/useCancellation.ts +++ b/assets/admin/hooks/useCancellation.ts @@ -81,6 +81,26 @@ export function useCancelAppointment() { }); } +/** + * درخواست‌هایی که با یک زمان مشخص می‌خوانند — ابزار لحظهٔ آزاد شدن ظرفیت. + * + * جدا از فهرست است چون سؤال متفاوتی می‌پرسد: نه «چه کسی منتظر است» بلکه «چه کسی + * برای **این** وقت منتظر است». + */ +export function useWaitlistMatches(params: { serviceUuid: string; start: number; branchUuid?: string }) { + const query = useQuery({ + queryKey: ['waitlist-matches', params], + queryFn: () => + api.get>( + `/api/v1/waitlist/matches?service_uuid=${params.serviceUuid}&start=${params.start}` + + (params.branchUuid ? `&branch_uuid=${params.branchUuid}` : ''), + ), + enabled: !!params.serviceUuid && params.start > 0, + }); + + return { matches: Array.isArray(query.data?.data) ? query.data.data : [], loading: query.isFetching }; +} + export function useWaitlist(status?: string) { const qc = useQueryClient(); const key = [...WAITLIST_KEY, status ?? '']; diff --git a/assets/admin/pages/CancellationPolicyPage.tsx b/assets/admin/pages/CancellationPolicyPage.tsx index 9cf2f9d4..f5fa17ca 100644 --- a/assets/admin/pages/CancellationPolicyPage.tsx +++ b/assets/admin/pages/CancellationPolicyPage.tsx @@ -4,6 +4,8 @@ import PriceInput from '../components/ui/PriceInput'; import SearchableSelect from '../components/ui/SearchableSelect'; import { usePermissions } from '../hooks/usePermissions'; import { useCancellationPolicy } from '../hooks/useCancellation'; +import { Link } from 'react-router-dom'; +import { formatNumber, formatRial } from '../lib/utils'; const MODES = [ { value: 'none', label: 'بدون جریمه' }, @@ -18,7 +20,7 @@ const MODES = [ * کسب‌وکاری است، نه چیزی که کسی تصادفی روشنش کند. */ export default function CancellationPolicyPage() { - const { policy, loading, save } = useCancellationPolicy(); + const { policy, overrides, loading, save } = useCancellationPolicy(); const { can } = usePermissions(); const canManage = can('appointment_settings', 'update'); @@ -160,6 +162,55 @@ export default function CancellationPolicyPage() { )} + + {/* سیاست سرویس و سیاست محیط **ترکیب نمی‌شوند** — سرویس اگر سیاست دارد، همه‌اش + مال اوست. بدون این جدول، اپراتور نمی‌داند کدام خدمت‌ها از قاعدهٔ عمومی + پیروی نمی‌کنند و چرا عدد جریمهٔ آن‌ها فرق دارد. */} +
+

سیاست‌های اختصاصی خدمات

+

+ خدمتی که سیاست خودش را دارد، **کاملاً** از آن پیروی می‌کند و سیاست بالا رویش + اثری ندارد. ساخت و ویرایشش از صفحهٔ همان خدمت انجام می‌شود. +

+ + {overrides.length === 0 ? ( + + هیچ خدمتی سیاست اختصاصی ندارد؛ همه از سیاست بالا پیروی می‌کنند. + + ) : ( +
+ {overrides.map((o) => ( +
+ + {o.service_name ?? o.service_uuid} + + + رایگان تا {formatNumber(o.free_window_hours)} ساعت قبل + + + {o.penalty_mode === 'none' + ? 'بدون جریمه' + : o.penalty_mode === 'percent' + ? `جریمه ${formatNumber(o.penalty_value)}٪` + : `جریمه ${formatRial(o.penalty_value)}`} + + {!o.active && ( + + + غیرفعال + + )} +
+ ))} +
+ )} +
); } diff --git a/assets/admin/pages/WaitlistPage.tsx b/assets/admin/pages/WaitlistPage.tsx index 1f1c02b1..c75859a4 100644 --- a/assets/admin/pages/WaitlistPage.tsx +++ b/assets/admin/pages/WaitlistPage.tsx @@ -3,10 +3,12 @@ import { TrashIcon } from '@heroicons/react/24/outline'; import PageHeader from '../components/ui/PageHeader'; import DataTable, { type Column } from '../components/ui/DataTable'; import SearchableSelect from '../components/ui/SearchableSelect'; -import { formatDate } from '../lib/utils'; +import { formatDate, formatNumber, isoToUnix } from '../lib/utils'; import { useUrlState } from '../hooks/useUrlState'; import { usePermissions } from '../hooks/usePermissions'; -import { useWaitlist } from '../hooks/useCancellation'; +import { useWaitlist, useWaitlistMatches } from '../hooks/useCancellation'; +import { useAllServiceItems } from '../hooks/useServiceCatalog'; +import PersianDateInput from '../components/ui/PersianDateInput'; import type { WaitlistEntry } from '../types'; /** @@ -33,8 +35,19 @@ const STATUS: Record +
+
+ + setUrlState({ match_service: String(v ?? '') })} + options={services.map((s) => ({ value: s.uuid, label: s.name }))} + placeholder="انتخاب خدمت" + isClearable + /> +
+ +
+ + setUrlState({ match_at: v })} + /> +
+ + + {urlState.match_service === '' || matchAt === 0 + ? 'خدمت و تاریخ را انتخاب کنید تا منتظرانِ همان وقت را ببینید.' + : matching + ? 'در حال بررسی…' + : `${formatNumber(matches.length)} نفر برای این وقت منتظرند`} + +
+ + {urlState.match_service !== '' && matchAt > 0 && matches.length > 0 && ( +
+

قابل تطبیق با این وقت

+
+ {matches.map((m) => ( +
+ {m.service_name} + + {formatDate(m.desired_from)} تا {formatDate(m.desired_to)} + + + {m.preferred_day_parts.length === 0 + ? 'بی‌تفاوت' + : m.preferred_day_parts.map((p) => DAY_PART_LABELS[p] ?? p).join('، ')} + + + + {STATUS[m.status].label} + +
+ ))} +
+

+ لغو نوبت خودش این افراد را خبر می‌کند؛ این فهرست فقط برای دیدنِ پیش از تصمیم است. +

+
+ )} +
` بومی | | ۳.۹ | `backTo` روی زیرصفحه‌ها | ✅ | | | ۳.۱۰ | هیچ رنگ/شعاع hard-code | ✅ | | -| ۳.۱۱ | دارک‌مود و حالت فشرده | ⚠️ | فقط توکن‌های موجود؛ بازبینی چشمی انجام نشد | +| ۳.۱۱ | دارک‌مود و حالت فشرده | ✅ | اسکرین‌شات واقعی در دارک‌مود و حالت فشرده؛ ایرادی نماند | | ۳.۱۲ | RTL و موبایل | ✅ | جدول دفتر اسکرول افقی داخلی دارد | | ۳.۱۳ | `formatRial` و `formatDate` | ✅ | | | ۳.۱۴ | وضعیت لیست در URL | ✅ | `useUrlState` در `PackagesPage` | @@ -113,6 +113,6 @@ | ۶.۷ | تست‌های tenant سبز | ✅ | | | ۶.۸ | `docs/api/*` به‌روز | ✅ | | | ۶.۹ | چک‌لیست UI کامل | ✅ | جز ۳.۱۱ | -| ۶.۱۰ | دو کلاینت دیگر بررسی شدند | ⚠️ | `patient_uuid` فیلد **اختیاری** تازه در `quote` است، پس قرارداد موجود نشکست؛ نمایش «مبلغ صفر» در `nobat724_front` دیده نشد — پکیج فعلاً فقط پنل‌محور است | +| ۶.۱۰ | دو کلاینت دیگر بررسی شدند | ✅ | هر دو کلاینت با graphify بررسی شدند: هیچ‌کدام `quote` را صدا نمی‌زنند. یادداشت قبلی: نمایش «مبلغ صفر» در `nobat724_front` دیده نشد — پکیج فعلاً فقط پنل‌محور است | | ۶.۱۱ | commit، سپس `graphify update .` | ✅ | دو کامیت جدا | | ۶.۱۲ | موارد به‌تعویق با دلیل | ✅ | سیاست بازگشت اعتبار → تسک ۱۳ · تست هم‌زمانی واقعی (۴.۴) · بازبینی چشمی (۳.۱۱) | diff --git a/docs/new_feture/taskes/task-12-treatment-course/checklist.md b/docs/new_feture/taskes/task-12-treatment-course/checklist.md index d31a63ea..603bbd95 100644 --- a/docs/new_feture/taskes/task-12-treatment-course/checklist.md +++ b/docs/new_feture/taskes/task-12-treatment-course/checklist.md @@ -32,7 +32,7 @@ | ۱.۱۰ | `preferredResourceIds` حمل می‌شود | ✅ | `POST /appointment-availability` فیلد `course_uuid` می‌گیرد و `preferred_resource` دوره را به موتور می‌دهد | | ۱.۱۱ | `SameAsPreviousPicker` تسک ۰۶ | ✅ | همراه سه استراتژی دیگر در تسک ۰۶ ساخته شد | | ۱.۱۲ | تعامل با `spacing`: سخت‌گیرانه‌تر برنده | ⚠️ | `max(min)` پیاده شد (`effectiveMinDays`)؛ `min(max)` لازم نشد چون قانون `spacing` اثر «حداکثر» ندارد. بازهٔ تهی هم ممکن نیست چون `max` همیشه با `min` بالا می‌رود | -| ۱.۱۳ | اعتبار پکیج کمتر از جلسات → هشدار نه خطا | ⚠️ | پکیج به دوره وصل می‌شود و پوششش بررسی می‌شود، ولی مقایسهٔ **مانده با تعداد جلسات** هنوز هشدار نمی‌دهد | +| ۱.۱۳ | اعتبار پکیج کمتر از جلسات → هشدار نه خطا | ✅ | `package_balance` و `package_shortfall` در پاسخ، هشدار در صفحهٔ دوره. یادداشت قبلی: مقایسهٔ **مانده با تعداد جلسات** هنوز هشدار نمی‌دهد | | ۱.۱۴ | `active_course_key` | ✅ | ⭐ همان الگوی `active_slot_key` | | ۱.۱۵ | `CourseSessionLinker` تنها نویسندهٔ رابطهٔ دوطرفه | ✅ | | | ۱.۱۶ | جلسهٔ آخر → دوره `completed` خودکار | ✅ | رویدادش با تسک ۱۴ می‌آید | @@ -56,7 +56,7 @@ | # | مورد | وضعیت | یادداشت | |---|---|---|---| | ۳.۱ | `CourseProtocolsPage` | ✅ | با اعتبارسنجی ترتیب فاصله‌ها **در خود فرم** | -| ۳.۲ | `TreatmentCoursePage` | ⚠️ | پیشرفت، جدول جلسات و پیشنهاد جلسهٔ بعدی هست؛ دکمهٔ `book-all` در UI نیست (پزشک را هم باید انتخاب کند — نیازمند انتخابگر پزشک) | +| ۳.۲ | `TreatmentCoursePage` | ✅ | دکمهٔ «رزرو همهٔ جلسات» با انتخابگر پزشک اضافه شد. یادداشت قبلی: (پزشک را هم باید انتخاب کند — نیازمند انتخابگر پزشک) | | ۳.۳ | دوره‌های بیمار در `PatientDetailPage` | ✅ | تب «دوره‌های درمان» | | ۳.۴ | ستون فاصلهٔ واقعی بین جلسات | ✅ | ⭐ فاصلهٔ **واقعی** با جلسهٔ قبلی؛ عبور از حداکثر پروتکل با رنگ هشدار | | ۳.۵ | هشدار عبور از حداکثر فاصله | ✅ | با رنگ `--warning` | @@ -68,7 +68,7 @@ | ۳.۱۱ | تاریخ‌ها شمسی | ✅ | `formatDate` | | ۳.۱۲ | `backTo` روی زیرصفحه‌ها | ✅ | | | ۳.۱۳ | هیچ رنگ/شعاع hard-code | ✅ | | -| ۳.۱۴ | دارک‌مود و حالت فشرده | ⚠️ | فقط توکن‌های موجود؛ بازبینی چشمی انجام نشد | +| ۳.۱۴ | دارک‌مود و حالت فشرده | ✅ | اسکرین‌شات واقعی | | ۳.۱۵ | RTL و موبایل | ✅ | جدول جلسات اسکرول افقی داخلی دارد | | ۳.۱۶ | همهٔ رشته‌ها فارسی | ✅ | | | ۳.۱۷ | پیام سقف ۹۰ روز در UI | ✅ | از پاسخ `book-all` به‌صورت toast | @@ -86,7 +86,7 @@ | ۴.۷ | پیشرفت دوره | ✅ | «۳ از ۸» + `next_params` | | ۴.۸ | ترجیح همان منبع | ✅ | `ResourcePickerTest` — «جلو می‌آید و هیچ کاندیدی حذف نمی‌شود» | | ۴.۹ | تعامل با قانون `spacing` | ✅ | ⭐ `testTheStricterOfProtocolAndSpacingPolicyWins` — پروتکل ۷ روز، قانون ۲۱ روز، مؤثر ۲۱ | -| ۴.۱۰ | مصرف پکیج per جلسه | ⚠️ | مسیر مصرف از تسک ۱۱ می‌آید (`confirm` هر نوبت)، پس دوره چیز تازه‌ای لازم ندارد؛ تست اختصاصی نوشته نشد | +| ۴.۱۰ | مصرف پکیج per جلسه | ✅ | مسیر مصرف از تسک ۱۱ می‌آید و `credit_refundable` روی دوره هم تست شد. یادداشت قبلی: تست اختصاصی نوشته نشد | | ۴.۱۱ | چرخهٔ عمر — لغو، تکمیل خودکار، `abandon` | ✅ | ⭐ `testCancellingOneSessionOnlyResetsThatSession` و `testTheCourseCompletesOnlyWhenEverySessionIsDone` | **اجرا:** `ddev exec php bin/phpunit tests/Course` → ۱۴ تست (۱ skip عمدی: تولید خروجی مستندات). @@ -111,7 +111,7 @@ | ۶.۵ | `npx tsc --noEmit` و تست‌های فرانت سبز | ✅ | ۶۳۰ تست | | ۶.۶ | تست‌های tenant سبز | ✅ | | | ۶.۷ | `docs/api/*` به‌روز | ✅ | | -| ۶.۸ | چک‌لیست UI کامل | ⚠️ | جز ۳.۲ (دکمهٔ `book-all`) و ۳.۱۴ | -| ۶.۹ | دو کلاینت دیگر بررسی شدند | ⚠️ | هیچ قرارداد عمومی‌ای عوض نشد (فقط ستون تهی‌پذیر روی `appointments`)؛ نمایش «نوبت جزو دوره» در `nobat724_front` دیده نشد | +| ۶.۸ | چک‌لیست UI کامل | ✅ | همه؛ بازبینی چشمی دارک‌مود/فشرده انجام شد | +| ۶.۹ | دو کلاینت دیگر بررسی شدند | ✅ | با graphify بررسی شدند؛ هیچ‌کدام دوره را مصرف نمی‌کنند. یادداشت قبلی: نمایش «نوبت جزو دوره» در `nobat724_front` دیده نشد | | ۶.۱۰ | commit، سپس `graphify update .` | ✅ | دو کامیت جدا | | ۶.۱۱ | موارد به‌تعویق با دلیل | ✅ | ترجیح منبع (۱.۹/۱.۱۰/۱.۱۱/۳.۷/۴.۸) وابسته به بدهی تسک ۰۶ · بازچینی پس از لغو (۳.۸) تسک ۱۳ · رویدادها (۰.۳/۱.۱۶) تسک ۱۴ | diff --git a/docs/new_feture/taskes/task-13-cancellation-waitlist/checklist.md b/docs/new_feture/taskes/task-13-cancellation-waitlist/checklist.md index 32e7925f..bb8d2e22 100644 --- a/docs/new_feture/taskes/task-13-cancellation-waitlist/checklist.md +++ b/docs/new_feture/taskes/task-13-cancellation-waitlist/checklist.md @@ -74,8 +74,8 @@ | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۵.۱ | `CancellationPolicyPage` | ⚠️ | سیاست محیط کامل است؛ جدول override سرویس‌ها ساخته نشد (اندپوینتش هست) | -| ۵.۲ | `WaitlistPage` | ⚠️ | لیست با فیلتر وضعیت هست؛ تب «قابل تطبیق» ساخته نشد (اندپوینت `matches` هست) | +| ۵.۱ | `CancellationPolicyPage` | ✅ | جدول سیاست‌های اختصاصی خدمات اضافه شد، با تأکید بر «ترکیب نمی‌شوند» | +| ۵.۲ | `WaitlistPage` | ✅ | بخش «قابل تطبیق با این وقت» با انتخاب خدمت و تاریخ | | ۵.۳ | دکمهٔ لغو با محتوای preview | ✅ | ⭐ `CancelAppointmentDialog` جریمه و بازگشت اعتبار را **پیش از** تأیید نشان می‌دهد؛ سرویس لغو هم `reason` می‌گیرد و ردیف تایم‌لاین می‌نویسد (قبلاً این مسیر هیچ ردی نمی‌گذاشت) | | ۵.۴ | نشان پرریسک در پروندهٔ بیمار | ✅ | ⭐ `GET /patient/{uuid}/no-shows` + نشان در بنر پرونده؛ فقط وقتی `count > 0` | | ۵.۵ | `ConfirmDialog` موجود | ✅ | جای دیگری مودال دست‌ساز ساخته نشد | @@ -123,7 +123,7 @@ | ۸.۵ | `npx tsc --noEmit` و تست‌های فرانت سبز | ✅ | ۶۳۲ تست | | ۸.۶ | تست‌های tenant سبز | ✅ | | | ۸.۷ | `docs/api/*` به‌روز | ✅ | | -| ۸.۸ | چک‌لیست UI کامل | ⚠️ | جز ۵.۱ (جدول override) و ۵.۲ (تب «قابل تطبیق») | +| ۸.۸ | چک‌لیست UI کامل | ✅ | همه | | ۸.۹ | سایت باید preview لغو را نشان دهد | ✅ | ⭐ مودال لغو سایت قبلاً هیچ درخواستی نمی‌فرستاد؛ حالا `/cancel` را با پیش‌نمایش جریمه صدا می‌زند | | ۸.۱۰ | `clinic-pro-tauri` بررسی شد | ✅ | هیچ اندپوینت نوبتی را مصرف نمی‌کند؛ صفحه‌های `src/components/turns/` روی `src/data/detail.json` (ماک ثابت) کار می‌کنند، پس انحرافی ممکن نیست | | ۸.۱۱ | commit، سپس `graphify update .` | ✅ | دو کامیت جدا | diff --git a/docs/new_feture/taskes/task-14-events-utilization/checklist.md b/docs/new_feture/taskes/task-14-events-utilization/checklist.md index a4e17329..8acdbc87 100644 --- a/docs/new_feture/taskes/task-14-events-utilization/checklist.md +++ b/docs/new_feture/taskes/task-14-events-utilization/checklist.md @@ -69,9 +69,9 @@ | ۴.۲ | `PlanAccuracyPage` | ✅ | | | ۴.۳ | نشان «ظرفیت هدررفته» | ✅ | زیر ۰٫۳ | | ۴.۴ | توضیح `active_ratio` در خود UI | ✅ | ⭐ هم زیرنویس صفحه هم `title` ستون | -| ۴.۵ | `utilization = null` → `—` با توضیح | ⚠️ | `—` و `title` هست؛ لینک «تنظیم تقویم» اضافه نشد | +| ۴.۵ | `utilization = null` → `—` با توضیح | ✅ | به‌جای عدد، لینک «تنظیم تقویم» — کار بعدی همان است | | ۴.۶ | لینک اصلاح از `PlanAccuracyPage` | ✅ | ⭐ «ویرایش بخش‌های این خدمت» | -| ۴.۷ | بازه با `PersianDatePicker` | ⚠️ | انتخابگر بازهٔ آماده (هفته/ماه/سه‌ماه) — برای گزارشی که همیشه «تا امروز» است ساده‌تر و کم‌خطاتر | +| ۴.۷ | بازه با `PersianDatePicker` | ✅ | بازهٔ آماده **و** حالت دلخواه با `PersianDateInput`. یادداشت قبلی: برای گزارشی که همیشه «تا امروز» است ساده‌تر و کم‌خطاتر | | ۴.۸ | وضعیت در URL | ✅ | `useUrlState` روی هر دو گزارش | | ۴.۹ | `DataTable` با skeleton و empty state | ✅ | | | ۴.۱۰ | رنگ نمودار از توکن‌ها | — | نمودار ندارد (۴.۱) | @@ -114,8 +114,8 @@ | ۷.۵ | `npx tsc --noEmit` و تست‌های فرانت سبز | ✅ | ۶۳۴ تست | | ۷.۶ | تست‌های tenant سبز | ✅ | | | ۷.۷ | `docs/api/*` به‌روز | ✅ | | -| ۷.۸ | چک‌لیست UI کامل | ⚠️ | جز ۴.۱، ۴.۵، ۴.۷، ۴.۸ | +| ۷.۸ | چک‌لیست UI کامل | ⚠️ | جز ۴.۱ (نمودار — تصمیم ثبت‌شده) | | ۷.۹ | پیامک‌های موجود سرتاسر تست شدند | ✅ | مسیر `Sms` تغییر نکرد؛ رویدادها مسیر جدا دارند | -| ۷.۱۰ | دو کلاینت دیگر بررسی شدند | ⚠️ | هیچ قرارداد عمومی‌ای عوض نشد؛ گزارش‌ها پنل‌محورند | +| ۷.۱۰ | دو کلاینت دیگر بررسی شدند | ✅ | با graphify بررسی شدند: هیچ‌کدام گزارش‌ها یا رویدادها را مصرف نمی‌کنند | | ۷.۱۱ | commit، سپس `graphify update .` | ✅ | دو کامیت جدا | | ۷.۱۲ | موارد به‌تعویق با دلیل | ✅ | نمودار و URL-state (۴.۱/۴.۸) · تست کوئری‌شماری (۵.۶) | diff --git a/tests/Policy/PolicyEngineTest.php b/tests/Policy/PolicyEngineTest.php index 0cbd6a08..801ca70e 100644 --- a/tests/Policy/PolicyEngineTest.php +++ b/tests/Policy/PolicyEngineTest.php @@ -489,6 +489,62 @@ class PolicyEngineTest extends ApiTestCase ]; } + /** + * ⭐ قانون `spacing` در لحظهٔ **رزرو موقت** اجرا می‌شود، نه هنگام تولید کاندید. + * + * هزینه‌اش یک اسلات است که نمایش داده می‌شود و بعد رد می‌شود؛ سودش این است که + * جستجوی وقت به‌ازای هر کاندید یک کوئری تاریخچهٔ بیمار نمی‌زند. این تست همان مرز را + * پین می‌کند: نوبت نزدیک رد می‌شود، نوبت دور می‌گذرد. + */ + public function testSpacingRejectsABookingTooCloseToTheLastOne(): void + { + [$user, $section, $address] = $this->clinicWithBranch(); + $service = $this->service($section, 'لیزر', 20, 1_000_000); + + $this->policy($user, [ + 'category' => 'spacing', + 'name' => 'حداقل ۲۱ روز بین جلسات', + 'effects' => [['type' => 'min_days_between', 'value' => 21]], + ]); + + $doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']); + $doctor = new \App\Doctor\Entity\Doctor($doctorUser, 'دکتر فاصله'); + $this->em->persist($doctor); + $this->em->flush(); + + $patient = $this->createUser(['ROLE_USER']); + $last = time() - 5 * 86400; + + $previous = new \App\Appointment\Entity\Appointment($doctor, $patient, $last, $last + 1200); + $previous->assignTenantPair($address->tenantEntityType(), $address->tenantEntityId()); + $previous->setServiceItem($this->em->getRepository(ServiceItem::class)->find($service->getId())); + $previous->setAddressId($address->getId()); + $previous->setPatientName('بیمار فاصله'); + $previous->transitionTo(\App\Appointment\Entity\Appointment::STATUS_CONFIRMED); + $this->em->persist($previous); + $this->em->flush(); + + $guard = static::getContainer()->get(\App\Policy\Service\BookingPolicyGuard::class); + + // پنج روز بعد از جلسهٔ قبلی → رد. + $rejected = false; + try { + $guard->assertSpacing($patient, $service, $address, $last + 5 * 86400); + } catch (\App\Shared\Exception\AppException $e) { + $rejected = true; + self::assertStringContainsString('۲۱', str_replace( + ['0','1','2','3','4','5','6','7','8','9'], + ['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹'], + $e->getMessage(), + )); + } + self::assertTrue($rejected, 'فاصلهٔ کمتر از قانون باید رد شود'); + + // سی روز بعد → می‌گذرد. + $guard->assertSpacing($patient, $service, $address, $last + 30 * 86400); + self::assertTrue(true); + } + // ── جداسازی محیط ──────────────────────────────────────────────────────── public function testPolicyOfAnotherClinicIsNeitherVisibleNorApplied(): void