From 7921407f335452416c9bc4cd3afe541f2141a51d Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 18 Jul 2026 21:04:50 +0330 Subject: [PATCH] feat(appointments,patients): make clinic context a first-class citizen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related fixes, all rooted in the same flaw: authorization and scoping decided by the caller's role instead of by the environment the data belongs to. 1. Single-appointment access (clinic operations were entirely broken) AppointmentController::canView/canManage only knew the patient, the owning doctor and admin -- appointment.clinic was never consulted. A clinic user could create an appointment through /my/appointment but got 403 on detail, edit, move, reserve transfer/replace and status change, so nearly every appointment operation failed in clinic mode. AppointmentAccessChecker now decides from appointment.clinic: clinic owner, member doctor (via ClinicDoctorPermissionChecker) and assigned secretary (via active context + DoctorSecretary) are recognised. Actions reuse the existing permission vocabulary, so active=false remains the single source of truth for "collaboration ended". Cancellation is gated separately and an inline status on PATCH /appointment/{uuid} cannot bypass that gate. The patient is narrowed to view + cancel. Also fixed alongside: listByDoctor now serves a clinic manager but scoped to that clinic; todayStats gained an admin branch and no longer passes an array of doctor ids as the clinic parameter; PatientController::appointments filters on appointment.clinic instead of current membership, so deactivating a doctor no longer erases clinic appointment history from the case file. The doctor-only active_slot_key was reviewed and deliberately left alone -- a doctor is one physical person, so adding clinic to the key would permit double-booking, not fix a bug. Reasoning recorded on the entity. 2. Appointment registration and confirmation Panel-created appointments are born pending ("ثبت شده") instead of confirmed. Confirming is now an explicit act: POST /appointment/{uuid}/confirm transitions the status, files the case file for the appointment's environment (reusing an existing record or creating one) and registers full or partial payments on the resulting visit -- all in one transaction. AppointmentExpiryService would have expired those pending appointments the moment their slot time passed; findExpiredPending is now limited to online gateway holds, which are the only pendings carrying a TTL. A pending appointment still occupies its slot, so the time stays reserved. The admin panel gets a "قطعی کردن نوبت" modal showing the visit fee, each selected service, the total, and paid/remaining/status. It is wired inside AppointmentStatusDropdown, so picking "confirmed" anywhere (timeline, detail, reserve list, info modal) goes through it and confirmation can never silently skip the case file and payment. 3. Clinic case-file access PatientRecordScopeResolver replaces the single-destination role mapping: the active context decides, so a doctor invited into a clinic finally sees their patients' records there. A clinic record is per-patient and shared by design, so "their own patients" is derived from appointments with that doctor in that clinic rather than from a new column. Clinic secretaries are limited to their assigned doctors. Read and write share one rule, and out-of-scope records report 404 so other environments are never disclosed. Tests: 29 new cases across the three areas (clinic appointment access, confirm flow, clinic record access). Full suite 466 tests, 2 pre-existing failures unchanged. API docs updated for all three. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/prompt/appointment-confirm-flow.md | 104 ++++++ .../clinic-appointment-operations-fix.md | 96 +++++ .claude/prompt/clinic-record-access.md | 77 ++++ .../appointments/ConfirmAppointmentModal.tsx | 220 ++++++++++++ .../components/appointments/TurnsTimeline.tsx | 17 +- .../ui/AppointmentStatusDropdown.tsx | 28 +- assets/admin/pages/AppointmentCreatePage.tsx | 22 +- assets/admin/pages/AppointmentDetailPage.tsx | 35 +- assets/admin/pages/PatientsListPage.tsx | 13 +- docs/api/appointment.md | 184 +++++++++- docs/api/patient.md | 52 ++- .../Controller/AppointmentController.php | 124 ++++++- .../Controller/MyAppointmentsController.php | 62 ++-- src/Appointment/Entity/Appointment.php | 13 +- .../Repository/AppointmentRepository.php | 34 +- .../Security/AppointmentAccessChecker.php | 135 +++++++ .../AppointmentConfirmationService.php | 64 +++- src/Patient/Controller/PatientController.php | 153 ++++---- .../Repository/PatientRecordRepository.php | 67 +++- src/Patient/Security/PatientRecordScope.php | 59 ++++ .../Security/PatientRecordScopeResolver.php | 115 ++++++ src/Patient/Service/PatientService.php | 16 +- .../AppointmentConfirmFlowTest.php | 302 ++++++++++++++++ .../AppointmentExpiryServiceTest.php | 25 ++ .../ClinicAppointmentAccessTest.php | 329 ++++++++++++++++++ tests/Patient/ClinicRecordAccessTest.php | 251 +++++++++++++ 26 files changed, 2409 insertions(+), 188 deletions(-) create mode 100644 .claude/prompt/appointment-confirm-flow.md create mode 100644 .claude/prompt/clinic-appointment-operations-fix.md create mode 100644 .claude/prompt/clinic-record-access.md create mode 100644 assets/admin/components/appointments/ConfirmAppointmentModal.tsx create mode 100644 src/Appointment/Security/AppointmentAccessChecker.php create mode 100644 src/Patient/Security/PatientRecordScope.php create mode 100644 src/Patient/Security/PatientRecordScopeResolver.php create mode 100644 tests/Appointment/AppointmentConfirmFlowTest.php create mode 100644 tests/Appointment/ClinicAppointmentAccessTest.php create mode 100644 tests/Patient/ClinicRecordAccessTest.php diff --git a/.claude/prompt/appointment-confirm-flow.md b/.claude/prompt/appointment-confirm-flow.md new file mode 100644 index 00000000..adb4c0f5 --- /dev/null +++ b/.claude/prompt/appointment-confirm-flow.md @@ -0,0 +1,104 @@ +# فرآیند ثبت و قطعی کردن نوبت (مودال پرداخت + پرونده) + +## پروژه + +`clinicpro` (backend + پنل ادمین) — **پیش‌نیاز:** `clinic-appointment-operations-fix.md` اجرا شده باشد. + +## زمینه + +وضعیت‌ها همین حالا وجود دارند: `pending` = «ثبت شده»، `confirmed` = «قطعی شده» (`turnStatus.ts`). زیرساخت پرونده هم هست: با confirm شدن نوبت، `AppointmentConfirmationService::onConfirmed` → `PatientService::autoCreateOnAppointmentConfirm` پرونده را بر اساس محیط (`clinic` اگر `appointment.getClinic()!==null` وگرنه `doctor`) **پیدا یا ایجاد** می‌کند و session با قیمت ویزیت + سرویس‌ها می‌سازد — یعنی الزام «پرونده موجود استفاده شود / نبود ساخته شود» از قبل پیاده است. پرداخت چندبخشی هم روی session موجود است (`SessionPayment`، متدهای `wallet/pos/cash/card`). + +آنچه کم است: (۱) نوبت پنلی الان مستقیم `confirmed` ساخته می‌شود؛ (۲) دکمه/مودال «قطعی کردن نوبت» با نمایش هزینه‌ها و پرداخت کامل/جزئی وجود ندارد؛ (۳) ثبت پرداخت‌ها هنگام قطعی شدن در پرونده انجام نمی‌شود. + +## مشکل / هدف + +1. هر نوبت (آنلاین، سریع، عادی) با وضعیت اولیه «ثبت‌شده» (`pending`) ایجاد شود. +2. روی کارت نوبت‌های `pending` در Timeline دکمه «قطعی کردن نوبت» باشد. +3. کلیک → مودال: مبلغ ویزیت + هزینه سرویس‌های انتخاب‌شده، پرداخت کامل یا جزئی، نمایش شفاف پرداخت‌شده/باقی‌مانده/وضعیت پرداخت. +4. تأیید مودال → وضعیت `confirmed` + ثبت سرویس‌ها و پرداخت‌ها در پرونده (موجود یا جدید) نزد همان محیط. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Appointment/Entity/Appointment.php` | وضعیت‌ها (~25-33)، `ALLOWED_TRANSITIONS` (~37-42)، `visitPriceRials`، `serviceItems` | +| `src/Appointment/Controller/MyAppointmentsController.php` | ساخت پنلی — الان `confirmed` می‌گذارد (~192) | +| `src/Appointment/Controller/AppointmentController.php` | `PATCH .../status` (~850)؛ endpoint جدید confirm اینجا یا کنارش | +| `src/Appointment/Repository/AppointmentRepository.php` | `expireLapsedPending` (~154) — TTL پانزده‌دقیقه‌ای pending | +| `src/Appointment/Service/AppointmentConfirmationService.php` | `onConfirmed` (~30) — نقطه واحد confirm | +| `src/Patient/Service/PatientService.php` | `autoCreateOnAppointmentConfirm` (~133)، `addSessionPayment` (~552) | +| `src/Payment/Service/PaymentManager.php` | مسیر آنلاین: بعد از پرداخت درگاه → `confirmed` (~306-315) — دست نزن | +| `assets/admin/components/appointments/TurnsTimeline.tsx` | کارت‌ها (`OccupiedCard` ~100) | +| `assets/admin/components/appointments/turnStatus.ts` | لیبل‌ها (pending=«ثبت شده») | +| `assets/admin/components/ui/AppointmentStatusDropdown.tsx` | `TRANSITIONS` + `PATCH status {status, version}` | +| `assets/admin/components/session/PaymentStep.tsx` | الگوی پرداخت جزئی (`METHODS`, `METHOD_LABELS`, `PriceInput`, toman→rial) | +| `assets/admin/components/ui/Modal.tsx`, `ConfirmDialog.tsx` | پایه مودال | +| `assets/admin/pages/AppointmentCreatePage.tsx` | گزینه‌های status هنگام ساخت (~496) | + +## وضعیت فعلی + +```php +// MyAppointmentsController (~192): نوبت پنلی بلافاصله confirmed +$appointment->setStatus(Appointment::STATUS_CONFIRMED); + +// PaymentManager (~313): نوبت سایت بعد از پرداخت درگاه confirmed می‌شود (درست است، حفظ شود) +// AppointmentRepository::expireLapsedPending: pending های کهنه را expire می‌کند (TTL رزرو آنلاین ۱۵ دقیقه) +``` + +```tsx +// AppointmentStatusDropdown (~74): تنها مسیر فعلی قطعی‌کردن — بدون پرداخت/پرونده +api.patch(`/api/v1/appointment/${uuid}/status`, { status: newStatus, version }) +``` + +## وظایف + +### ۱. Backend — ساخت پنلی با وضعیت `pending` بدون انقضا + +- در `MyAppointmentsController::create` وضعیت اولیه را `STATUS_PENDING` کن (نوبت سریع و عادی). +- **حیاتی:** `expireLapsedPending` نباید نوبت‌های پنلی را بعد از ۱۵ دقیقه منقضی کند. مکانیزم تفکیک اضافه کن — مثلاً فیلد/فلگ `source`/`hold_expires_at` روی Appointment (migration) یا شرط «pending فقط وقتی expire شود که از مسیر رزرو آنلاین با TTL ساخته شده». مسیر آنلاین (POST `/api/v1/appointment` عمومی) رفتار فعلی‌اش (pending با TTL تا پرداخت درگاه) را حفظ کند. +- گذار `pending → confirmed` از قبل در `ALLOWED_TRANSITIONS` مجاز است — دست نزن. + +### ۲. Backend — endpoint قطعی‌کردن اتمیک + +`POST /api/v1/appointment/{uuid}/confirm` بساز (در `AppointmentController`، با `canManage` از checker پرامپت قبلی): + +```php +// Request: +// { "version": 3, "payments": [ { "method": "cash|pos|card|wallet", "amount_rials": 500000 } ], "discount"?: ... } +// در یک تراکنش: +// 1) transitionTo(STATUS_CONFIRMED) → از canTransitionTo عبور کند +// 2) AppointmentConfirmationService::onConfirmed($appointment) → record/session (منطق موجود reuse/create) +// 3) session ساخته/یافته‌شده را بگیر و هر payment را با PatientService::addSessionPayment ثبت کن +// Response: success + { appointment: {...}, session: { uuid, final_price_rials, paid_total_rials, remaining_rials, is_paid } } +``` + +- `payments` می‌تواند خالی باشد (قطعی بدون پرداخت) یا جزئی — جمع نباید از مبلغ قابل‌پرداخت بیشتر شود (خطای موجود `ERR_SESSION_PAYMENT_EXCEEDS` reuse شود). +- `autoCreateOnAppointmentConfirm` الان خطا را قورت می‌دهد (log-only). برای این endpoint نباید silent باشد: اگر پرونده/سرویس‌ها ساخته نشد (مثلاً feature اشتراک `patient_records` فعال نیست)، پاسخ باید صریح بگوید (confirm موفق ولی `session: null` + پیام، یا خطای کامل — تصمیم را مستند کن). +- endpoint یک GET پیش‌نمایش هم لازم دارد یا همان detail کافی است: مودال باید مبلغ ویزیت (`visit_price_rials`) + سرویس‌های نوبت (`serviceItems` با قیمت) را قبل از تأیید نشان دهد — اگر detail فعلی قیمت آیتم‌ها را نمی‌دهد، به پاسخ detail اضافه کن. + +### ۳. Frontend — دکمه و مودال «قطعی کردن نوبت» + +- در `TurnsTimeline.tsx` روی `OccupiedCard` وقتی `a.status === 'pending'` دکمه «قطعی کردن نوبت» اضافه کن (کنار کلاستر dropdown/menu، با `stopPropagation`). +- مودال جدید `components/appointments/ConfirmAppointmentModal.tsx` بر پایه `Modal` (نه ConfirmDialog — فرم دارد): + - بخش هزینه‌ها: ردیف «ویزیت» + ردیف هر سرویس انتخاب‌شده + جمع کل (`formatRial`، نمایش تومان مثل `PaymentStep`). + - بخش پرداخت: همان الگوی `PaymentStep` — روش‌ها (`METHODS`/`METHOD_LABELS`)، `PriceInput` تومان، امکان چند ردیف پرداخت یا یک ردیف با مبلغ دلخواه؛ دکمه میان‌بر «پرداخت کامل». + - خلاصه شفاف: پرداخت‌شده / باقی‌مانده / وضعیت (تسویه کامل، پرداخت جزئی، بدون پرداخت). + - تأیید → `POST /api/v1/appointment/${uuid}/confirm` با `version`؛ بعد `invalidateQueries({ queryKey })`؛ toast موفقیت با sonner؛ خطای 409 نسخه با پیام فارسی. +- همین دکمه/مودال را در `AppointmentDetailPage`، `ReserveAppointmentsPage` (ردیف‌های pending) و `AppointmentInfoModal` هم در دسترس بگذار. +- در `AppointmentStatusDropdown`، انتخاب مستقیم `confirmed` از dropdown باید همین مودال را باز کند (نه PATCH خام) تا مسیر دورزدن پرداخت/پرونده نماند — یا حداقل بعد از PATCH خام هم `onConfirmed` سمت سرور اجرا می‌شود (الان می‌شود؛ ولی بدون پرداخت). تصمیم UX: dropdown → مودال. مستند کن. +- `AppointmentCreatePage` (~496): پیش‌فرض ساخت را «ثبت شده» بگذار؛ گزینه ساخت مستقیم confirmed را بردار یا به مودال وصل کن. + +### ۴. تست و مستندات + +- سناریوها: قطعی با پرداخت کامل / جزئی / بدون پرداخت؛ بیمار با پرونده قبلی نزد همان پزشک (reuse — session جدید در همان پرونده) و بیمار بدون پرونده (create)؛ همین دو حالت در محیط کلینیک (`entityType=clinic`) با کاربر `09024206041` و در مطب شخصی با کاربر پزشک از `TEST_USERS.md`. +- رزرو آنلاین سایت: بدون رگرسیون — pending تا پرداخت درگاه، بعد confirmed + پرونده (مسیر `PaymentManager` دست‌نخورده). +- نوبت‌های `is_reserve` مثل قبل از `onConfirmed` رد می‌شوند (خط ~33) — دکمه قطعی‌کردن برای ردیف رزرو روزانه بعد از انتقال به slot معنا پیدا می‌کند. +- `docs/api/*`: endpoint جدید confirm + تغییر رفتار create مستند شود. + +## نکات مهم + +- تاریخ‌ها Unix timestamp؛ نمایش شمسی با `formatDate()`. مبالغ backend ریال، ورودی UI تومان (`tomanToRial`). +- Optimistic lock: هر mutation نوبت `version` می‌خواهد؛ فراموشش نکن (AppointmentDetailPage الان status را بدون version می‌فرستد — همان‌جا هم اصلاح کن). +- envelope پاسخ: single ممکن است double-nested باشد (`data?.data?.data`) — الگوی صفحات موجود را نگاه کن. +- لیبل‌های فارسی موجود را تغییر نده: `pending`=«ثبت شده»، `confirmed`=«قطعی شده». دو map وضعیت موازی هست (`turnStatus.ts` و `AppointmentStatusDropdown.STATUS_META`) — اگر دست زدی هر دو را همگام نگه دار. +- کامپوننت انتخاب‌ها فقط `SearchableSelect`؛ طراحی مودال با تم/کلاس‌های موجود پنل، بدون طراحی جدید. diff --git a/.claude/prompt/clinic-appointment-operations-fix.md b/.claude/prompt/clinic-appointment-operations-fix.md new file mode 100644 index 00000000..dac181fb --- /dev/null +++ b/.claude/prompt/clinic-appointment-operations-fix.md @@ -0,0 +1,96 @@ +# رفع کامل عملیات نوبت در حالت کلینیک (context / permissions) + +## پروژه + +`clinicpro` (backend + پنل ادمین) + +## زمینه + +در حالت کلینیک تقریباً هیچ‌یک از عملیات نوبت کار نمی‌کند. کاربر تست کلینیک: نام کاربری `09024206041` / رمز `09024206041` (بعد از ریست دیتابیس: `ddev exec php create_test_users.php`). + +ریشه‌یابی انجام شده: مسیر **نوشتن** نوبت (`MyAppointmentsController`) کلینیک را می‌فهمد، اما مسیر **خواندن/تغییر تک‌نوبت** (`AppointmentController`) فقط بیمار، پزشکِ مالک و ادمین را می‌شناسد. نتیجه: کاربر کلینیک نوبت می‌سازد ولی روی `GET /appointment/{uuid}`، `PATCH /appointment/{uuid}`، `PATCH /appointment/{uuid}/status` و `GET /appointment/{uuid}/events` خطای 403 می‌گیرد — یعنی ویرایش، جابه‌جایی، انتقال/جایگزینی رزرو، تغییر وضعیت و مشاهده جزئیات همگی می‌شکنند. + +## مشکل / هدف + +تمام عملیات زیر باید در حالت کلینیک (مدیر کلینیک + منشی کلینیک) بدون خطا و مطابق منطق دسترسی کار کند: + +- ویرایش نوبت، ثبت سرویس برای نوبت، مشاهده جزئیات، جابه‌جایی، انتقال به لیست رزرو، جایگزینی از لیست رزرو، تغییر وضعیت (همه وضعیت‌ها). + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Appointment/Controller/AppointmentController.php` | `canView`/`canManage` (خطوط ~686-698)، endpoint های detail/status/update/events | +| `src/Appointment/Controller/MyAppointmentsController.php` | لیست role-scoped، `canBookForDoctor` (~460)، `todayStats` (~385) | +| `src/Appointment/Repository/AppointmentRepository.php` | کوئری‌های slot فقط بر اساس doctor (~76, 122-172) | +| `src/Appointment/Entity/Appointment.php` | `refreshActiveSlotKey` (~204-211) — کلید slot بدون clinic | +| `src/Shared/Context/EntityContextResolver.php` | resolver کانتکست (`canActInClinic` خط ~68) | +| `src/Clinic/Security/ClinicDoctorPermissionChecker.php` | مجوزهای پزشکِ عضو کلینیک | +| `src/Secretary/Security/SecretaryPermissionChecker.php` + `src/Secretary/Entity/DoctorSecretary.php` | مجوز منشی (`active`، `OWNER_CLINIC`) | +| `src/Patient/Controller/PatientController.php` | `resolveEntity` (~1186)، `appointments` (~940, ~955) | +| `assets/admin/components/AppointmentActions.tsx` | منوی عملیات + مودال‌های move/transfer/replace + `findRecordUuid` | +| `assets/admin/components/ui/AppointmentStatusDropdown.tsx` | تغییر وضعیت (`PATCH .../status` با `version`) | +| `assets/admin/pages/AppointmentsPage.tsx`, `ReserveAppointmentsPage.tsx`, `AppointmentEditPage.tsx`, `AppointmentDetailPage.tsx` | صفحات مصرف‌کننده | + +## وضعیت فعلی + +`AppointmentController` (~686-698) — کلینیک و منشی اصلاً بررسی نمی‌شوند: + +```php +// canView/canManage: فقط بیمار (user)، پزشک مالک (doctor->getUser()) و ROLE_ADMIN. +// appointment->getClinic() هیچ‌جا چک نمی‌شود. +``` + +سایر ناهماهنگی‌های تأییدشده: + +1. `AppointmentController::listByDoctor` (~626): فقط پزشکِ مالک یا ادمین — مدیر کلینیک برای پزشک عضو 403 می‌گیرد. +2. `PatientController::appointments` (~940): برای کلینیک از `acceptedDoctorIdsByClinic` استفاده می‌کند؛ اگر عضویت پزشک غیرفعال شود، نوبت‌های کلینیکیِ ثبت‌شده با `appointment.clinic_id` از پرونده «گم» می‌شوند — باید بر اساس `appointment.clinic` کوئری شود نه عضویت فعلی. +3. `todayStats` (~385): بدون شاخه ADMIN و بدون گیت `canView` منشی — ناهماهنگ با `myAppointments`. +4. کلید یکتای slot: `sprintf('%d:%d', doctorId, slotStart)` — clinic در کلید نیست؛ `isSlotTaken`/`occupiedIntervals`/`bookAtomically` همه فقط `a.doctor` را فیلتر می‌کنند. پزشکی که همزمان مطب شخصی و کلینیک دارد، رزرو در یک محیط، محیط دیگر را می‌بندد. +5. دو سبک موازی authorization: `PatientController::resolveEntity` از `UserActiveContext` می‌خواند ولی `MyAppointmentsController` شاخه‌بندی role دارد — رفتار منشی بین این دو ناسازگار است. + +## وظایف + +### ۱. تمرکز authorization تک‌نوبت در یک سرویس + +یک سرویس واحد (مثلاً `src/Appointment/Security/AppointmentAccessChecker.php`) بساز با دو متد `canView(User, Appointment)` و `canManage(User, Appointment)` و در هر ۴ endpoint تک‌نوبت (`detail`, `update`, `status`, `events`) جایگزین چک‌های فعلی کن. منطق: + +- ادمین: همیشه مجاز. +- بیمار (`appointment.user`): فقط `canView` + لغو خودش (رفتار فعلی حفظ شود). +- پزشک مالک (`appointment.doctor.user`): مجاز. +- **مدیر کلینیک**: اگر `appointment.getClinic() !== null` و کاربر مالک همان کلینیک است → مجاز (view + manage). +- **پزشک عضو کلینیک**: اگر نوبت کلینیکی است و پزشک عضو همان کلینیک است → از `ClinicDoctorPermissionChecker::can(user, clinic, 'appointments', action)` عبور کند (که `active=false` را خودش رد می‌کند). +- **منشی**: از `UserActiveContext` (مثل `PatientController::resolveEntity`) scope را دربیاور؛ اگر scope کلینیک است، نوبت باید متعلق به همان کلینیک و پزشکِ نوبت جزو پزشکان محول‌شده به منشی باشد؛ اگر scope پزشک است، `appointment.doctor` باید همان پزشک باشد. سپس `SecretaryPermissionChecker::can` با action مناسب (`edit`/`cancel`/`view`). + +### ۲. رفع `listByDoctor` و `todayStats` + +- `listByDoctor`: به مدیر کلینیک اجازه بده لیست نوبت‌های پزشکِ عضو را ببیند — اما فقط نوبت‌های همان کلینیک (`a.clinic = :clinic`). +- `todayStats`: شاخه ADMIN و گیت `canView` منشی را هم‌ارز `myAppointments` اضافه کن؛ برای کاربر بدون role معتبر، خروجی صفر/403 بده نه شمارش unscoped. + +### ۳. رفع کوئری نوبت‌های پرونده + +در `PatientController::appointments` شاخه کلینیک را از «doctorIds عضو فعلی» به فیلتر مستقیم `a.clinic = :clinicId` تغییر بده تا با غیرفعال شدن پزشک، تاریخچه نوبت‌های کلینیک از پرونده حذف نشود. + +### ۴. کلید slot با محیط (clinic) + +`refreshActiveSlotKey` را به `doctorId:clinicIdOrZero:slotStart` تغییر بده و `isSlotTaken`/`occupiedIntervals`/`expireLapsedPending`/`bookAtomically` را clinic-aware کن (پارامتر nullable clinic؛ `IS NULL` برای مطب شخصی). **migration لازم است** (تغییر مقدار ستون + بازتولید کلیدهای فعال موجود در migration data step). دقت: اگر منطق فعلی عمداً تداخل بین‌محیطی را می‌بندد (پزشک فیزیکی یک نفر است)، این وظیفه را با بررسی تنظیمات زمان‌بندی (schedule هر محیط جدا است یا نه) تأیید کن — اگر schedule ها ذاتاً غیرهمپوشان‌اند، فقط مستند کن و تغییر نده. + +### ۵. تست end-to-end با کاربر کلینیک + +با `09024206041` (و طبق `TEST_USERS.md` برای منشی/پزشک عضو) از طریق API یا پنل، تک‌تک این سناریوها را اجرا و سبز کن: + +- ساخت نوبت پنل → مشاهده جزئیات → ویرایش (زمان/سرویس/یادداشت) → جابه‌جایی slot → انتقال به رزرو (`is_reserve:true`) → بازگشت از رزرو → جایگزینی بیمار → تمام گذارهای وضعیت مجاز (`ALLOWED_TRANSITIONS`). +- «ثبت سرویس برای نوبت» (منوی عملیات → `findRecordUuid` → `/admin/patients/{recordUuid}/session/new`): بررسی کن `GET /api/v1/patient?search=` در حالت کلینیک پرونده درست (entityType=clinic) را برمی‌گرداند و اگر پرونده وجود ندارد، فرانت پیام مناسب بدهد (نه crash). +- همه با پاسخ envelope استاندارد `BaseController` (`success`/`error`) و کد خطای معنادار، نه 500. + +### ۶. فرانت: حذف فرض‌های doctor-only + +بعد از باز شدن backend، بررسی کن صفحات clinic-mode چیز دیگری نمی‌شکنند: `AppointmentsPage` (در clinic mode «dbUuid = clinic id» است و doctor از `doctorUuid` جدا می‌آید)، مودال‌های `AppointmentActions` همه `version` را می‌فرستند (optimistic lock)، و خطای 409 نسخه با پیام فارسی مناسب toast شود. + +## نکات مهم + +- همه controller ها از `BaseController` ارث می‌برند؛ پاسخ‌ها فقط با `$this->success()/error()/paginated()`. +- **Voter وجود ندارد** — الگوی پروژه سرویس‌های checker است؛ همین الگو را ادامه بده، Voter جدید معرفی نکن. +- `ClinicDoctorPermission.can()` و `SecretaryPermissionChecker` هر دو `active=false` را رد می‌کنند — منبع حقیقتِ «پایان همکاری» همین است؛ چک موازی دستی ننویس. +- تغییر Entity ⇒ migration؛ تغییر هر endpoint ⇒ به‌روزرسانی `docs/api/*` در همین سشن. +- این پرامپت پیش‌نیاز `appointment-confirm-flow.md` است (دکمه قطعی‌کردن در حالت کلینیک به همین `canManage` تکیه دارد). diff --git a/.claude/prompt/clinic-record-access.md b/.claude/prompt/clinic-record-access.md new file mode 100644 index 00000000..087fa779 --- /dev/null +++ b/.claude/prompt/clinic-record-access.md @@ -0,0 +1,77 @@ +# دسترسی پزشک و مدیر کلینیک به پرونده‌های کلینیک + +## پروژه + +`clinicpro` (backend + پنل ادمین) — بعد از `clinic-appointment-operations-fix.md` و `appointment-confirm-flow.md` اجرا شود. + +## زمینه + +پرونده‌ها per-محیط silo شده‌اند: `PatientRecord` مالک چندریختی دارد — `entityType` (`doctor|clinic|system`) + `entityId` با یکتایی `(entity_type, entity_id, user_id)`. `PatientController::resolveEntity` (~1186) هر کاربر را به **یک** scope نگاشت می‌کند (پزشک → پرونده‌های شخصی خودش، کلینیک → پرونده‌های کلینیک) و `ownsRecord()` (~1232) تساوی دقیق می‌سنجد. نتیجه فعلی: پزشکِ دعوت‌شده به کلینیک، پرونده‌های بیمارانش **در آن کلینیک** را نمی‌بیند (فقط مدیر کلینیک می‌بیند). + +## مشکل / هدف + +- پزشک عضو کلینیک و مدیر کلینیک هر دو به پرونده‌های بیماران آن پزشک در آن کلینیک دسترسی داشته باشند (مشاهده + مدیریت). +- تا وقتی پزشک در کلینیک فعال است (`ClinicDoctorPermission.active` / عضویت)، همه پرونده‌های مرتبطش در آن کلینیک برایش قابل مشاهده/مدیریت باشد. +- با پایان همکاری یا غیرفعال شدن، دسترسی پزشک طبق سطح دسترسی سیستم محدود/قطع شود؛ مدیر کلینیک دسترسی کامل بماند. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Patient/Controller/PatientController.php` | `resolveEntity` (~1186)، `ownsRecord` (~1221-1240)، همه endpoint های پرونده/session/پرداخت | +| `src/Patient/Entity/PatientRecord.php` | مالک چندریختی، بدون FK پزشک | +| `src/Patient/Entity/PatientSession.php` | `appointment` nullable → پل به `appointment.doctor` | +| `src/Patient/Service/PatientService.php` | ساخت پرونده/session (`autoCreateForEntity` ~144) | +| `src/Clinic/Security/ClinicDoctorPermissionChecker.php` | `can(user, clinic, 'patients', action)` — `active=false` را رد می‌کند | +| `src/Clinic/Entity/ClinicDoctorPermission.php` | resource `patients` در `DEFAULT_PERMISSIONS` | +| `src/Shared/Context/EntityContextResolver.php` | کانتکست فعال (پزشکی که داخل کلینیک سوییچ کرده) | +| `assets/admin/pages/MyPatientsPage.tsx`, `PatientsListPage.tsx`, `PatientDetailPage.tsx` | UI پرونده‌ها | +| `assets/admin/stores/authStore.ts`, `hooks/useClinicContext.ts` | کانتکست SPA (`switchContext`) | + +## وضعیت فعلی + +```php +// PatientController::resolveEntity — نگاشت تک‌مقصدی: +// ROLE_DOCTOR → ['doctor', doctorId] // فقط پرونده‌های مطب شخصی +// ROLE_CLINIC → ['clinic', clinicId] // فقط پرونده‌های کلینیک +// ROLE_SECRETARY → از UserActiveContext + +// ownsRecord(): record.entityType === entityType && record.entityId === entityId +``` + +نکته کلیدی مدل: پرونده کلینیکی per-بیمار است نه per-پزشک (unique روی clinic+user). «پرونده‌های بیماران آن پزشک» یعنی پرونده‌های کلینیکی‌ای که بیمارشان با آن پزشک session/نوبت داشته — از مسیر `PatientSession.appointment.doctor` (و برای سشن‌های دستی `createdByType/createdById`) قابل استخراج است. + +## وظایف + +### ۱. Backend — دسترسی پزشک به پرونده‌های کلینیک + +`resolveEntity`/`ownsRecord` را از نگاشت تک‌مقصدی به مدل «scope فعال + عضویت» ارتقا بده: + +- وقتی پزشک با `UserActiveContext` روی کانتکست کلینیک است (SPA با `switchContext` این را ست می‌کند)، scope پرونده = `['clinic', clinicId]` **مشروط به** `ClinicDoctorPermissionChecker::can(user, clinic, 'patients', action)` — که خودش عضویت غیرفعال را رد می‌کند. این الزام «قطع دسترسی بعد از پایان همکاری» را بدون منطق جدید برآورده می‌کند. +- محدودسازی به «بیماران آن پزشک»: در لیست پرونده‌ها (`GET /api/v1/patient(s)`) وقتی scope=clinic و کاربر پزشک عضو است (نه مدیر)، فیلتر کن به پرونده‌هایی که حداقل یک session با `appointment.doctor = doctor` یا `createdByType='doctor' AND createdById=doctorId` دارند (subquery/EXISTS در repository). مدیر کلینیک بدون این فیلتر، همه را می‌بیند. +- دسترسی تک‌پرونده (detail/session/پرداخت/یادداشت/پیوست): همان قاعده — مدیر کلینیک کامل؛ پزشک عضو فعال فقط اگر پرونده طبق فیلتر بالا «مالِ بیماران خودش» باشد. تصمیم باز که باید حین اجرا گرفته و مستند شود: آیا پزشک به کل پرونده مشترک بیمار (شامل session های پزشک دیگر همان کلینیک) دید دارد یا فقط session های خودش؟ پیش‌فرض پیشنهادی: دید کامل به پرونده، مدیریت فقط روی session های خودش. +- `assertPatientGate` (feature اشتراک `patient_records`) سر جای خودش بماند — گیت اشتراک باید بر اساس محیط کلینیک چک شود نه اشتراک شخصی پزشک. + +### ۲. Backend — منشی + +منشی کلینیک (`DoctorSecretary` با `OWNER_CLINIC` و `active`) طبق همان الگو: scope کلینیک + محدود به پزشکان محول‌شده + `SecretaryPermissionChecker::can(..., 'patients', ...)`. رفتار فعلی منشی نباید پس‌رفت کند. + +### ۳. Frontend — نمایش پرونده‌های کلینیک برای پزشک + +- وقتی پزشک کانتکست کلینیک را انتخاب کرده (`useClinicContext` مقدار دارد)، `MyPatientsPage`/`PatientsListPage` باید پرونده‌های کلینیکِ scope شده را نشان دهند — احتمالاً بدون تغییر فرانت کار می‌کند چون scope سمت سرور است؛ تست کن و فقط اگر endpoint/پارامتر جدید لازم شد دست بزن. +- حالت خطای «دسترسی قطع شده» (پزشک غیرفعال‌شده): پیام فارسی روشن، نه صفحه خالی. + +### ۴. تست + +- پزشک عضو فعال در کلینیک `09024206041` (طبق `TEST_USERS.md` بساز/استفاده کن): در کانتکست کلینیک پرونده بیمارانش را می‌بیند و session/پرداخت ثبت می‌کند؛ در کانتکست شخصی فقط پرونده‌های مطب خودش. +- مدیر کلینیک: همه پرونده‌های کلینیک، قبل و بعد از غیرفعال‌سازی پزشک. +- پزشک را غیرفعال کن (`ClinicDoctorPermission.active=false`): پزشک 403/فیلتر می‌شود، مدیر همچنان کامل؛ پرونده‌ها و تاریخچه دست‌نخورده می‌مانند (هیچ حذف/انتقالی رخ نمی‌دهد). +- قطعی‌کردن نوبت کلینیکی توسط پزشک عضو (خروجی پرامپت قبلی) پرونده را در محیط کلینیک می‌سازد و همان پرونده برای هر دو نقش دیده می‌شود. +- `docs/api/*` برای هر endpoint تغییرکرده به‌روز شود. + +## نکات مهم + +- **FK پزشک به `PatientRecord` اضافه نکن** — یکتایی `(clinic, user)` عمداً پرونده مشترک کلینیکی است؛ ارتباط پزشک از مسیر session/appointment استخراج می‌شود. +- Voter نساز؛ الگوی checker service موجود. +- لیست‌ها با DQL array hydration (`getArrayResult`)؛ فیلتر EXISTS را در repository اضافه کن نه در PHP. +- اگر schema تغییر کرد (بعید، ولی مثلاً index برای کوئری EXISTS): migration. diff --git a/assets/admin/components/appointments/ConfirmAppointmentModal.tsx b/assets/admin/components/appointments/ConfirmAppointmentModal.tsx new file mode 100644 index 00000000..831c286e --- /dev/null +++ b/assets/admin/components/appointments/ConfirmAppointmentModal.tsx @@ -0,0 +1,220 @@ +import { useMemo, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { api } from '../../lib/api'; +import type { ApiResponse } from '../../lib/api'; +import { formatRial, rialToToman, tomanToRial } from '../../lib/utils'; +import Modal from '../ui/Modal'; +import PriceInput from '../ui/PriceInput'; +import SearchableSelect from '../ui/SearchableSelect'; + +/** همان چهار روشِ SessionPayment::METHODS در بک‌اند. */ +const METHOD_OPTIONS = [ + { value: 'cash', label: 'پرداخت نقدی' }, + { value: 'pos', label: 'پرداخت از طریق کارت خوان' }, + { value: 'card', label: 'کارت به کارت' }, + { value: 'wallet', label: 'پرداخت از طریق کیف پول' }, +]; + +interface ServiceItem { + uuid: string; + name: string; + price_rials?: number | null; +} + +interface AppointmentLike { + uuid: string; + version?: number; + visit_price_rials?: number | null; + service_items?: ServiceItem[] | null; + patient_name?: string | null; +} + +interface Props { + open: boolean; + appointmentUuid: string; + /** اگر صفحه از قبل نوبت را دارد، پاس بده تا درخواست اضافه نرود. */ + appointment?: AppointmentLike | null; + onClose: () => void; + /** کلید کوئریِ لیستی که بعد از قطعی‌شدن باید invalidate شود. */ + queryKey?: unknown[]; +} + +const rowStyle: React.CSSProperties = { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: '10px 0', + borderBottom: '1px solid var(--border)', +}; + +/** + * «قطعی کردن نوبت» — هزینه‌های نوبت را نشان می‌دهد، پرداخت کامل یا جزئی می‌گیرد و + * نوبت را از «ثبت شده» به «قطعی شده» می‌برد. + * + * سرور همین یک درخواست را اتمیک انجام می‌دهد: وضعیت + پرونده/مراجعه + پرداخت‌ها. + */ +export default function ConfirmAppointmentModal({ + open, + appointmentUuid, + appointment, + onClose, + queryKey, +}: Props) { + const qc = useQueryClient(); + const [method, setMethod] = useState('cash'); + const [amountToman, setAmountToman] = useState(0); + + // وقتی صفحه‌ی میزبان نوبت را ندارد (مثل ردیف لیست) خودمان جزئیات را می‌گیریم: + // مبلغ ویزیت و قیمت سرویس‌ها فقط در detail هستند. + const detailQuery = useQuery({ + queryKey: ['appointment', appointmentUuid], + queryFn: () => api.get>(`/api/v1/appointment/${appointmentUuid}`), + enabled: open && !appointment, + }); + + const appt: AppointmentLike | null = appointment + ?? ((detailQuery.data?.data as any)?.data ?? detailQuery.data?.data ?? null); + + const visitPrice = Number(appt?.visit_price_rials ?? 0); + const services = appt?.service_items ?? []; + const servicesTotal = useMemo( + () => services.reduce((sum, s) => sum + Number(s.price_rials ?? 0), 0), + [services], + ); + const total = visitPrice + servicesTotal; + + const amountRials = tomanToRial(amountToman); + const remaining = Math.max(0, total - amountRials); + const overpaid = amountRials > total; + + const paymentState = amountRials === 0 + ? 'بدون پرداخت' + : remaining === 0 + ? 'تسویه کامل' + : 'پرداخت جزئی'; + + const confirmMut = useMutation({ + mutationFn: () => + api.post>(`/api/v1/appointment/${appointmentUuid}/confirm`, { + version: appt?.version, + payments: amountRials > 0 ? [{ method, amount_rials: amountRials }] : [], + }), + onSuccess: () => { + toast.success('نوبت قطعی شد'); + if (queryKey) qc.invalidateQueries({ queryKey }); + qc.invalidateQueries({ queryKey: ['appointment', appointmentUuid] }); + qc.invalidateQueries({ queryKey: ['appointment-events', appointmentUuid] }); + reset(); + onClose(); + }, + onError: (e: any) => toast.error(e?.message || 'قطعی کردن نوبت ناموفق بود'), + }); + + function reset() { + setAmountToman(0); + setMethod('cash'); + } + + function handleClose() { + reset(); + onClose(); + } + + const loading = detailQuery.isLoading && !appointment; + + return ( + + + + + } + > + {loading ? ( +

