diff --git a/assets/admin/pages/UsersPage.tsx b/assets/admin/pages/UsersPage.tsx index 1187ebb8..cdeb513a 100644 --- a/assets/admin/pages/UsersPage.tsx +++ b/assets/admin/pages/UsersPage.tsx @@ -306,7 +306,7 @@ export default function UsersPage() { setSearchInput(e.target.value)} - placeholder="نام، موبایل یا ایمیل..." + placeholder="شماره کاربری، نام، موبایل یا ایمیل..." />
diff --git a/docs/architecture/online-resource-booking.md b/docs/architecture/online-resource-booking.md new file mode 100644 index 00000000..16b3a9ca --- /dev/null +++ b/docs/architecture/online-resource-booking.md @@ -0,0 +1,250 @@ +# نوبت‌دهی آنلاین منبع‌محور در nobat724 + +> وضعیت: **طرح پیشنهادی** — هنوز پیاده نشده. مرجعِ تصمیم‌گیری پیش از شروع کار. +> دامنه: `clinicpro` (بک‌اند) + `nobat724_front` (سایت عمومی). +> پیش‌نیاز خواندن: [resource-first-model.md](resource-first-model.md) · [docs/api/appointment.md](../api/appointment.md) · [docs/api/resource.md](../api/resource.md) + +--- + +## ۱. صورت مسئله + +نوبت‌دهی منبع‌محور امروز فقط از **پنل** کار می‌کند. بیماری که وارد سایت می‌شود، برای +کلینیکی که در حالت `resource` است یا چیزی نمی‌بیند، یا یک تقویمِ اسلاتیِ بی‌ربط. + +سه حالت نوبت‌دهی در سیستم هست ([WeeklySchedule.php](../../src/Appointment/Entity/WeeklySchedule.php)): + +| حالت | یعنی | سایت امروز؟ | +|---|---|---| +| `slot` | شبکهٔ اسلات ثابت پزشک | ✅ تاریخ → ساعت | +| `service` | طول نوبت از مدت سرویس‌ها | ✅ سرویس → تاریخ → ساعت | +| `resource` | برنامهٔ چندبخشی روی **تقویم منابع** | ❌ هیچ | + +`booking_mode` برای هر محل جدا می‌آید (`GET /api/v1/appointment-booking-locations/{doctorUuid}`) +و سایت آن را از **محل انتخاب‌شده** می‌خواند، نه از پزشک — این قرارداد سرِ جایش می‌ماند و +`resource` فقط حالت سوم همان سوییچ است. + +### چرا نمی‌شود همین موتور را به سایت وصل کرد + +موتور منبع‌محور (`appointment-availability` → `appointment-hold` → `appointment-confirm`) +**ذاتاً پنلی** است، نه فقط «احراز هویت لازم دارد»: + +```php +// AvailabilityController::search() و BookingController::create() +$address = $this->branches->resolve($user, $data['branch_uuid']); // ← محیطِ خودِ کاربر +[$entityType, $entityId] = $this->branches->pair($user); // ← محیطِ خودِ کاربر +``` + +`AddressResolver::pair()` محیط را از **کاربرِ درخواست‌دهنده** می‌گیرد. بیمار هیچ محیطی +ندارد، پس همین حالا هم اگر توکنِ بیمار بفرستیم، آدرس کلینیک «یافت نشد» می‌شود. یعنی +مسئله یک خط `security.yaml` نیست؛ **جهتِ resolve** باید برعکس شود: محیط باید از +**مقصدِ رزرو** (پزشک/کلینیک) بیاید، نه از فرستنده. + +سه گاردِ دیگر هم که مسیرهای عمومی از قبل دارند، در این موتور **اصلاً وجود ندارند** — +چون تا امروز فقط کارمند صدایش می‌زده: + +| گارد | مسیر عمومیِ اسلاتی/سرویسی | موتور منبع‌محور | +|---|---|---| +| `online_booking_enabled` | ✅ [SlotCalculatorService.php:197](../../src/Appointment/Service/SlotCalculatorService.php#L197) | ❌ | +| پنجرهٔ رزرو (`booking_window_*`) | ✅ | ❌ | +| «فقط سرویس‌های bookable» | ✅ | جزئی | +| عدم افشای منابع | — | ❌ پاسخ، نام دستگاه و اپراتور را می‌دهد | + +--- + +## ۲. تصمیم معماری + +سه راه روی میز بود: + +| گزینه | کار | چرا نه / چرا آری | +|---|---|---| +| **الف. مسیر عمومیِ موازی روی همان موتور** | سه اندپوینت عمومی که محیط را از مقصد resolve می‌کنند و گاردهای عمومی را دارند | ✅ **پیشنهادی.** موتور تخصیص، ظرفیت و اتمیک‌بودن یکی می‌ماند؛ فقط لایهٔ ورودی/گارد جدا می‌شود | +| ب. بیمار خودش منبع را انتخاب کند (مثل مودال پنل) | استفادهٔ مستقیم از `resource/{uuid}/service-slots` | ❌ بیمار نباید بین «لیزر CO2 شمارهٔ ۲» و «۳» انتخاب کند؛ این تصمیمِ کلینیک است و افشای ظرفیت داخلی هم هست | +| ج. تنزل `resource` به `service` برای سایت | نادیده گرفتن منابع در مسیر عمومی | ❌ نوبتِ ثبت‌شده منبع نمی‌گیرد، پس پنل و سایت دو حقیقت متفاوت از ظرفیت می‌سازند و دابل‌بوکینگ قطعی است | + +**تصمیم: گزینهٔ الف.** هستهٔ `AvailabilityEngine` / `HoldService` / `BookingService` دست +نمی‌خورد؛ فقط سه کنترلر نازکِ عمومی روی آن می‌نشیند. + +--- + +## ۳. قرارداد API پیشنهادی + +همه زیر `/api/v1/public/...` تا از مسیر پنلی جدا بماند و در `security.yaml` یک‌جا +whitelist شود. احراز هویتِ بیمار برای دو مرحلهٔ آخر لازم است (مثل `POST /api/v1/appointment`). + +### ۳.۱ `GET /api/v1/appointment-booking-services/{doctorUuid}` — بدون تغییر + +از قبل عمومی است و `booking_mode` را می‌دهد. سایت با دیدن `"resource"` جریان تازه را +شروع می‌کند. تنها افزودهٔ لازم: در این حالت هم `services[]` باید سرویس‌های **قابل رزرو +آنلاین** باشند (`ServiceItem.bookable`). + +### ۳.۲ `GET /api/v1/public/resource-availability/month` — تقویم ماه + +``` +?doctor_uuid=…&clinic_uuid=…&service_uuid=…&item_uuids[]=…&year=1405&month=5 +→ { enabled_dates: [...], disabled_dates: [...], online_booking_enabled: true, + booking_window: { value: 3, unit: "month" } } +``` + +عمداً سبک: فقط «این روز ظرفیت دارد یا نه»، بدون ساختِ تخصیص. معادلِ موجودِ پنلی‌اش +`appointment-availability/month` است. + +### ۳.۳ `POST /api/v1/public/resource-availability` — زمان‌های یک روز + +```json +{ "doctor_uuid": "…", "clinic_uuid": "…", "service_uuid": "…", + "item_uuids": ["…"], "date": "2026-08-04", "patient_gender": "woman" } +``` + +پاسخ **بدون** `assignment`: + +```json +{ "plan": { "total_minutes": 75, "segments": [ { "name": "لیزر", "duration_minutes": 50 } ] }, + "slots": [ { "start": 1785220200, "end": 1785224700 } ], + "reason": null } +``` + +> **افشای منابع ممنوع.** پاسخ پنلی `assignment` (نام دستگاه و اپراتور) دارد؛ نسخهٔ +> عمومی فقط زمان می‌دهد. تخصیص سمت سرور در `hold` نگه داشته می‌شود. + +### ۳.۴ `POST /api/v1/public/resource-hold` — نگه‌داشتن موقت (احراز شده) + +ورودی: همان کلیدها + `start`. خروجی: `{ hold_uuid, starts_at, ends_at, expires_at }`. + +تخصیص منبع را **سرور** انتخاب می‌کند (`resource_strategy` محیط: `first_available` / +`least_loaded` / …) و بیمار در آن نقشی ندارد. hold اجباری است: بین دیدن وقت و پرداخت، +صندلی باید قفل شود وگرنه دو بیمار هم‌زمان یک دستگاه را می‌خرند. + +### ۳.۵ `POST /api/v1/public/resource-confirm` — ثبت نهایی (احراز شده) + +ورودی `{ hold_uuid, patient_national_code, patient_gender, … }`. خروجی همان +`appointment_uuid` + `price_snapshot`. نوبت `pending` متولد می‌شود و مسیر پرداخت/بیعانهٔ +موجود دست‌نخورده می‌ماند. + +### ۳.۶ گاردهای مشترکِ هر چهار اندپوینت + +1. `online_booking_enabled` برای همان (پزشک، محیط) — خاموش ⇒ `403` صریح، نه فهرست خالی. +2. پنجرهٔ رزرو `booking_window_value/unit` — تاریخ خارج از پنجره ⇒ `422`. +3. `booking_mode === 'resource'` — در غیر این‌صورت `422` با اشاره به مسیر درست. +4. سرویس باید `bookable` و مالِ همان محیط باشد. +5. محیط از **مقصد** resolve می‌شود: `EntityContext::forBooking($doctor, $clinic)` — نه از کاربر. +6. Rate limit روی `hold` (بیمار می‌تواند با چند hold ظرفیت را قفل کند). + +--- + +## ۴. کلید روشن/خاموش برای مدیر کلینیک + +خواستهٔ صریح: **مدیر کلینیک باید بتواند نوبت‌دهی آنلاین را داشته باشد یا نه.** +این کلید از قبل وجود دارد و لازم نیست چیز تازه‌ای اختراع شود — فقط باید در مسیر +منبع‌محور هم **خوانده** شود. + +### وضع موجود + +| لایه | کجا | +|---|---| +| ذخیره | `WeeklySchedule.meta.online_booking_enabled` per (پزشک، محیط) — [WeeklySchedule.php:50](../../src/Appointment/Entity/WeeklySchedule.php#L50) | +| کنترل در پنل | سوییچ «نوبت‌دهی آنلاین» در [ScheduleSection.tsx:886](../../assets/admin/components/schedule/ScheduleSection.tsx#L886) | +| اعمال در مسیر اسلاتی/سرویسی | [SlotCalculatorService.php:197](../../src/Appointment/Service/SlotCalculatorService.php#L197) و `:315` (فقط وقتی `forManagement` نباشد) | +| اثر روی فهرست‌ها | پزشکِ همه‌خاموش از فهرست عمومی حذف می‌شود — [DoctorRepository.php:98](../../src/Doctor/Repository/DoctorRepository.php#L98) | + +نکتهٔ مهم: خاموش‌بودن آنلاین **هرگز** جلوی ثبت نوبت از پنل را نمی‌گیرد — منشی همچنان +نوبت می‌دهد. همین رفتار باید در حالت منبع‌محور هم عیناً حفظ شود. + +### آنچه باید اضافه شود + +1. **خواندن همان کلید در موتور منبع‌محور.** هر چهار اندپوینت عمومی بند ۳ اول این را + بسنجند. بدون این، عمومی‌کردن موتور یعنی کلیدِ خاموشِ کلینیک بی‌اثر می‌شود — یعنی + نشت رفتار، نه یک نقص کوچک. +2. **سه سطحِ کنترل، از درشت به ریز:** + + | سطح | کلید | وضعیت | + |---|---|---| + | کل محیط/پزشک | `online_booking_enabled` | ✅ هست | + | هر سرویس | `ServiceItem.bookable` («نمایش در نوبت‌دهی») | ✅ هست | + | هر منبع | `ClinicResource.online_bookable` | ⛔ **پیشنهاد فاز ۲** | + + سطح سوم برای دستگاهی است که باید در گردش کار داخلی بماند ولی مستقیم آنلاین فروخته + نشود. تا وقتی نیست، همان `active` تنها اهرم است — و خاموش‌کردنش نوبت‌دهی پنلی را هم + می‌کُشد، که همان چیزی نیست که مدیر می‌خواهد. +3. **پاسخِ «خاموش است» باید صریح باشد.** فهرست خالی، هم بیمار را گیج می‌کند هم + پشتیبانی را. یک `403` با پیام «نوبت‌دهی آنلاین این کلینیک فعال نیست» + پنهان‌کردن + دکمهٔ رزرو در سایت. + +--- + +## ۵. جریان کاربر در سایت + +``` +انتخاب محل (booking_mode از همان محل) + └─ resource ─→ ۱. انتخاب سرویس (یک یا چند) ← appointment-booking-services + ۲. تقویم ماه ← public/resource-availability/month + ۳. زمان‌های روز ← public/resource-availability + ۴. نگه‌داشتن + شمارش معکوس ← public/resource-hold + ۵. مشخصات بیمار و پرداخت ← public/resource-confirm +``` + +نگاشت به کد موجود `nobat724_front`: + +| گام | فایل | کار | +|---|---|---| +| ارکستراسیون | `components/appointment/index.js` | شاخهٔ سوم برای `booking_mode === 'resource'` | +| انتخاب سرویس | `components/appointment/service/` | تقریباً بدون تغییر؛ مدت کل از سرور می‌آید | +| تقویم/ساعت | `components/appointment/date/`, `lib/appointmentSlots.js` | آداپتور سوم `adaptResourceSlots` با همان خروجیِ `{ start_time, end_time, label, slots }` | +| تماس‌ها | `services/response.js` | چهار متد تازه | +| نگه‌داشتن | جدید | شمارش معکوس تا `expires_at`؛ معادلِ `HoldCountdown` پنل | + +قواعدی که همین حالا در `nobat724_front/CLAUDE.md` هست و اینجا هم برقرارند: مدت **دادهٔ +سرور** است و در فرانت جمع زده نمی‌شود؛ مرزهای شیفت در فرانت استنتاج نمی‌شوند. + +رفتارهای لبه‌ای که باید در UI دیده شوند: + +- `409` روی hold ⇒ «این زمان همین لحظه گرفته شد» + رفرش خودکار زمان‌ها. +- انقضای hold پیش از پرداخت ⇒ برگشت به گام ۳ با پیام روشن، نه خطای خام. +- ترک صفحه ⇒ آزادسازی hold (`beforeunload` + TTL سمت سرور به‌عنوان تور ایمنی). + +--- + +## ۶. داده و مهاجرت + +فاز ۱ **هیچ مهاجرتی ندارد**: `resource_occupancy`، `appointment_holds` و +`price_snapshots` از قبل هستند. تنها مهاجرتِ احتمالی، فیلد `online_bookable` روی +`clinic_resources` در فاز ۲ است (پیش‌فرض `true`، عقب‌رو-سازگار). + +یک بدهیِ شناخته‌شده که این کار آن را برجسته می‌کند: نوبت‌های مسیر پنلیِ منبع +(`appointments.resource_id`) ردیف `resource_occupancy` نمی‌سازند، پس موتور آن‌ها را +نمی‌بیند ([appointment.md](../api/appointment.md)). تا وقتی رزرو آنلاین منبع‌محور روشن +نشده این فقط یک ناهماهنگی است؛ **بعد از آن، منبعِ دابل‌بوکینگ می‌شود.** بستنش +پیش‌نیازِ فاز ۱ است، نه کارِ بعدی. + +--- + +## ۷. فازبندی + +| فاز | کار | خروجی | +|---|---|---| +| ۰ | نوشتن occupancy برای نوبت‌های منبعِ پنلی + آزادسازی روی لغو | یک منبعِ حقیقت برای اشغال | +| ۱ | چهار اندپوینت عمومی + گاردها + تست (موفق/خطا/مرزی) + `docs/api/*` | API آمادهٔ مصرف | +| ۲ | سایت: آداپتور، مراحل، شمارش معکوس، حالت‌های لبه | رزرو آنلاین قابل استفاده | +| ۳ | `ClinicResource.online_bookable` + نمایش «اتاق/پزشک» در تأیید نهایی | کنترل ریزتر | + +--- + +## ۸. تصمیم‌های باز + +1. **بیعانه/پرداخت آنلاین:** آیا رزرو منبع‌محور مثل بقیه بیعانه می‌گیرد؟ اگر بله، مهلت + hold باید از مهلت درگاه بیشتر باشد وگرنه بیمار پول می‌دهد و وقت را از دست می‌دهد. +2. **مهلت hold:** مقدار فعلی پنلی برای بیمارِ در حال تایپ کوتاه است. عدد جدا برای مسیر + عمومی؟ +3. **بیمه:** مسیر عمومی امروز بیمه نمی‌گیرد؛ نوبت منبع‌محور فاکتور لحظه‌ای دارد + (`PriceSnapshot`). تکلیف سهم بیمار در سایت باید روشن شود. +4. **چند سرویس در یک نوبت آنلاین:** پنل اجازه می‌دهد؛ سایت هم؟ اگر بله، سقفِ مدت لازم است. + +--- + +## ۹. چک‌لیست پذیرش + +- [ ] کلینیک با `booking_mode = resource` و `online_booking_enabled = true` در سایت قابل رزرو است. +- [ ] خاموش‌کردن سوییچ، بلافاصله رزرو آنلاین را می‌بندد و **پنل دست‌نخورده** کار می‌کند. +- [ ] پاسخ عمومی هیچ نام منبعی افشا نمی‌کند. +- [ ] دو رزرو هم‌زمان روی آخرین ظرفیت ⇒ یکی `409` می‌گیرد، نه هر دو موفق. +- [ ] نوبتِ ثبت‌شده از سایت، در تایم‌لاین منبعِ پنل دیده می‌شود و بالعکس. +- [ ] تاریخ خارج از پنجرهٔ رزرو، در تقویم غیرفعال است. diff --git a/src/Admin/Controller/AdminApiController.php b/src/Admin/Controller/AdminApiController.php index 4afef556..8019cacc 100644 --- a/src/Admin/Controller/AdminApiController.php +++ b/src/Admin/Controller/AdminApiController.php @@ -226,8 +226,20 @@ class AdminApiController extends BaseController ->from(User::class, 'u'); if ($search !== '') { - $qb->andWhere('u.mobileNumber LIKE :s OR u.realName LIKE :s OR u.email LIKE :s') - ->setParameter('s', '%' . $search . '%'); + // Persian/Arabic digits are what an admin actually types into the box; normalise + // them so both the mobile LIKE and the numeric user-id match behave. + $search = InputValidator::toEnglishDigits($search); + + // A digits-only term is ambiguous: it can be a user id (#123) or part of a + // mobile number, so match either. + if (ctype_digit($search)) { + $qb->andWhere('u.id = :sid OR u.mobileNumber LIKE :s OR u.realName LIKE :s OR u.email LIKE :s') + ->setParameter('sid', (int) $search); + } else { + $qb->andWhere('u.mobileNumber LIKE :s OR u.realName LIKE :s OR u.email LIKE :s'); + } + + $qb->setParameter('s', '%' . $search . '%'); } if ($role !== '') { diff --git a/tests/Admin/AdminUsersSearchTest.php b/tests/Admin/AdminUsersSearchTest.php new file mode 100644 index 00000000..dd5f036e --- /dev/null +++ b/tests/Admin/AdminUsersSearchTest.php @@ -0,0 +1,76 @@ + $u['id'], $body['data']); + } + + public function testSearchByUserIdReturnsThatUserOnly(): void + { + $target = $this->createUser(['ROLE_USER']); + $other = $this->createUser(['ROLE_USER']); + $admin = $this->createUser(['ROLE_ADMIN']); + + $body = $this->authJson('GET', '/api/v1/admin/users?search=' . $target->getId(), $admin); + + self::assertSame(200, $this->responseCode()); + self::assertTrue($body['success']); + self::assertContains($target->getId(), $this->idsOf($body)); + self::assertNotContains($other->getId(), $this->idsOf($body)); + } + + public function testSearchByPersianDigitsMatchesUserId(): void + { + $target = $this->createUser(['ROLE_USER']); + $admin = $this->createUser(['ROLE_ADMIN']); + + $persianId = strtr((string) $target->getId(), [ + '0' => '۰', '1' => '۱', '2' => '۲', '3' => '۳', '4' => '۴', + '5' => '۵', '6' => '۶', '7' => '۷', '8' => '۸', '9' => '۹', + ]); + + $body = $this->authJson('GET', '/api/v1/admin/users?search=' . urlencode($persianId), $admin); + + self::assertSame(200, $this->responseCode()); + self::assertContains($target->getId(), $this->idsOf($body)); + } + + public function testSearchByMobileStillWorks(): void + { + $target = $this->createUser(['ROLE_USER']); + $admin = $this->createUser(['ROLE_ADMIN']); + + $body = $this->authJson('GET', '/api/v1/admin/users?search=' . $target->getMobileNumber(), $admin); + + self::assertSame(200, $this->responseCode()); + self::assertContains($target->getId(), $this->idsOf($body)); + } + + public function testSearchByUnknownIdReturnsEmptyList(): void + { + $admin = $this->createUser(['ROLE_ADMIN']); + + $body = $this->authJson('GET', '/api/v1/admin/users?search=999999999', $admin); + + self::assertSame(200, $this->responseCode()); + self::assertSame([], $body['data']); + self::assertSame(0, $body['meta']['totalRecords']); + } + + public function testNonAdminForbidden(): void + { + $user = $this->createUser(['ROLE_USER']); + $this->authJson('GET', '/api/v1/admin/users?search=1', $user); + self::assertSame(403, $this->responseCode()); + } +}