در حال دریافت اطلاعات نوبت…

+ ) : ( + <> + {appt?.patient_name && ( +

بیمار: {appt.patient_name}

+ )} + +
+
+ ویزیت + {formatRial(visitPrice)} +
+ {services.map((s) => ( +
+ {s.name} + {formatRial(Number(s.price_rials ?? 0))} +
+ ))} +
+ جمع کل + {formatRial(total)} +
+
+ +
+ + setMethod(String(v ?? 'cash'))} + options={METHOD_OPTIONS} + placeholder="روش پرداخت" + /> +
+ +
+ + + +
+ + {overpaid && ( +

+ مبلغ پرداخت از جمع کل بیشتر است. +

+ )} + +
+
+ پرداخت‌شده + {formatRial(Math.min(amountRials, total))} +
+
+ باقی‌مانده + {formatRial(remaining)} +
+
+ وضعیت پرداخت + {paymentState} +
+
+ + )} +
+ ); +} diff --git a/assets/admin/components/appointments/TurnsTimeline.tsx b/assets/admin/components/appointments/TurnsTimeline.tsx index 0b26ca6d..edcf6ccb 100644 --- a/assets/admin/components/appointments/TurnsTimeline.tsx +++ b/assets/admin/components/appointments/TurnsTimeline.tsx @@ -1,8 +1,9 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { UserIcon, PhoneIcon, DocumentTextIcon, PlusIcon } from '@heroicons/react/24/outline'; import type { Appointment } from '../../types'; import AppointmentStatusDropdown from '../ui/AppointmentStatusDropdown'; import AppointmentActionsMenu from '../AppointmentActions'; +import ConfirmAppointmentModal from './ConfirmAppointmentModal'; import { turnStatusConfig, EMPTY_SLOT_CONFIG } from './turnStatus'; import type { TimelineSlot } from './types'; @@ -103,6 +104,7 @@ function OccupiedCard({ appointment: Appointment; queryKey: unknown[]; onView: (a: Appointment) => void; }) { const cfg = turnStatusConfig(a.status); + const [confirmOpen, setConfirmOpen] = useState(false); return (
onView(a)} @@ -135,6 +137,19 @@ function OccupiedCard({ {/* وضعیت + عملیات (کلیک روی این ناحیه نباید کارت را باز کند) */}
e.stopPropagation()} style={{ display: 'flex', alignItems: 'center', gap: 8, alignSelf: 'flex-start', flexShrink: 0 }}> + {a.status === 'pending' && ( + <> + + setConfirmOpen(false)} + queryKey={queryKey} + /> + + )}
diff --git a/assets/admin/components/ui/AppointmentStatusDropdown.tsx b/assets/admin/components/ui/AppointmentStatusDropdown.tsx index fadbf4b8..92e0023a 100644 --- a/assets/admin/components/ui/AppointmentStatusDropdown.tsx +++ b/assets/admin/components/ui/AppointmentStatusDropdown.tsx @@ -4,6 +4,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { ChevronDownIcon } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import { api } from '../../lib/api'; +import ConfirmAppointmentModal from '../appointments/ConfirmAppointmentModal'; // Labels follow the Figma نوبت‌ها design (ثبت شده / قطعی شده / ویزیت شده …). export const STATUS_META: Record = { @@ -35,6 +36,7 @@ interface Props { export default function AppointmentStatusDropdown({ uuid, currentStatus, version, queryKey }: Props) { const [open, setOpen] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); const [menuPos, setMenuPos] = useState<{ top: number; right: number } | null>(null); const btnRef = useRef(null); const menuRef = useRef(null); @@ -76,7 +78,9 @@ export default function AppointmentStatusDropdown({ uuid, currentStatus, version qc.invalidateQueries({ queryKey }); setOpen(false); }, - onError: () => toast.error('خطا در تغییر وضعیت'), + // پیام سرور را نشان بده: تداخل نسخه (۴۰۹) و نبودِ دسترسی (۴۰۳) پیام فارسی + // دقیق دارند و «خطا در تغییر وضعیت» آن را پنهان می‌کرد. + onError: (e: any) => toast.error(e?.message || 'خطا در تغییر وضعیت'), }); const meta = STATUS_META[currentStatus] ?? { label: currentStatus, color: '#9ca3af' }; @@ -90,6 +94,19 @@ export default function AppointmentStatusDropdown({ uuid, currentStatus, version setOpen(o => !o); } + /** + * «قطعی شده» راه میان‌بر ندارد: قطعی‌کردن یعنی ثبت هزینه‌ها و پرداخت در پرونده، + * پس همیشه از مودال رد می‌شود. بقیهٔ وضعیت‌ها همان PATCH ساده‌اند. + */ + function handlePick(status: string) { + if (status === 'confirmed') { + setOpen(false); + setConfirmOpen(true); + return; + } + mutation.mutate(status); + } + return (
, document.body )} + + setConfirmOpen(false)} + queryKey={queryKey} + />
); } diff --git a/assets/admin/pages/AppointmentCreatePage.tsx b/assets/admin/pages/AppointmentCreatePage.tsx index a60d19bc..08310577 100644 --- a/assets/admin/pages/AppointmentCreatePage.tsx +++ b/assets/admin/pages/AppointmentCreatePage.tsx @@ -102,7 +102,6 @@ export default function AppointmentCreatePage() { // ── بیعانه / وضعیت / توضیحات const [depositRequired, setDepositRequired] = useState(false); const [depositToman, setDepositToman] = useState(0); - const [status, setStatus] = useState('pending'); const [note, setNote] = useState(''); // ── هزینه ویزیت — الزامی بودن از تنظیمات «الزامی کردن هزینه ویزیت» (کاربر بدون @@ -150,11 +149,9 @@ export default function AppointmentCreatePage() { ...(visitPriceToman > 0 ? { visit_price_rials: tomanToRial(visitPriceToman) } : {}), ...(note.trim() ? { note: note.trim() } : {}), }; - const res: any = await api.post(createEndpoint, payload); - if (status !== 'pending' && res?.data?.uuid) { - await api.patch(`/api/v1/appointment/${res.data.uuid}/status`, { status, version: 1 }); - } - return res; + // نوبت همیشه «ثبت شده» متولد می‌شود؛ قطعی‌کردن یک عملِ جداست که هزینه‌ها را + // نشان می‌دهد و پرداخت می‌گیرد (مودال «قطعی کردن نوبت»). + return api.post(createEndpoint, payload); }, onSuccess: () => { qc.invalidateQueries({ queryKey: ['appointments'] }); @@ -489,19 +486,6 @@ export default function AppointmentCreatePage() { )} -
- -
- setStatus(v ? String(v) : '')} - placeholder="انتخاب وضعیت" - height={44} - /> -
-
-