feat(appointments,patients): make clinic context a first-class citizen

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) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-18 21:04:50 +03:30
co-authored by Claude Opus 4.8
parent e6422014d1
commit 7921407f33
26 changed files with 2409 additions and 188 deletions
+104
View File
@@ -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`؛ طراحی مودال با تم/کلاس‌های موجود پنل، بدون طراحی جدید.
@@ -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` تکیه دارد).
+77
View File
@@ -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.
@@ -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<ApiResponse<AppointmentLike>>(`/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<ApiResponse<unknown>>(`/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 (
<Modal
open={open}
title="قطعی کردن نوبت"
size="md"
onClose={handleClose}
footer={
<>
<button type="button" className="btn" onClick={handleClose}>
انصراف
</button>
<button
type="button"
className="btn primary"
disabled={loading || overpaid || confirmMut.isPending}
onClick={() => confirmMut.mutate()}
>
{confirmMut.isPending ? 'در حال ثبت…' : 'تأیید و قطعی کردن'}
</button>
</>
}
>
{loading ? (
<p style={{ color: 'var(--text-2)' }}>در حال دریافت اطلاعات نوبت</p>
) : (
<>
{appt?.patient_name && (
<p style={{ marginBottom: 12, color: 'var(--text-2)' }}>بیمار: {appt.patient_name}</p>
)}
<div style={{ marginBottom: 18 }}>
<div style={rowStyle}>
<span>ویزیت</span>
<strong>{formatRial(visitPrice)}</strong>
</div>
{services.map((s) => (
<div key={s.uuid} style={rowStyle}>
<span>{s.name}</span>
<strong>{formatRial(Number(s.price_rials ?? 0))}</strong>
</div>
))}
<div style={{ ...rowStyle, borderBottom: 'none', fontSize: 16 }}>
<span>جمع کل</span>
<strong>{formatRial(total)}</strong>
</div>
</div>
<div className="field" style={{ marginBottom: 12 }}>
<label>روش پرداخت</label>
<SearchableSelect
value={method}
onChange={(v) => setMethod(String(v ?? 'cash'))}
options={METHOD_OPTIONS}
placeholder="روش پرداخت"
/>
</div>
<div className="field" style={{ marginBottom: 12 }}>
<label>مبلغ پرداختی (تومان)</label>
<PriceInput value={amountToman} onChange={setAmountToman} suffix="تومان" />
<button
type="button"
className="btn sm"
style={{ marginTop: 8 }}
onClick={() => setAmountToman(rialToToman(total))}
>
پرداخت کامل
</button>
</div>
{overpaid && (
<p style={{ color: 'var(--danger)', marginBottom: 12 }}>
مبلغ پرداخت از جمع کل بیشتر است.
</p>
)}
<div style={{ background: 'var(--surface-2)', borderRadius: 'var(--r-sm)', padding: 12 }}>
<div style={rowStyle}>
<span>پرداختشده</span>
<strong>{formatRial(Math.min(amountRials, total))}</strong>
</div>
<div style={rowStyle}>
<span>باقیمانده</span>
<strong>{formatRial(remaining)}</strong>
</div>
<div style={{ ...rowStyle, borderBottom: 'none' }}>
<span>وضعیت پرداخت</span>
<strong>{paymentState}</strong>
</div>
</div>
</>
)}
</Modal>
);
}
@@ -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 (
<div
onClick={() => onView(a)}
@@ -135,6 +137,19 @@ function OccupiedCard({
{/* وضعیت + عملیات (کلیک روی این ناحیه نباید کارت را باز کند) */}
<div onClick={(e) => e.stopPropagation()} style={{ display: 'flex', alignItems: 'center', gap: 8, alignSelf: 'flex-start', flexShrink: 0 }}>
{a.status === 'pending' && (
<>
<button type="button" className="btn primary sm" onClick={() => setConfirmOpen(true)}>
قطعی کردن نوبت
</button>
<ConfirmAppointmentModal
open={confirmOpen}
appointmentUuid={a.uuid}
onClose={() => setConfirmOpen(false)}
queryKey={queryKey}
/>
</>
)}
<AppointmentStatusDropdown uuid={a.uuid} currentStatus={a.status} version={a.version} queryKey={queryKey} />
<AppointmentActionsMenu appointment={a} queryKey={queryKey} />
</div>
@@ -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<string, { label: string; color: string }> = {
@@ -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<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(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 (
<div style={{ position: 'relative', display: 'inline-block' }}>
<button
@@ -129,7 +146,7 @@ export default function AppointmentStatusDropdown({ uuid, currentStatus, version
return (
<button
key={s}
onClick={() => mutation.mutate(s)}
onClick={() => handlePick(s)}
disabled={mutation.isPending}
style={{
display: 'flex', alignItems: 'center', gap: 8,
@@ -152,6 +169,13 @@ export default function AppointmentStatusDropdown({ uuid, currentStatus, version
</div>,
document.body
)}
<ConfirmAppointmentModal
open={confirmOpen}
appointmentUuid={uuid}
onClose={() => setConfirmOpen(false)}
queryKey={queryKey}
/>
</div>
);
}
+3 -19
View File
@@ -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() {
)}
</div>
<div style={{ maxWidth: 500 }}>
<label style={label}>انتخاب وضعیت</label>
<div style={{ margin: '6px 0 12px' }}>
<SearchableSelect
options={[{ value: 'pending', label: 'ثبت شده' }, { value: 'confirmed', label: 'قطعی شده' }]}
value={status || null}
onChange={v => setStatus(v ? String(v) : '')}
placeholder="انتخاب وضعیت"
height={44}
/>
</div>
</div>
<label style={label}>توضیحات</label>
<div className="field" style={{ height: 'auto', margin: '6px 0 16px' }}>
<textarea value={note} onChange={e => setNote(e.target.value)} rows={4} placeholder="توضیحات..."
+33 -2
View File
@@ -11,6 +11,7 @@ import PageHeader from '../components/ui/PageHeader';
import StatusBadge from '../components/ui/StatusBadge';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import SearchableSelect from '../components/ui/SearchableSelect';
import ConfirmAppointmentModal from '../components/appointments/ConfirmAppointmentModal';
const ALL_STATUSES: { value: AppointmentStatus; label: string }[] = [
{ value: 'pending', label: 'رزرو شده' },
@@ -50,6 +51,7 @@ export default function AppointmentDetailPage() {
const navigate = useNavigate();
const qc = useQueryClient();
const [cancelOpen, setCancelOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [cancelReason, setCancelReason] = useState('');
const [newStatus, setNewStatus] = useState('');
@@ -68,7 +70,7 @@ export default function AppointmentDetailPage() {
const statusMutation = useMutation({
mutationFn: (status: string) =>
api.patch<ApiResponse<null>>(`/api/v1/appointment/${uuid}/status`, { status }),
api.patch<ApiResponse<null>>(`/api/v1/appointment/${uuid}/status`, { status, version: appt?.version }),
onSuccess: () => {
toast.success('وضعیت نوبت بروزرسانی شد');
qc.invalidateQueries({ queryKey: ['appointment', uuid] });
@@ -80,6 +82,7 @@ export default function AppointmentDetailPage() {
const cancelMutation = useMutation({
mutationFn: () => api.patch<ApiResponse<null>>(`/api/v1/appointment/${uuid}/status`, {
status: 'cancelled_by_doctor',
version: appt?.version,
...(cancelReason.trim() ? { cancel_reason: cancelReason.trim() } : {}),
}),
onSuccess: () => {
@@ -94,6 +97,17 @@ export default function AppointmentDetailPage() {
// پاسخ single تودرتو است: { data: { data: {...} } }
const appt: any = (data?.data as any)?.data ?? data?.data;
// قطعی‌کردن هزینه و پرداخت دارد؛ از مسیر مودال می‌رود، نه PATCH وضعیت.
function applyStatus() {
if (!newStatus) return;
if (newStatus === 'confirmed') {
setConfirmOpen(true);
return;
}
statusMutation.mutate(newStatus);
}
// بازگشت به همان روزِ نوبت (نه امروز).
const day = isoDay(appt?.slot_start);
const backTo = day ? `/admin/appointments?date=${day}` : '/admin/appointments';
@@ -144,6 +158,15 @@ export default function AppointmentDetailPage() {
<StatusBadge type="appointment" value={appt.status} />
</div>
{appt.status === 'pending' && (
<button
onClick={() => setConfirmOpen(true)}
className="btn primary w-full"
>
قطعی کردن نوبت
</button>
)}
<div className="mt-6">
<label className="cp-label mb-2">تغییر وضعیت:</label>
<div className="flex gap-2">
@@ -157,7 +180,7 @@ export default function AppointmentDetailPage() {
/>
</div>
<button
onClick={() => newStatus && statusMutation.mutate(newStatus)}
onClick={() => applyStatus()}
disabled={!newStatus || statusMutation.isPending}
className="btn primary sm"
>
@@ -233,6 +256,14 @@ export default function AppointmentDetailPage() {
/>
</div>
</ConfirmDialog>
<ConfirmAppointmentModal
open={confirmOpen}
appointmentUuid={uuid!}
appointment={appt}
onClose={() => setConfirmOpen(false)}
queryKey={['appointment', uuid]}
/>
</div>
);
}
+12 -1
View File
@@ -119,7 +119,7 @@ export default function PatientsListPage() {
if (af) qs.set('admitted_from', String(af));
if (at) qs.set('admitted_to', String(at));
const { data, isLoading } = useQuery<ApiResponse<PatientRecord[]> & { meta?: { totalRecords: number } }>({
const { data, isLoading, error } = useQuery<ApiResponse<PatientRecord[]> & { meta?: { totalRecords: number } }>({
queryKey: ['patients', qs.toString()],
queryFn: () => api.get(`/api/v1/patients?${qs.toString()}`),
});
@@ -197,6 +197,17 @@ export default function PatientsListPage() {
{isLoading ? (
<div style={{ padding: 24, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
) : error ? (
/* «دسترسی ندارید» با «بیماری یافت نشد» یکی نیست — پیام سرور را نشان بده. */
<div className="card" style={{ padding: '60px 0', textAlign: 'center' }}>
<IdentificationIcon style={{ width: 52, margin: '0 auto 14px', display: 'block', opacity: 0.3, color: 'var(--danger)' }} />
<div style={{ fontSize: 14, color: 'var(--danger)' }}>
{(error as any)?.message || 'دسترسی به پرونده‌ها امکان‌پذیر نیست'}
</div>
<div style={{ fontSize: 13, color: 'var(--text-3)', marginTop: 8 }}>
اگر بهتازگی محیط کاریتان تغییر کرده، از منوی بالا محیط درست را انتخاب کنید.
</div>
</div>
) : records.length === 0 ? (
<div className="card" style={{ padding: '60px 0', textAlign: 'center', color: 'var(--text-3)' }}>
<IdentificationIcon style={{ width: 52, margin: '0 auto 14px', display: 'block', opacity: 0.3 }} />
+165 -19
View File
@@ -273,11 +273,40 @@ Book an appointment slot.
---
## Single-appointment access model
`GET /appointment/{uuid}`, `PATCH /appointment/{uuid}`, `PATCH /appointment/{uuid}/status`
and `GET /appointment/{uuid}/events` all resolve access through
`App\Appointment\Security\AppointmentAccessChecker`. The decision is driven by the
appointment's own environment (`appointment.clinic`: `null` = the doctor's personal
office, a value = that clinic) — **not** by the caller's role.
| Caller | Allowed |
|---|---|
| `ROLE_ADMIN` | everything |
| Owning doctor (`appointment.doctor.user`) | everything |
| Patient (`appointment.user`) | `view` and `cancel` only — never reschedule/edit |
| Clinic owner | everything, when `appointment.clinic` is their clinic |
| Member doctor of that clinic | per `ClinicDoctorPermission.appointments.{view,cancel,update_status}`; denied once the row is `active = false` |
| Secretary | active-context scope must match the appointment (same clinic **and** an assigned doctor, or the scope doctor), then `DoctorSecretary.appointments.{view,cancel,update_status}` |
Actions map onto the existing permission vocabulary: reads use `view`; edit / move /
reserve-transfer / replace / non-cancel status changes use `update_status`; any
transition to `cancelled_by_doctor` / `cancelled_by_user` requires `cancel` — including
an inline `status` sent to `PATCH /appointment/{uuid}`. Denials return
`ERR_ACCESS_DENIED` with HTTP `403`.
> Deactivating a doctor in a clinic (`ClinicDoctorPermission.active = false`) or a
> secretary (`DoctorSecretary.active = false`) is the single source of truth for
> "collaboration ended" — both checkers refuse on it. The clinic owner keeps full access.
---
## GET `/api/v1/appointment/{uuid}`
Get appointment detail.
**Permission:** `AUTH`must be the patient, the doctor, or `ROLE_ADMIN`
**Permission:** `AUTH`see [Single-appointment access model](#single-appointment-access-model)
### Path Parameters
| Param | Type | Description |
@@ -323,7 +352,7 @@ Get appointment detail.
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_FORBIDDEN_001` | 403 | Not the patient/doctor/admin |
| `ERR_ACCESS_DENIED` | 403 | Caller fails the single-appointment access model |
| `ERR_NOT_FOUND_001` | 404 | Appointment not found |
---
@@ -332,7 +361,11 @@ Get appointment detail.
Get all appointments for a specific doctor.
**Permission:** `AUTH` must be the doctor, their secretary, or `ROLE_ADMIN`
**Permission:** `AUTH` — the doctor themselves or `ROLE_ADMIN` see every appointment of
that doctor. A clinic user (owner, or member doctor holding `appointments.view`) may also
call it, but the result is **scoped to their own clinic**: only appointments whose
`clinic_id` is that clinic are returned, so the doctor's personal-office appointments
never leak into a clinic. Anyone else gets `403`.
### Path Parameters
| Param | Type | Description |
@@ -365,7 +398,7 @@ Get all appointments for a specific doctor.
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_FORBIDDEN_001` | 403 | Not authorized to view this doctor's appointments |
| `ERR_ACCESS_DENIED` | 403 | Not the doctor/admin, and no clinic scope granting `appointments.view` over this doctor |
| `ERR_NOT_FOUND_001` | 404 | Doctor not found |
---
@@ -409,7 +442,11 @@ Get all appointments for the authenticated user.
Change appointment status.
**Permission:** `AUTH`patient can cancel; doctor/secretary can confirm/complete/no_show; admin can do all
**Permission:** `AUTH`see [Single-appointment access model](#single-appointment-access-model).
The required action depends on the target status: a transition to `cancelled_by_doctor` /
`cancelled_by_user` needs `appointments.cancel`, everything else needs
`appointments.update_status`. A clinic secretary therefore confirms and completes by
default but cannot cancel until `cancel` is granted.
### Path Parameters
| Param | Type | Description |
@@ -431,12 +468,15 @@ Change appointment status.
| `version` | integer | ❌ | Optimistic lock version (prevents double-submit) |
| `cancel_reason` | string | ❌ | Only when transitioning to `cancelled_by_doctor` / `cancelled_by_user`. Stored on the recorded cancellation event (Timeline). Ignored for other statuses. |
**Allowed Transitions by Role:**
| Actor | Allowed transitions |
|-------|---------------------|
| Patient | `pending → cancelled` |
| Doctor / Secretary | `pending → confirmed`, `confirmed → completed`, `confirmed → no_show` |
| Admin | Any transition |
**Allowed Transitions by Actor:** the state machine itself is
`Appointment::ALLOWED_TRANSITIONS` (identical for everyone); the actor only decides
*whether* the transition may be attempted:
| Actor | Allowed |
|-------|---------|
| Patient | cancellation of their own appointment only |
| Doctor (owner) / clinic owner / admin | any transition the state machine permits |
| Member doctor / secretary | non-cancel transitions with `update_status`; cancellations only with `cancel` |
> **Cancellation is logged.** When the status becomes `cancelled_by_doctor` or `cancelled_by_user`, an `AppointmentEvent` (type `cancelled`, title «نوبت لغو شد») is recorded with the actor (user id + name), the optional `cancel_reason`, and the cancel time — surfaced via `GET /api/v1/appointment/{uuid}/events`. A `warning`-level entry is also written to `app_log`.
@@ -447,18 +487,102 @@ Updated appointment object.
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_FORBIDDEN_001` | 403 | Not authorized for this transition |
| `ERR_ACCESS_DENIED` | 403 | Caller lacks `update_status` (or `cancel` for a cancellation) on this appointment |
| `ERR_NOT_FOUND_001` | 404 | Appointment not found |
| `ERR_CONFLICT_001` | 409 | Version mismatch (optimistic lock) |
| `ERR_VALIDATION_001` | 422 | Invalid status value |
---
## POST `/api/v1/appointment/{uuid}/confirm`
Confirm an appointment («ثبت شده» → «قطعی شده») and register its money on the patient
case file — status transition, case file / visit, and payments in **one atomic
transaction**. If any step fails nothing is committed.
**Permission:** `AUTH``appointments.update_status` per the
[single-appointment access model](#single-appointment-access-model).
### Request Body
```json
{
"version": 3,
"payments": [
{ "method": "cash", "amount_rials": 2000000 },
{ "method": "pos", "amount_rials": 3000000 }
]
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `version` | integer | ❌ | Optimistic lock version; defaults to the stored one |
| `payments` | array | ❌ | Empty/absent = confirm without payment. Each row: `method``wallet\|pos\|cash\|card` and `amount_rials` > 0. Several rows are allowed (split payment). |
The sum of `payments` may not exceed the visit's payable amount → `ERR_SESSION_PAYMENT_EXCEEDS`.
Partial payment is normal: the remainder stays as `remaining_rials` on the visit and can be
collected later through `POST /api/v1/session/{uuid}/payments`.
### What happens on the server
1. `pending → confirmed` (state machine still applies).
2. `AppointmentConfirmationService` files the case file for the appointment's environment
(`appointment.clinic` → clinic, otherwise the doctor's personal office): an **existing**
record for that patient in that environment is reused, otherwise a new one is created.
A `PatientSession` is opened with the visit price and one line per attached service.
3. Each payment row is registered on that visit (`wallet` also debits the patient wallet).
### Response `200`
```json
{
"success": true,
"data": {
"appointment": { "uuid": "…", "status": "confirmed", "version": 4 },
"session": {
"uuid": "…",
"visit_price_rials": 5000000,
"services_total_rials": 1500000,
"final_price_rials": 6500000,
"discount_rials": 0,
"paid_total_rials": 5000000,
"remaining_rials": 1500000,
"is_paid": false
}
}
}
```
`session` is `null` when the tenant does not have the `patient_records` subscription
feature — the appointment is still confirmed, it simply has no case file. **Sending
`payments` in that situation fails with `403 ERR_SUBSCRIPTION_REQUIRED` and confirms
nothing**, because there would be nowhere to record the money.
Reserve-list entries (`is_reserve: true`) never open a visit; move them onto a real slot
first.
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_ACCESS_DENIED` | 403 | No `update_status` on this appointment |
| `ERR_SUBSCRIPTION_REQUIRED` | 403 | Payments sent but the tenant has no `patient_records` feature |
| `ERR_VALIDATION_002` | 404 | Appointment not found |
| `ERR_CONFLICT_001` | 409 | Version mismatch (optimistic lock) |
| `ERR_VALIDATION_001` | 422 | Transition to `confirmed` not allowed from the current status |
| `ERR_SESSION_PAYMENT_INVALID` | 422 | Unknown `method` or non-positive `amount_rials` |
| `ERR_SESSION_PAYMENT_EXCEEDS` | 422 | Payments exceed the payable amount |
> **Admin panel:** this is the only path to «قطعی شده». Picking `confirmed` in
> `AppointmentStatusDropdown` opens the «قطعی کردن نوبت» modal rather than issuing a raw
> `PATCH .../status`, so confirmation can never silently skip the case file and payment.
---
## GET `/api/v1/appointment/{uuid}/events`
Appointment Timeline — chronological event history for one appointment. Currently records cancellation events; the structure is generic for future event types.
**Permission:** `IS_AUTHENTICATED_FULLY`caller must be able to manage the appointment (`canManage`).
**Permission:** `IS_AUTHENTICATED_FULLY`read-only, so it needs `view` (not `update_status`):
see [Single-appointment access model](#single-appointment-access-model). The patient sees
their own Timeline.
### Path Parameters
| Param | Type | Description |
@@ -485,7 +609,7 @@ Events are ordered oldest → newest. `data` is a flat array (single nesting). `
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_006` | 403 | Not allowed to manage this appointment |
| `ERR_ACCESS_DENIED` | 403 | Not allowed to view this appointment |
| `ERR_VALIDATION_002` | 404 | Appointment not found |
---
@@ -494,6 +618,18 @@ Events are ordered oldest → newest. `data` is a flat array (single nesting). `
Create a new appointment for a patient. Used by doctor/clinic/secretary to book appointments on behalf of patients. If no user exists with the given mobile, a new user account is created automatically.
> **Initial status is `pending` («ثبت شده»), not `confirmed`.** Every appointment —
> online, quick, or regular — starts as registered; confirming it is a separate act that
> shows the costs and takes payment (`POST /api/v1/appointment/{uuid}/confirm`). Because
> of that, **no case file / visit is opened at creation time** any more; it is opened on
> confirmation.
>
> A panel-created `pending` appointment still **occupies its slot** (so the time stays
> reserved) and carries **no `expires_at`**, so it is never auto-expired: only online
> gateway holds (created with a 15-minute TTL by `POST /api/v1/appointment`) are swept by
> `AppointmentExpiryService`. Ending a stale registered appointment is an operator
> decision (cancel).
**Auth:** `IS_AUTHENTICATED_FULLY` — Roles: `ROLE_DOCTOR`, `ROLE_CLINIC`, `ROLE_SECRETARY`, `ROLE_ADMIN`
> **Scope enforced:** the caller must be related to the target `doctor_uuid`, not merely hold an allowed role. A doctor may book only onto their own calendar; a clinic only onto doctors that belong to it; a secretary only within their active clinic/doctor scope **and** with the `appointments.create` permission; admin onto any. Otherwise `403 FORBIDDEN`.
@@ -598,9 +734,19 @@ Role-aware paginated list of appointments. Returns only what the authenticated u
| `ROLE_ADMIN` | All appointments |
| `ROLE_CLINIC` | Appointments for doctors in this clinic |
| `ROLE_DOCTOR` | Appointments for this doctor |
| `ROLE_SECRETARY` | Appointments for the linked doctor (empty if `appointments.view` permission is false) |
| `ROLE_SECRETARY` | Appointments of the doctors assigned to this secretary in their active scope (empty if `appointments.view` is false) |
| (plain patient `ROLE_USER`) | The patient's own appointments (`a.user = current user`) |
### GET `/api/v1/my/appointments/today-stats`
Same scoping rules as the list above, aggregated into `{ total, completed, waiting, cancelled }`
for one day (`?date=Y-m-d`, defaults to today).
**Auth:** `IS_AUTHENTICATED_FULLY`. A caller with no resolvable scope (clinic/doctor row
missing, secretary without `appointments.view` or with no assigned doctors) gets all-zero
counts rather than an unscoped, system-wide count. A plain patient gets counts over their
own appointments only.
### Query Parameters
| Param | Type | Default | Description |
|-------|------|---------|-------------|
@@ -640,13 +786,13 @@ Role-aware paginated list of appointments. Returns only what the authenticated u
## Clinic workflow extensions (نوبتها Figma)
New optional fields on `Appointment` (all backward-compatible): `service_section` (بخش), `service_item` (سرویسِ اصلی/اول), `service_items` (آرایهٔ همهٔ سرویسهای نوبت چند سرویس، هر عضو `{uuid, name}`), `staff` (پرسنل), `deposit_required` / `deposit_amount_rials` (بیعانه), `visit_price_rials` (هزینه ویزیت، nullable), `is_reserve` (نوبت رزرو day-level, never occupies a slot).
New optional fields on `Appointment` (all backward-compatible): `service_section` (بخش), `service_item` (سرویسِ اصلی/اول), `service_items` (آرایهٔ همهٔ سرویسهای نوبت چند سرویس، هر عضو `{uuid, name, price_rials}`؛ `price_rials` افزوده شد تا مودالِ «قطعی کردن نوبت» بتواند هزینهها را پیش از ساختهشدنِ مراجعه نشان دهد), `staff` (پرسنل), `deposit_required` / `deposit_amount_rials` (بیعانه), `visit_price_rials` (هزینه ویزیت، nullable), `is_reserve` (نوبت رزرو day-level, never occupies a slot).
New statuses: `following_up` (در حال پیگیری), `salon` (سالن). Transitions:
`pending confirmed|following_up|cancelled_*|expired` · `confirmed completed|following_up|salon|cancelled_*|no_show` · `following_up confirmed|salon|completed|cancelled_*|no_show` · `salon completed|following_up|cancelled_*|no_show`
### PATCH `/api/v1/appointment/{uuid}`
General update (ویرایش / جا به جایی / انتقال به رزرو / جایگزینی). All body fields optional; only present keys change. **Permission:** appointment's patient, owning doctor, or admin.
General update (ویرایش / جا به جایی / انتقال به رزرو / جایگزینی). All body fields optional; only present keys change. **Permission:** `appointments.update_status` per the [single-appointment access model](#single-appointment-access-model) the appointment's owning doctor, admin, the clinic owner / member doctor / assigned secretary of `appointment.clinic`. The patient is **not** allowed here (view + cancel only).
```json
{
@@ -661,7 +807,7 @@ General update (ویرایش / جا به جایی / انتقال به رزرو /
- `slot_start`/`slot_end` must be sent together; moving to an occupied slot → `409`.
- Relation uuids: empty string clears; unknown uuid → `422`.
- `status` follows the same transition rules as `PATCH /appointment/{uuid}/status`. A transition to `cancelled_by_doctor`/`cancelled_by_user` records a cancellation event (Timeline) + `app_log` warning; an optional `cancel_reason` body field is stored on the event.
- `status` follows the same transition rules as `PATCH /appointment/{uuid}/status`. A transition to `cancelled_by_doctor`/`cancelled_by_user` records a cancellation event (Timeline) + `app_log` warning; an optional `cancel_reason` body field is stored on the event. An inline cancellation is gated on `appointments.cancel` exactly like the dedicated status endpoint, so it cannot be used to bypass a secretary's missing cancel permission.
- Optimistic lock via `version``409` on concurrent edit.
Response `200`: `{ success, data: { data: <appointment.toArray()> } }`
@@ -669,7 +815,7 @@ Response `200`: `{ success, data: { data: <appointment.toArray()> } }`
| HTTP | Description |
|------|-------------|
| 404 | نوبت یافت نشد |
| 403 | not patient/doctor/admin |
| 403 | `ERR_ACCESS_DENIED` — no `update_status` on this appointment, or an inline cancellation without `cancel` |
| 422 | half slot pair, end < start, unknown relation uuid, invalid transition |
| 409 | slot taken or version conflict |
+49 -3
View File
@@ -5,7 +5,46 @@
Patient records track patients per entity (doctor or clinic). Each record holds multiple sessions (visits). Access requires an active subscription with the `patient_records` feature.
**Base path:** `/api/v1`
**Auth:** Bearer JWT (doctor, clinic, or secretary with `appointments.view` permission required)
**Auth:** Bearer JWT (doctor, clinic, or secretary)
---
## Record access model
Every endpoint in this file resolves the caller's environment through
`App\Patient\Security\PatientRecordScopeResolver`. The **active context**
(`UserActiveContext`, set by `POST /api/v1/auth/switch-context`) decides it — not the role
alone, because a doctor invited into a clinic has records in both places.
| Caller | Scope | Visible records |
|---|---|---|
| Clinic owner | `clinic:<id>` | every record of the clinic |
| Doctor, active context = a clinic they belong to | `clinic:<id>` | only records of **their own** patients in that clinic |
| Doctor, otherwise | `doctor:<id>` | their personal-office records only |
| Secretary, active context = clinic | `clinic:<id>` | records of the doctors assigned to that secretary |
| Secretary, active context = doctor | `doctor:<id>` | that doctor's records |
**"Their own patients" is derived, not stored.** A clinic record is per-patient
(`UNIQUE(entity_type, entity_id, user_id)`) and deliberately shared between the clinic's
doctors — there is no doctor column on it and none should be added. A record counts as a
member doctor's when the patient has at least one appointment with that doctor **in that
clinic**. Manually created visits carry no doctor (`PatientSession` has no creator column),
so they never widen a member doctor's view on their own.
The member-doctor path additionally requires `ClinicDoctorPermission.patients.view`, and a
clinic secretary requires an active `DoctorSecretary` row. Both refuse when `active = false`,
so **deactivating a doctor or secretary is the single mechanism that ends their access**
the clinic owner keeps everything, and no record is moved or deleted. A doctor whose clinic
membership was revoked silently falls back to their personal-office scope.
> **Read and write use the same rule.** An active member doctor who can see a record can
> also manage it (notes, sessions, payments, attachments): the clinic record is shared by
> design, and per-visit ownership is not modelled, so inventing a write-only restriction on
> top of it would produce confusing 403s. A clinic owner who wants a read-only doctor
> revokes `patients.update` for them.
A record outside the caller's scope is reported as `404 ERR_PATIENT_NOT_FOUND` (not 403), so
the existence of another environment's records is never disclosed.
---
@@ -17,7 +56,10 @@ Patient records track patients per entity (doctor or clinic). Each record holds
GET /api/v1/patients
```
Returns a paginated list of patient records belonging to the authenticated entity.
Returns a paginated list of patient records belonging to the authenticated entity, already
narrowed by the [record access model](#record-access-model) — a member doctor or clinic
secretary receives only their own patients, with `meta.totalRecords` counted over the same
restriction.
**Query params:**
@@ -356,7 +398,11 @@ GET /api/v1/patient/{uuid}/appointments
نوبت‌های همین بیمار را برمی‌گرداند. برای جلوگیری از نشتِ اطلاعات بین ارائه‌دهنده‌ها، فقط نوبت‌هایی نمایش داده می‌شوند که با پزشک(های) خودِ صاحب پرونده گرفته شده‌اند:
- ارائه‌دهنده‌ی **پزشک**: نوبت‌های بیمار با همان پزشک.
- ارائه‌دهنده‌ی **کلینیک** (و منشیِ فعالِ کلینیک): نوبت‌های بیمار با پزشکانی که دعوت پذیرفته‌شده (`accepted`) در آن کلینیک دارند.
- ارائه‌دهنده‌ی **کلینیک** (و منشیِ فعالِ کلینیک): نوبت‌هایی که `appointment.clinic_id` آن‌ها همین کلینیک است.
> شاخهٔ کلینیک قبلاً بر اساس «پزشکانِ دارای دعوتِ پذیرفته‌شده در این کلینیک» کوئری می‌شد؛
> با پایان همکاری یا غیرفعال شدن پزشک، تاریخچهٔ نوبت‌های همان کلینیک از پرونده ناپدید
> می‌شد. مبنا حالا خودِ محیطِ ثبت‌شدهٔ نوبت است، که تغییرناپذیر است.
مرتب‌شده بر اساس `starts_at` نزولی. خروجی آرایه‌ی ساده است (بدون صفحه‌بندی).
@@ -7,6 +7,7 @@ use App\Appointment\Entity\WeeklySchedule;
use App\Appointment\Repository\AppointmentRepository;
use App\Appointment\Repository\SlotTakenException;
use App\Appointment\Repository\WeeklyScheduleRepository;
use App\Appointment\Security\AppointmentAccessChecker;
use App\Appointment\Service\AppointmentConfirmationService;
use App\Appointment\Service\SlotCalculatorService;
use App\Clinic\Entity\Clinic;
@@ -40,6 +41,7 @@ class AppointmentController extends BaseController
private readonly \App\ClinicService\Repository\ServiceItemRepository $itemRepo,
private readonly \App\Staff\Repository\ClinicStaffRepository $staffRepo,
private readonly \App\Appointment\Repository\AppointmentEventRepository $eventRepo,
private readonly \App\Appointment\Security\AppointmentAccessChecker $accessChecker,
private readonly \Psr\Log\LoggerInterface $logger,
) {}
@@ -576,7 +578,7 @@ class AppointmentController extends BaseController
}
if (!$this->canView($appointment, $user)) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
}
return $this->success(['data' => $appointment->toArray()]);
@@ -631,12 +633,17 @@ class AppointmentController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
$isOwner = $doctor->getUser()->getId() === $user->getId() || $user->hasRole('ROLE_ADMIN');
// مدیر کلینیک لیست پزشک عضو را می‌بیند، ولی فقط نوبت‌های همان کلینیک —
// نوبت‌های مطب شخصی پزشک به کلینیک نشت نمی‌کند.
$scopeClinic = $isOwner ? null : $this->accessChecker->viewableClinicFor($user, $doctor);
if (!$isOwner && $scopeClinic === null) {
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
}
$status = $request->query->get('status');
$appointments = $this->appointmentRepo->findByDoctor($doctor, $status);
$appointments = $this->appointmentRepo->findByDoctor($doctor, $status, $scopeClinic);
return $this->success(['data' => array_map(fn(Appointment $a) => $a->toArray(), $appointments)]);
}
@@ -685,16 +692,12 @@ class AppointmentController extends BaseController
private function canView(Appointment $a, User $user): bool
{
return $a->getUser()->getId() === $user->getId()
|| $a->getDoctor()->getUser()->getId() === $user->getId()
|| $user->hasRole('ROLE_ADMIN');
return $this->accessChecker->canView($a, $user);
}
private function canManage(Appointment $a, User $user): bool
{
return $a->getUser()->getId() === $user->getId()
|| $a->getDoctor()->getUser()->getId() === $user->getId()
|| $user->hasRole('ROLE_ADMIN');
return $this->accessChecker->canManage($a, $user);
}
/**
@@ -855,14 +858,20 @@ class AppointmentController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
}
if (!$this->canManage($appointment, $user)) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
$data = json_decode($request->getContent(), true) ?? [];
$newStatus = trim($data['status'] ?? '');
$version = (int) ($data['version'] ?? $appointment->getVersion());
// لغو مجوز جداگانه دارد: منشی به‌صورت پیش‌فرض اجازهٔ لغو ندارد ولی وضعیت‌های
// دیگر را تغییر می‌دهد.
$action = in_array($newStatus, self::CANCEL_STATUSES, true)
? AppointmentAccessChecker::ACTION_CANCEL
: AppointmentAccessChecker::ACTION_UPDATE_STATUS;
if (!$this->accessChecker->can($appointment, $user, $action)) {
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
}
if (!$appointment->canTransitionTo($newStatus)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, sprintf(
'انتقال از "%s" به "%s" مجاز نیست', $appointment->getStatus(), $newStatus
@@ -889,6 +898,81 @@ class AppointmentController extends BaseController
return $this->success(['data' => $appointment->toArray()]);
}
/**
* قطعی‌کردن نوبت به‌همراه پرداخت — «ثبت‌شده» → «قطعی».
*
* یک عملِ اتمیک: انتقال وضعیت، ساخت/یافتنِ پروندهٔ همان محیط با سرویس‌های نوبت،
* و ثبت پرداخت‌های کامل یا جزئی روی همان مراجعه. اگر هر مرحله شکست بخورد هیچ‌کدام
* ثبت نمی‌شوند.
*/
#[OA\Post(
path: '/api/v1/appointment/{uuid}/confirm',
summary: 'Confirm an appointment and register its payments on the patient case file',
security: [['bearerAuth' => []]],
responses: [
new OA\Response(response: 200, description: 'Appointment confirmed'),
new OA\Response(response: 403, description: 'Access denied, or payments sent without the patient_records feature'),
new OA\Response(response: 404, description: 'Appointment not found'),
new OA\Response(response: 409, description: 'Version conflict'),
new OA\Response(response: 422, description: 'Invalid transition or payment'),
]
)]
#[Route('/api/v1/appointment/{uuid}/confirm', methods: ['POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function confirm(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$appointment = $this->appointmentRepo->findByUuid($uuid);
if ($appointment === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
}
if (!$this->accessChecker->can($appointment, $user, AppointmentAccessChecker::ACTION_UPDATE_STATUS)) {
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
}
$data = json_decode($request->getContent(), true) ?? [];
$version = (int) ($data['version'] ?? $appointment->getVersion());
if (!$appointment->canTransitionTo(Appointment::STATUS_CONFIRMED)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, sprintf(
'انتقال از "%s" به "%s" مجاز نیست', $appointment->getStatus(), Appointment::STATUS_CONFIRMED
), 422);
}
$payments = [];
foreach ((array) ($data['payments'] ?? []) as $row) {
$method = trim((string) ($row['method'] ?? ''));
$amount = (int) ($row['amount_rials'] ?? 0);
if (!in_array($method, \App\Patient\Entity\SessionPayment::METHODS, true)) {
return $this->error(ErrorCodes::ERR_SESSION_PAYMENT_INVALID, ErrorCodes::message(ErrorCodes::ERR_SESSION_PAYMENT_INVALID), 422, 'method');
}
if ($amount <= 0) {
return $this->error(ErrorCodes::ERR_SESSION_PAYMENT_INVALID, ErrorCodes::message(ErrorCodes::ERR_SESSION_PAYMENT_INVALID), 422, 'amount_rials');
}
$payments[] = ['method' => $method, 'amount_rials' => $amount];
}
try {
$session = $this->appointmentConfirmation->confirmWithPayments($appointment, $version, $payments, $user);
} catch (OptimisticLockException) {
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'تداخل ویرایش همزمان. لطفاً دوباره تلاش کنید', 409);
}
return $this->success([
'appointment' => $appointment->toArray(),
'session' => $session === null ? null : [
'uuid' => $session->getUuid(),
'visit_price_rials' => $session->getVisitPriceRials(),
'services_total_rials' => $session->getServicesTotalRials(),
'final_price_rials' => $session->getFinalPriceRials(),
'discount_rials' => $session->getDiscountRials(),
'paid_total_rials' => $session->getPaidTotalRials(),
'remaining_rials' => $session->getRemainingRials(),
'is_paid' => $session->getRemainingRials() === 0,
],
]);
}
/**
* General update (ویرایش / جا به جایی / انتقال به رزرو / جایگزینی).
* All fields optional; only what is present in the body changes. Slot moves
@@ -905,12 +989,18 @@ class AppointmentController extends BaseController
}
if (!$this->canManage($appointment, $user)) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
}
$data = json_decode($request->getContent(), true) ?? [];
$version = (int) ($data['version'] ?? $appointment->getVersion());
// status درون‌خطی نباید گیت لغو را دور بزند.
if (in_array(trim((string) ($data['status'] ?? '')), self::CANCEL_STATUSES, true)
&& !$this->accessChecker->canCancel($appointment, $user)) {
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
}
// Slot move / reserve toggle — both times together, or neither.
$hasStart = array_key_exists('slot_start', $data);
$hasEnd = array_key_exists('slot_end', $data);
@@ -1015,8 +1105,8 @@ class AppointmentController extends BaseController
if ($appointment === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
}
if (!$this->canManage($appointment, $user)) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
if (!$this->canView($appointment, $user)) {
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
}
return $this->success($this->eventRepo->findByAppointmentUuid($uuid));
@@ -45,7 +45,6 @@ class MyAppointmentsController extends BaseController
private readonly \App\Auth\Repository\UserRepository $userRepo,
private readonly \App\UserProfile\Repository\UserProfileRepository $profileRepo,
private readonly VisitPriceRequirementResolver $visitPriceResolver,
private readonly \App\Appointment\Service\AppointmentConfirmationService $appointmentConfirmation,
) {}
#[Route('/api/v1/my/appointment', methods: ['POST'])]
@@ -186,10 +185,10 @@ class MyAppointmentsController extends BaseController
$appointment->setPatientName($patient->getRealName() ?: $patientName);
$appointment->setPatientMobile($mobile);
// نوبتی که خودِ کلینیک/پزشک ثبت می‌کند پرداخت آنلاین ندارد و منتظر چیزی نیست؛
// قطعی است. transitionTo قبل از ذخیره می‌آید تا active_slot_key با وضعیت نهایی
// محاسبه شود.
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
// نوبت پنلی «ثبت‌شده» (pending) متولد می‌شود، نه قطعی: قطعی‌شدن یک عملِ جداست
// که هزینه‌ها را نشان می‌دهد و پرداخت می‌گیرد (POST /appointment/{uuid}/confirm).
// pending هم اسلات را اشغال می‌کند (SLOT_OCCUPYING_STATUSES)، پس جای نوبت
// محفوظ می‌ماند. expiresAt ست نمی‌شود، پس هرگز خودبه‌خود منقضی نمی‌شود.
if ($isReserve) {
// Day-level reserve: no slot occupation, plain save (no atomic slot check).
@@ -203,8 +202,6 @@ class MyAppointmentsController extends BaseController
}
}
$this->appointmentConfirmation->onConfirmed($appointment);
return $this->success([
'uuid' => $appointment->getUuid(),
'slot_start' => $slotStart,
@@ -403,30 +400,44 @@ class MyAppointmentsController extends BaseController
->groupBy('a.status');
$roles = $user->getRoles();
if (in_array('ROLE_CLINIC', $roles, true)) {
if (in_array('ROLE_ADMIN', $roles, true)) {
// Admin sees all
} elseif (in_array('ROLE_CLINIC', $roles, true)) {
$clinic = $this->clinicRepo->findByUser($user);
if ($clinic) {
$qb->join('App\Clinic\Entity\Clinic', 'c', 'WITH', 'd MEMBER OF c.doctors')
->andWhere('c = :clinic')
->setParameter('clinic', $clinic);
if ($clinic === null) {
return $this->emptyTodayStats();
}
$qb->join('App\Clinic\Entity\Clinic', 'c', 'WITH', 'd MEMBER OF c.doctors')
->andWhere('c = :clinic')
->setParameter('clinic', $clinic);
} elseif (in_array('ROLE_DOCTOR', $roles, true)) {
$doctor = $this->doctorRepo->findByUser($user);
if ($doctor) {
$qb->andWhere('a.doctor = :doctor')->setParameter('doctor', $doctor);
if ($doctor === null) {
return $this->emptyTodayStats();
}
$qb->andWhere('a.doctor = :doctor')->setParameter('doctor', $doctor);
} elseif (in_array('ROLE_SECRETARY', $roles, true)) {
$filter = $this->resolveSecretaryFilter($user);
if ($filter !== null) {
[$filterType, $filterValue] = $filter;
if ($filterType === 'clinic') {
$qb->join('App\Clinic\Entity\Clinic', 'c', 'WITH', 'd MEMBER OF c.doctors')
->andWhere('c = :clinic')
->setParameter('clinic', $filterValue);
} else {
$qb->andWhere('a.doctor = :doctor')->setParameter('doctor', $filterValue);
}
if ($filter === null) {
return $this->emptyTodayStats();
}
// در scope کلینیک، filterValue آرایه‌ی idهای پزشکانِ تخصیص‌یافته است —
// نه خود کلینیک؛ هم‌شکل با myAppointments.
[$filterType, $filterValue, $canView] = $filter;
if (!$canView) {
return $this->emptyTodayStats();
}
if ($filterType === 'clinic') {
if (empty($filterValue)) {
return $this->emptyTodayStats();
}
$qb->andWhere('a.doctor IN (:doctorIds)')->setParameter('doctorIds', $filterValue);
} else {
$qb->andWhere('a.doctor = :doctor')->setParameter('doctor', $filterValue);
}
} else {
// بیمار عادی: فقط نوبت‌های خودش — نه شمارشِ بی‌محدودهٔ کل سیستم.
$qb->andWhere('a.user = :patient')->setParameter('patient', $user);
}
$rows = $qb->getQuery()->getArrayResult();
@@ -451,6 +462,11 @@ class MyAppointmentsController extends BaseController
]);
}
private function emptyTodayStats(): JsonResponse
{
return $this->success(['total' => 0, 'completed' => 0, 'waiting' => 0, 'cancelled' => 0]);
}
/**
* Whether the acting user is allowed to book onto this doctor's calendar.
* The role gate alone is not enough: a doctor/clinic/secretary must be
+12 -1
View File
@@ -200,6 +200,11 @@ class Appointment
/**
* Recompute the unique active-slot key from the current status. Non-null
* while the appointment occupies the slot; null once it is cancelled.
*
* کلید عمداً clinic ندارد و فقط doctor+slotStart است: برنامهٔ هفتگی هر محیط
* جداست (WeeklySchedule با UNIQUE(doctor_id, clinic_key)) و می‌تواند با محیط
* دیگر هم‌پوشانی داشته باشد، ولی پزشک یک نفر است. افزودن clinic به کلید یعنی
* اجازهٔ رزرو هم‌زمان همان پزشک در مطب و کلینیک — نه رفع باگ.
*/
private function refreshActiveSlotKey(): void
{
@@ -356,8 +361,14 @@ class Appointment
'patient_reason' => $this->patientReason,
'service_section' => $this->serviceSection ? ['uuid' => $this->serviceSection->getUuid(), 'name' => $this->serviceSection->getName()] : null,
'service_item' => $this->serviceItem ? ['uuid' => $this->serviceItem->getUuid(), 'name' => $this->serviceItem->getName()] : null,
// price_rials لازم است تا مودالِ «قطعی کردن نوبت» بتواند هزینه‌ها را قبل از
// ساخته‌شدنِ مراجعه نشان دهد.
'service_items' => array_map(
fn(\App\ClinicService\Entity\ServiceItem $i) => ['uuid' => $i->getUuid(), 'name' => $i->getName()],
fn(\App\ClinicService\Entity\ServiceItem $i) => [
'uuid' => $i->getUuid(),
'name' => $i->getName(),
'price_rials' => $i->getPriceRials(),
],
$this->serviceItems->toArray()
),
'staff' => $this->staff ? ['uuid' => $this->staff->getUuid(), 'full_name' => $this->staff->getFullName()] : null,
@@ -4,6 +4,7 @@ namespace App\Appointment\Repository;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
@@ -172,13 +173,33 @@ class AppointmentRepository extends ServiceEntityRepository
}
/** @return Appointment[] */
public function findByDoctor(Doctor $doctor, ?string $status = null): array
public function findByDoctor(Doctor $doctor, ?string $status = null, ?Clinic $clinic = null): array
{
$criteria = ['doctor' => $doctor];
if ($status !== null) $criteria['status'] = $status;
// محدودکردن به یک محیط: مدیر کلینیک نباید نوبت‌های مطب شخصی پزشک را ببیند.
if ($clinic !== null) $criteria['clinic'] = $clinic;
return $this->findBy($criteria, ['slotStart' => 'ASC']);
}
/**
* نوبت‌های یک بیمار در یک کلینیک — بر پایهٔ خودِ محیطِ ثبت‌شدهٔ نوبت، تا غیرفعال
* شدنِ بعدیِ پزشک تاریخچه را از پروندهٔ کلینیک حذف نکند.
*
* @return Appointment[]
*/
public function findByUserAndClinic(User $user, int $clinicId): array
{
return $this->createQueryBuilder('a')
->where('a.user = :user')
->andWhere('IDENTITY(a.clinic) = :clinicId')
->setParameter('user', $user)
->setParameter('clinicId', $clinicId)
->orderBy('a.slotStart', 'DESC')
->getQuery()
->getResult();
}
/** @return Appointment[] */
public function findByUser(User $user, ?string $status = null): array
{
@@ -247,11 +268,20 @@ class AppointmentRepository extends ServiceEntityRepository
->getResult();
}
/** @return Appointment[] pending appointments older than given timestamp */
/**
* رزروهای آنلاینِ پرداخت‌نشده که ساعتشان هم گذشته است.
*
* `expiresAt IS NOT NULL` یعنی فقط نگه‌داشتِ موقتِ درگاه (markPendingWithTtl).
* نوبت «ثبت‌شده»ای که کلینیک/پزشک از پنل ثبت کرده TTL ندارد و نباید سرِ ساعتِ
* نوبت خودبه‌خود منقضی شود — قطعی/لغو کردنش تصمیم اپراتور است.
*
* @return Appointment[]
*/
public function findExpiredPending(int $before): array
{
return $this->createQueryBuilder('a')
->where('a.status = :status')
->andWhere('a.expiresAt IS NOT NULL')
->andWhere('a.slotStart < :before')
->setParameter('status', Appointment::STATUS_PENDING)
->setParameter('before', $before)
@@ -0,0 +1,135 @@
<?php
namespace App\Appointment\Security;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\Auth\Repository\UserActiveContextRepository;
use App\Clinic\Repository\ClinicRepository;
use App\Clinic\Security\ClinicDoctorPermissionChecker;
use App\Doctor\Repository\DoctorRepository;
use App\Secretary\Repository\DoctorSecretaryRepository;
use App\Secretary\Security\SecretaryPermissionChecker;
/**
* تنها تصمیم‌گیرندهٔ دسترسی روی «یک نوبت مشخص».
*
* پیش از این، مسیرهای تک‌نوبت فقط بیمار، پزشکِ مالک و ادمین را می‌شناختند؛ نوبتی که
* کاربر کلینیک از مسیر /my/appointment می‌ساخت، روی مشاهده و ویرایش ۴۰۳ می‌گرفت.
* محیط نوبت با appointment.clinic بیان می‌شود (NULL یعنی مطب شخصی) و همان مبنای
* تصمیم است — نه نقش کاربر.
*
* اکشن‌ها از همان واژگان ClinicDoctorPermission/DoctorSecretary گرفته شده‌اند تا
* «پایان همکاری» فقط یک منبع حقیقت داشته باشد: active=false در همان رکوردها.
*/
class AppointmentAccessChecker
{
public const ACTION_VIEW = 'view';
public const ACTION_UPDATE_STATUS = 'update_status';
public const ACTION_CANCEL = 'cancel';
private const RESOURCE = 'appointments';
public function __construct(
private readonly ClinicDoctorPermissionChecker $clinicPermissions,
private readonly SecretaryPermissionChecker $secretaryPermissions,
private readonly DoctorSecretaryRepository $secretaryRepo,
private readonly UserActiveContextRepository $contextRepo,
private readonly ClinicRepository $clinicRepo,
private readonly DoctorRepository $doctorRepo,
) {}
public function canView(Appointment $appointment, User $user): bool
{
return $this->can($appointment, $user, self::ACTION_VIEW);
}
/** مجوز تغییر نوبت: ویرایش، جابه‌جایی، رزرو، جایگزینی و تغییر وضعیت. */
public function canManage(Appointment $appointment, User $user): bool
{
return $this->can($appointment, $user, self::ACTION_UPDATE_STATUS);
}
public function canCancel(Appointment $appointment, User $user): bool
{
return $this->can($appointment, $user, self::ACTION_CANCEL);
}
public function can(Appointment $appointment, User $user, string $action): bool
{
if ($user->hasRole('ROLE_ADMIN')) {
return true;
}
if ($appointment->getDoctor()->getUser()->getId() === $user->getId()) {
return true;
}
// بیمار نوبت خودش را می‌بیند و لغو می‌کند، ولی جابه‌جا/ویرایش نمی‌کند.
if ($appointment->getUser()->getId() === $user->getId()) {
return $action === self::ACTION_VIEW || $action === self::ACTION_CANCEL;
}
$clinic = $appointment->getClinic();
if ($clinic !== null && $this->clinicPermissions->can($user, $clinic, self::RESOURCE, $action)) {
return true;
}
return $this->secretaryCan($appointment, $user, $action);
}
/**
* کلینیکی که این کاربر در آن اجازهٔ دیدن نوبت‌های این پزشک را دارد، یا null.
* برای لیست‌هایی که باید به یک محیط محدود شوند (نه تک‌نوبت).
*/
public function viewableClinicFor(User $user, \App\Doctor\Entity\Doctor $doctor): ?\App\Clinic\Entity\Clinic
{
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
$clinic = $dbUuid !== null ? $this->clinicRepo->findByUuid($dbUuid) : null;
if ($clinic === null) {
$clinic = $this->clinicRepo->findByUser($user);
}
if ($clinic === null || !$clinic->hasDoctor($doctor)) {
return null;
}
return $this->clinicPermissions->can($user, $clinic, self::RESOURCE, self::ACTION_VIEW)
? $clinic
: null;
}
/**
* منشی در محیط فعالِ خودش. در محیط کلینیک، نوبت باید هم متعلق به همان کلینیک
* باشد و هم پزشکش جزو پزشکان تخصیص‌یافته به این منشی — عضویت در کلینیک به‌تنهایی
* یعنی منشیِ یک پزشک بتواند نوبت پزشک دیگری را دست‌کاری کند.
*/
private function secretaryCan(Appointment $appointment, User $user, string $action): bool
{
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
if ($dbUuid === null) {
return false;
}
$clinic = $this->clinicRepo->findByUuid($dbUuid);
if ($clinic !== null) {
if ($appointment->getClinic()?->getId() !== $clinic->getId()) {
return false;
}
$relation = $this->secretaryRepo->findActiveClinicRow($user, $clinic, $appointment->getDoctor());
return $relation !== null && $this->secretaryPermissions->can($relation, self::RESOURCE, $action);
}
$doctor = $this->doctorRepo->findByUuid($dbUuid);
if ($doctor === null || $doctor->getId() !== $appointment->getDoctor()->getId()) {
return false;
}
$relation = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor);
return $relation !== null && $this->secretaryPermissions->can($relation, self::RESOURCE, $action);
}
}
@@ -3,7 +3,13 @@
namespace App\Appointment\Service;
use App\Appointment\Entity\Appointment;
use App\Appointment\Repository\AppointmentRepository;
use App\Auth\Entity\User;
use App\Patient\Entity\PatientSession;
use App\Patient\Service\PatientService;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
@@ -16,8 +22,10 @@ use Psr\Log\LoggerInterface;
class AppointmentConfirmationService
{
public function __construct(
private readonly PatientService $patientService,
private readonly LoggerInterface $logger,
private readonly PatientService $patientService,
private readonly AppointmentRepository $appointmentRepo,
private readonly EntityManagerInterface $em,
private readonly LoggerInterface $logger,
) {}
/**
@@ -27,20 +35,66 @@ class AppointmentConfirmationService
* رزرو شده و پول پرداخت شده است؛ پرونده را می‌شود با
* `app:appointment:backfill-sessions` ساخت، ولی رول‌بکِ پرداخت برگشت‌ناپذیر است.
*/
public function onConfirmed(Appointment $appointment): void
public function onConfirmed(Appointment $appointment): ?PatientSession
{
// نوبت رزروِ روز-محور اسلات و ساعت مشخص ندارد؛ مراجعهٔ زمان‌دار برایش معنا ندارد.
if ($appointment->isReserve()) {
return;
return null;
}
try {
$this->patientService->autoCreateOnAppointmentConfirm($appointment);
return $this->patientService->autoCreateOnAppointmentConfirm($appointment);
} catch (\Throwable $e) {
$this->logger->error('Auto-creating the patient record on confirm failed', [
'appointment_uuid' => $appointment->getUuid(),
'exception' => $e,
]);
return null;
}
}
/**
* قطعی‌کردنِ صریح از پنل: انتقال وضعیت، ثبت پرونده/مراجعه و ثبت پرداخت‌ها — همه
* در یک تراکنش. برخلاف onConfirmed اینجا شکست خاموش نمی‌ماند: کاربر روبه‌روی
* مودالی ایستاده که مبلغ نشان داده و منتظر تأیید است؛ «قطعی شد ولی پول ثبت نشد»
* بدترین خروجیِ ممکن است.
*
* @param array<int, array{method: string, amount_rials: int}> $payments
* @return PatientSession|null null یعنی این tenant قابلیت پرونده را ندارد
* (فقط وقتی مجاز است که پرداختی هم ارسال نشده باشد)
*/
public function confirmWithPayments(
Appointment $appointment,
int $expectedVersion,
array $payments,
User $actor,
): ?PatientSession {
return $this->em->wrapInTransaction(function () use ($appointment, $expectedVersion, $payments, $actor) {
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
$this->appointmentRepo->saveWithLock($appointment, $expectedVersion);
$session = $this->patientService->autoCreateOnAppointmentConfirm($appointment);
if ($session === null) {
if ($payments !== []) {
throw new AppException(ErrorCodes::ERR_SUBSCRIPTION_REQUIRED, null, 403);
}
return null;
}
foreach ($payments as $payment) {
$this->patientService->addSessionPayment(
$session,
$payment['method'],
$payment['amount_rials'],
null,
$actor,
);
}
return $session;
});
}
}
+68 -85
View File
@@ -3,18 +3,16 @@
namespace App\Patient\Controller;
use App\Auth\Entity\User;
use App\Auth\Repository\UserActiveContextRepository;
use App\Auth\Repository\UserRepository;
use App\Billing\Repository\InvoiceRepository;
use App\Billing\Service\ClaimService;
use App\Billing\Service\InvoiceService;
use Psr\Log\LoggerInterface;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Patient\Entity\PatientRecord;
use App\Secretary\Repository\DoctorSecretaryRepository;
use App\Patient\Repository\PatientRecordRepository;
use App\Patient\Repository\PatientSessionRepository;
use App\Patient\Security\PatientRecordScope;
use App\Patient\Security\PatientRecordScopeResolver;
use App\Patient\Service\PatientService;
use App\UserProfile\Entity\UserProfile;
use App\Shared\Constant\ErrorCodes;
@@ -38,17 +36,13 @@ class PatientController extends BaseController
private readonly PatientService $patientService,
private readonly SubscriptionService $subscriptionService,
private readonly UserRepository $userRepo,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly DoctorSecretaryRepository $secretaryRepo,
private readonly UserActiveContextRepository $contextRepo,
private readonly PatientRecordScopeResolver $scopeResolver,
private readonly \App\UserProfile\Repository\UserProfileRepository $profileRepo,
private readonly \App\Insurance\Repository\InsuranceRepository $insuranceRepo,
private readonly InvoiceService $invoiceService,
private readonly ClaimService $claimService,
private readonly InvoiceRepository $invoiceRepo,
private readonly \App\Appointment\Repository\AppointmentRepository $appointmentRepo,
private readonly \App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository $invitationRepo,
private readonly \App\Tag\Repository\TenantTagRepository $tenantTagRepo,
private readonly \App\Patient\Repository\PatientAttachmentRepository $attachmentRepo,
private readonly \App\Patient\Repository\PatientMedicalRecordRepository $medicalRepo,
@@ -79,7 +73,7 @@ class PatientController extends BaseController
{
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
}
@@ -102,7 +96,7 @@ class PatientController extends BaseController
{
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
}
@@ -123,7 +117,7 @@ class PatientController extends BaseController
{
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
}
@@ -149,7 +143,7 @@ class PatientController extends BaseController
{
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
}
@@ -184,7 +178,7 @@ class PatientController extends BaseController
{
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
}
@@ -224,7 +218,7 @@ class PatientController extends BaseController
{
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
}
@@ -243,7 +237,7 @@ class PatientController extends BaseController
{
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
}
@@ -279,7 +273,7 @@ class PatientController extends BaseController
{
[$entityType, $entityId] = $this->resolveEntity($user);
$call = $this->callRepo->findByUuid($uuid);
if ($call === null || !$this->ownsRecord($call->getRecord(), $entityType, $entityId)) {
if ($call === null || !$this->ownsRecord($call->getRecord(), $entityType, $entityId, $user)) {
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
}
@@ -295,7 +289,7 @@ class PatientController extends BaseController
{
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
}
@@ -310,7 +304,7 @@ class PatientController extends BaseController
{
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
}
@@ -335,7 +329,7 @@ class PatientController extends BaseController
{
[$entityType, $entityId] = $this->resolveEntity($user);
$message = $this->messageRepo->findByUuid($uuid);
if ($message === null || !$this->ownsRecord($message->getRecord(), $entityType, $entityId)) {
if ($message === null || !$this->ownsRecord($message->getRecord(), $entityType, $entityId, $user)) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پیام یافت نشد', 404);
}
@@ -354,7 +348,7 @@ class PatientController extends BaseController
{
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
}
@@ -369,7 +363,7 @@ class PatientController extends BaseController
{
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
}
@@ -391,7 +385,7 @@ class PatientController extends BaseController
{
[$entityType, $entityId] = $this->resolveEntity($user);
$note = $this->noteRepo->findByUuid($uuid);
if ($note === null || !$this->ownsRecord($note->getRecord(), $entityType, $entityId)) {
if ($note === null || !$this->ownsRecord($note->getRecord(), $entityType, $entityId, $user)) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'یادداشت یافت نشد', 404);
}
@@ -416,7 +410,7 @@ class PatientController extends BaseController
{
[$entityType, $entityId] = $this->resolveEntity($user);
$note = $this->noteRepo->findByUuid($uuid);
if ($note === null || !$this->ownsRecord($note->getRecord(), $entityType, $entityId)) {
if ($note === null || !$this->ownsRecord($note->getRecord(), $entityType, $entityId, $user)) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'یادداشت یافت نشد', 404);
}
@@ -432,7 +426,7 @@ class PatientController extends BaseController
{
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
}
@@ -447,7 +441,7 @@ class PatientController extends BaseController
{
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
}
@@ -471,7 +465,7 @@ class PatientController extends BaseController
{
[$entityType, $entityId] = $this->resolveEntity($user);
$medical = $this->medicalRepo->findByUuid($uuid);
if ($medical === null || !$this->ownsRecord($medical->getRecord(), $entityType, $entityId)) {
if ($medical === null || !$this->ownsRecord($medical->getRecord(), $entityType, $entityId, $user)) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'رکورد پزشکی یافت نشد', 404);
}
@@ -501,7 +495,7 @@ class PatientController extends BaseController
{
[$entityType, $entityId] = $this->resolveEntity($user);
$medical = $this->medicalRepo->findByUuid($uuid);
if ($medical === null || !$this->ownsRecord($medical->getRecord(), $entityType, $entityId)) {
if ($medical === null || !$this->ownsRecord($medical->getRecord(), $entityType, $entityId, $user)) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'رکورد پزشکی یافت نشد', 404);
}
@@ -517,7 +511,7 @@ class PatientController extends BaseController
{
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
}
@@ -532,7 +526,7 @@ class PatientController extends BaseController
{
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
}
@@ -554,7 +548,7 @@ class PatientController extends BaseController
{
[$entityType, $entityId] = $this->resolveEntity($user);
$attachment = $this->attachmentRepo->findByUuid($uuid);
if ($attachment === null || !$this->ownsRecord($attachment->getRecord(), $entityType, $entityId)) {
if ($attachment === null || !$this->ownsRecord($attachment->getRecord(), $entityType, $entityId, $user)) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'ضمیمه یافت نشد', 404);
}
@@ -668,8 +662,9 @@ class PatientController extends BaseController
'has_debt' => $request->query->getBoolean('has_debt'),
];
$records = $this->recordRepo->findByEntity($entityType, $entityId, $page, $limit, $search, $filters);
$total = $this->recordRepo->countByEntity($entityType, $entityId, $search, $filters);
$restrictTo = $this->scope($user)->restrictToDoctorIds;
$records = $this->recordRepo->findByEntity($entityType, $entityId, $page, $limit, $search, $filters, $restrictTo);
$total = $this->recordRepo->countByEntity($entityType, $entityId, $search, $filters, $restrictTo);
// کد ملی روی profiles ذخیره می‌شود نه users؛ اگر روی user خالی بود از پروفایل پر کن.
$userIds = array_map(fn(PatientRecord $r) => $r->getUser()->getId(), $records);
@@ -768,7 +763,7 @@ class PatientController extends BaseController
$this->assertPatientGate($entityType, $entityId);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
}
@@ -785,7 +780,7 @@ class PatientController extends BaseController
$this->assertPatientGate($entityType, $entityId);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
}
@@ -918,7 +913,7 @@ class PatientController extends BaseController
$this->assertPatientGate($entityType, $entityId);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
}
@@ -944,17 +939,15 @@ class PatientController extends BaseController
$this->assertPatientGate($entityType, $entityId);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
}
// نوبت‌های این بیمار فقط با پزشک(های) همین ارائه‌دهنده نمایش داده می‌شوند تا
// نوبت‌های او با کلینیک‌های دیگر نشت نکند.
$doctorIds = $entityType === 'doctor'
? [$entityId]
: $this->invitationRepo->acceptedDoctorIdsByClinic($entityId);
$appointments = $this->appointmentRepo->findByUserAndDoctorIds($record->getUser(), $doctorIds);
// محیط نوبت با appointment.clinic بیان می‌شود. تکیه بر عضویت فعلیِ پزشک
// یعنی با پایان همکاری، تاریخچهٔ نوبت‌های همان کلینیک از پرونده ناپدید شود.
$appointments = $entityType === 'doctor'
? $this->appointmentRepo->findByUserAndDoctorIds($record->getUser(), [$entityId])
: $this->appointmentRepo->findByUserAndClinic($record->getUser(), $entityId);
return $this->success(array_map(fn(\App\Appointment\Entity\Appointment $a) => [
'uuid' => $a->getUuid(),
@@ -996,7 +989,7 @@ class PatientController extends BaseController
$this->assertPatientGate($entityType, $entityId);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
}
@@ -1039,7 +1032,7 @@ class PatientController extends BaseController
$this->assertPatientGate($entityType, $entityId);
$session = $this->sessionRepo->findByUuid($uuid);
if ($session === null || !$this->ownsRecord($session->getRecord(), $entityType, $entityId)) {
if ($session === null || !$this->ownsRecord($session->getRecord(), $entityType, $entityId, $user)) {
return $this->error(ErrorCodes::ERR_SESSION_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SESSION_NOT_FOUND), 404);
}
@@ -1109,7 +1102,7 @@ class PatientController extends BaseController
$this->assertPatientGate($entityType, $entityId);
$session = $this->sessionRepo->findByUuid($uuid);
if ($session === null || !$this->ownsRecord($session->getRecord(), $entityType, $entityId)) {
if ($session === null || !$this->ownsRecord($session->getRecord(), $entityType, $entityId, $user)) {
return $this->error(ErrorCodes::ERR_SESSION_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SESSION_NOT_FOUND), 404);
}
@@ -1134,7 +1127,7 @@ class PatientController extends BaseController
$this->assertPatientGate($entityType, $entityId);
$session = $this->sessionRepo->findByUuid($uuid);
if ($session === null || !$this->ownsRecord($session->getRecord(), $entityType, $entityId)) {
if ($session === null || !$this->ownsRecord($session->getRecord(), $entityType, $entityId, $user)) {
return $this->error(ErrorCodes::ERR_SESSION_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SESSION_NOT_FOUND), 404);
}
$payment = $this->sessionPaymentRepo->findByUuid($paymentUuid);
@@ -1155,7 +1148,7 @@ class PatientController extends BaseController
$this->assertPatientGate($entityType, $entityId);
$session = $this->sessionRepo->findByUuid($uuid);
if ($session === null || !$this->ownsRecord($session->getRecord(), $entityType, $entityId)) {
if ($session === null || !$this->ownsRecord($session->getRecord(), $entityType, $entityId, $user)) {
return $this->error(ErrorCodes::ERR_SESSION_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SESSION_NOT_FOUND), 404);
}
$payment = $this->sessionPaymentRepo->findByUuid($paymentUuid);
@@ -1176,46 +1169,25 @@ class PatientController extends BaseController
$this->assertPatientGate($entityType, $entityId);
$session = $this->sessionRepo->findByUuid($uuid);
if ($session === null || !$this->ownsRecord($session->getRecord(), $entityType, $entityId)) {
if ($session === null || !$this->ownsRecord($session->getRecord(), $entityType, $entityId, $user)) {
return $this->error(ErrorCodes::ERR_SESSION_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SESSION_NOT_FOUND), 404);
}
return $this->success($this->sessionAuditRepo->findBySessionUuid($uuid));
}
/** @var array<int, PatientRecordScope> حل‌شده یک‌بار در هر درخواست، نه یک‌بار به‌ازای هر چک. */
private array $scopeCache = [];
private function scope(User $user): PatientRecordScope
{
return $this->scopeCache[$user->getId()] ??= $this->scopeResolver->resolve($user);
}
/** @return array{0: string, 1: int|null} */
private function resolveEntity(User $user): array
{
if ($user->hasRole('ROLE_DOCTOR')) {
$doctor = $this->doctorRepo->findByUser($user);
return $doctor !== null ? ['doctor', $doctor->getId()] : ['doctor', null];
}
if ($user->hasRole('ROLE_CLINIC')) {
$clinic = $this->clinicRepo->findByUser($user);
return $clinic !== null ? ['clinic', $clinic->getId()] : ['clinic', null];
}
if ($user->hasRole('ROLE_SECRETARY')) {
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
if ($dbUuid !== null) {
$clinic = $this->clinicRepo->findByUuid($dbUuid);
if ($clinic !== null) {
$rel = $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic);
if ($rel !== null) {
return ['clinic', $clinic->getId()];
}
}
$doctor = $this->doctorRepo->findByUuid($dbUuid);
if ($doctor !== null) {
$rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor);
if ($rel !== null) {
return ['doctor', $doctor->getId()];
}
}
}
}
return ['unknown', null];
return $this->scope($user)->toLegacyTuple();
}
private function assertPatientGate(string $entityType, ?int $entityId): void
@@ -1229,10 +1201,21 @@ class PatientController extends BaseController
}
}
private function ownsRecord($record, string $entityType, ?int $entityId): bool
/**
* محیط پرونده باید همان محیط کاربر باشد، و اگر دسترسی کاربر به بیمارانِ پزشک(های)
* مشخصی محدود است، پرونده هم باید در همان محدوده بیفتد — همان قاعدهٔ لیست.
*/
private function ownsRecord($record, string $entityType, ?int $entityId, User $user): bool
{
return $entityId !== null
&& $record->getEntityType() === $entityType
&& $record->getEntityId() === $entityId;
if ($entityId === null
|| $record->getEntityType() !== $entityType
|| $record->getEntityId() !== $entityId) {
return false;
}
return $this->recordRepo->isVisibleToDoctors(
$record,
$this->scope($user)->restrictToDoctorIds,
);
}
}
@@ -34,10 +34,11 @@ class PatientRecordRepository extends ServiceEntityRepository
* (pending|completed), has_debt(bool)
* @return list<PatientRecord>
*/
public function findByEntity(string $entityType, int $entityId, int $page = 1, int $limit = 20, ?string $search = null, array $filters = []): array
public function findByEntity(string $entityType, int $entityId, int $page = 1, int $limit = 20, ?string $search = null, array $filters = [], ?array $restrictToDoctorIds = null): array
{
$qb = $this->baseQuery($entityType, $entityId);
$this->applyFilters($qb, $search, $filters);
$this->applyDoctorRestriction($qb, $entityType, $entityId, $restrictToDoctorIds);
return $qb->orderBy('r.id', 'DESC')
->setFirstResult(($page - 1) * $limit)
@@ -47,14 +48,76 @@ class PatientRecordRepository extends ServiceEntityRepository
}
/** @param array<string, mixed> $filters same shape as {@see findByEntity}. */
public function countByEntity(string $entityType, int $entityId, ?string $search = null, array $filters = []): int
public function countByEntity(string $entityType, int $entityId, ?string $search = null, array $filters = [], ?array $restrictToDoctorIds = null): int
{
$qb = $this->baseQuery($entityType, $entityId)->select('COUNT(r.id)');
$this->applyFilters($qb, $search, $filters);
$this->applyDoctorRestriction($qb, $entityType, $entityId, $restrictToDoctorIds);
return (int) $qb->getQuery()->getSingleScalarResult();
}
/**
* آیا این پرونده در دسترسِ محدودشدهٔ این پزشک(ها) هست؟ همان قاعدهٔ لیست، برای یک
* رکورد — تا detail و list هرگز از هم واگرا نشوند.
*
* @param int[]|null $restrictToDoctorIds
*/
public function isVisibleToDoctors(PatientRecord $record, ?array $restrictToDoctorIds): bool
{
if ($restrictToDoctorIds === null) {
return true;
}
if ($restrictToDoctorIds === []) {
return false;
}
$qb = $this->createQueryBuilder('r')
->select('COUNT(r.id)')
->where('r.id = :recordId')
->setParameter('recordId', $record->getId());
$this->applyDoctorRestriction($qb, $record->getEntityType(), $record->getEntityId(), $restrictToDoctorIds);
return (int) $qb->getQuery()->getSingleScalarResult() > 0;
}
/**
* پزشکِ عضو فقط بیمارانِ خودش را می‌بیند. پروندهٔ کلینیکی ستون پزشک ندارد
* (یکتایی clinic+user)، پس رابطه از نوبت‌های همان پزشک در همان کلینیک می‌آید —
* نه از session، چون مراجعهٔ دستی اصلاً پزشک ثبت‌شده ندارد.
*
* @param int[]|null $restrictToDoctorIds
*/
private function applyDoctorRestriction(
\Doctrine\ORM\QueryBuilder $qb,
string $entityType,
?int $entityId,
?array $restrictToDoctorIds,
): void {
if ($restrictToDoctorIds === null || $entityType !== 'clinic') {
return;
}
if ($restrictToDoctorIds === []) {
$qb->andWhere('1 = 0');
return;
}
$qb->andWhere(
$qb->expr()->exists(
'SELECT 1 FROM App\Appointment\Entity\Appointment ra
WHERE ra.user = r.user
AND IDENTITY(ra.clinic) = :restrictClinicId
AND IDENTITY(ra.doctor) IN (:restrictDoctorIds)'
)
)
->setParameter('restrictClinicId', $entityId)
->setParameter('restrictDoctorIds', $restrictToDoctorIds);
}
private function baseQuery(string $entityType, int $entityId): \Doctrine\ORM\QueryBuilder
{
return $this->createQueryBuilder('r')
@@ -0,0 +1,59 @@
<?php
namespace App\Patient\Security;
/**
* محیطی که پرونده‌های بیمار در آن خوانده/نوشته می‌شوند، به‌همراه محدودیت اختیاریِ
* «فقط بیمارانِ این پزشک(ها)».
*
* پروندهٔ کلینیکی per-بیمار است نه per-پزشک (یکتایی clinic+user)، پس محدودسازیِ
* پزشکِ عضو نمی‌تواند روی خودِ پرونده باشد؛ از مسیر نوبت‌های همان پزشک در همان
* کلینیک استخراج می‌شود.
*/
final class PatientRecordScope
{
/** @param int[]|null $restrictToDoctorIds null یعنی بدون محدودیت (مدیر/مالک) */
private function __construct(
public readonly string $entityType,
public readonly ?int $entityId,
public readonly ?array $restrictToDoctorIds,
) {}
public static function forDoctor(?int $doctorId): self
{
return new self('doctor', $doctorId, null);
}
/** مدیر کلینیک: همهٔ پرونده‌های کلینیک. */
public static function forClinic(?int $clinicId): self
{
return new self('clinic', $clinicId, null);
}
/**
* پزشک عضو یا منشی: پرونده‌های کلینیک، محدود به بیمارانِ این پزشک(ها).
* لیست خالی یعنی هیچ پزشکی تخصیص نیافته ⇒ هیچ پرونده‌ای.
*
* @param int[] $doctorIds
*/
public static function forClinicRestrictedToDoctors(int $clinicId, array $doctorIds): self
{
return new self('clinic', $clinicId, array_values(array_unique($doctorIds)));
}
public static function unknown(): self
{
return new self('unknown', null, null);
}
public function isRestricted(): bool
{
return $this->restrictToDoctorIds !== null;
}
/** @return array{0: string, 1: int|null} سازگار با امضای قدیمیِ resolveEntity. */
public function toLegacyTuple(): array
{
return [$this->entityType, $this->entityId];
}
}
@@ -0,0 +1,115 @@
<?php
namespace App\Patient\Security;
use App\Auth\Entity\User;
use App\Auth\Repository\UserActiveContextRepository;
use App\Clinic\Repository\ClinicRepository;
use App\Clinic\Security\ClinicDoctorPermissionChecker;
use App\Doctor\Repository\DoctorRepository;
use App\Secretary\Repository\DoctorSecretaryRepository;
/**
* «پرونده‌های کدام محیط را این کاربر می‌بیند؟»
*
* پیش از این، نگاشت تک‌مقصدی بود: نقش پزشک همیشه به پروندهٔ مطب شخصی می‌رسید، پس
* پزشکِ دعوت‌شده به کلینیک پرونده‌های بیمارانش در آن کلینیک را اصلاً نمی‌دید. محیط
* فعال (UserActiveContext) تعیین‌کننده است، دقیقاً مثل EntityContextResolver.
*
* «پایان همکاری» منبع حقیقتِ جدا ندارد: ClinicDoctorPermission/DoctorSecretary با
* active=false خودشان رد می‌کنند.
*/
class PatientRecordScopeResolver
{
private const RESOURCE = 'patients';
public function __construct(
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly UserActiveContextRepository $contextRepo,
private readonly DoctorSecretaryRepository $secretaryRepo,
private readonly ClinicDoctorPermissionChecker $clinicPermissions,
) {}
public function resolve(User $user): PatientRecordScope
{
if ($user->hasRole('ROLE_DOCTOR')) {
return $this->forDoctorUser($user);
}
if ($user->hasRole('ROLE_CLINIC')) {
$clinic = $this->clinicRepo->findByUser($user);
return PatientRecordScope::forClinic($clinic?->getId());
}
if ($user->hasRole('ROLE_SECRETARY')) {
return $this->forSecretary($user);
}
return PatientRecordScope::unknown();
}
/**
* پزشک در محیط کلینیکِ فعالش پرونده‌های همان کلینیک را می‌بیند — محدود به
* بیمارانِ خودش. بیرون از آن محیط، فقط پروندهٔ مطب شخصی.
*/
private function forDoctorUser(User $user): PatientRecordScope
{
$doctor = $this->doctorRepo->findByUser($user);
if ($doctor === null) {
return PatientRecordScope::forDoctor(null);
}
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
$clinic = $dbUuid !== null ? $this->clinicRepo->findByUuid($dbUuid) : null;
if ($clinic === null || !$clinic->hasDoctor($doctor)) {
return PatientRecordScope::forDoctor($doctor->getId());
}
// مالک کلینیکی که خودش پزشک هم هست، محدود نمی‌شود.
if ($clinic->getUser()->getId() === $user->getId()) {
return PatientRecordScope::forClinic($clinic->getId());
}
if (!$this->clinicPermissions->can($user, $clinic, self::RESOURCE, 'view')) {
return PatientRecordScope::forDoctor($doctor->getId());
}
return PatientRecordScope::forClinicRestrictedToDoctors($clinic->getId(), [$doctor->getId()]);
}
/**
* منشی در محیط فعالش. در کلینیک، فقط بیمارانِ پزشکانِ تخصیص‌یافته به او —
* عضویت در کلینیک به‌تنهایی یعنی منشیِ یک پزشک پروندهٔ بیماران پزشک دیگر را ببیند.
*/
private function forSecretary(User $user): PatientRecordScope
{
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
if ($dbUuid === null) {
return PatientRecordScope::unknown();
}
$clinic = $this->clinicRepo->findByUuid($dbUuid);
if ($clinic !== null) {
if ($this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic) === null) {
return PatientRecordScope::unknown();
}
$doctorIds = array_map(
fn($d) => $d->getId(),
$this->secretaryRepo->findDoctorsBySecretaryInClinic($user, $clinic),
);
return PatientRecordScope::forClinicRestrictedToDoctors($clinic->getId(), $doctorIds);
}
$doctor = $this->doctorRepo->findByUuid($dbUuid);
if ($doctor !== null && $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor) !== null) {
return PatientRecordScope::forDoctor($doctor->getId());
}
return PatientRecordScope::unknown();
}
}
+10 -6
View File
@@ -130,7 +130,7 @@ class PatientService
* محیط رزرو تعیین‌کننده است: کلینیک، یا مطب شخصی پزشک — هرگز هر دو. دو پرونده
* برای یک نوبت یعنی درآمد یک ویزیت دو بار شمرده می‌شود.
*/
public function autoCreateOnAppointmentConfirm(Appointment $appointment): void
public function autoCreateOnAppointmentConfirm(Appointment $appointment): ?PatientSession
{
$clinic = $appointment->getClinic();
@@ -138,10 +138,11 @@ class PatientService
? ['clinic', (int) $clinic->getId()]
: ['doctor', (int) $appointment->getDoctor()->getId()];
$this->autoCreateForEntity($entityType, $entityId, $appointment, $entityId);
return $this->autoCreateForEntity($entityType, $entityId, $appointment, $entityId);
}
private function autoCreateForEntity(string $entityType, int $entityId, Appointment $appointment, int $createdById): void
/** مراجعهٔ ساخته‌شده یا موجود؛ null یعنی این tenant قابلیت پرونده را ندارد. */
private function autoCreateForEntity(string $entityType, int $entityId, Appointment $appointment, int $createdById): ?PatientSession
{
if (!$this->subscriptionService->hasFeature($entityType, $entityId, 'patient_records')) {
// به‌زور پرونده نمی‌سازیم، ولی بی‌نشانه هم رد نمی‌شویم: بدون این لاگ،
@@ -152,11 +153,12 @@ class PatientService
'appointment_uuid' => $appointment->getUuid(),
]);
return;
return null;
}
if ($this->sessionRepo->findByAppointmentAndEntity($appointment, $entityType, $entityId) !== null) {
return;
$existing = $this->sessionRepo->findByAppointmentAndEntity($appointment, $entityType, $entityId);
if ($existing !== null) {
return $existing;
}
$patient = $appointment->getUser();
@@ -196,6 +198,8 @@ class PatientService
$this->sessionServiceRepo->save($line);
$session->addService($line);
}
return $session;
}
public function createSession(
@@ -0,0 +1,302 @@
<?php
namespace App\Tests\Appointment;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Patient\Entity\PatientRecord;
use App\Patient\Entity\PatientSession;
use App\Tests\ApiTestCase;
/**
* «ثبت‌شده» → «قطعی»: POST /api/v1/appointment/{uuid}/confirm.
*
* یک عملِ اتمیک — وضعیت نوبت، پروندهٔ همان محیط با سرویس‌های نوبت، و پرداخت کامل یا
* جزئی روی همان مراجعه.
*/
class AppointmentConfirmFlowTest extends ApiTestCase
{
private function makeDoctor(string $name = 'دکتر تست'): Doctor
{
$user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$doctor = new Doctor($user, $name);
$doctor->setMobileNumber($user->getMobileNumber());
$this->em->persist($doctor);
$this->em->flush();
return $doctor;
}
/** @return array{0: User, 1: Clinic} */
private function makeClinicWith(Doctor ...$doctors): array
{
$owner = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($owner);
$clinic->setName('کلینیک تست');
foreach ($doctors as $d) {
$clinic->getDoctors()->add($d);
}
$this->em->persist($clinic);
$this->em->flush();
return [$owner, $clinic];
}
private function makeAppointment(
Doctor $doctor,
?Clinic $clinic = null,
?User $patient = null,
int $visitPriceRials = 5_000_000,
): Appointment {
$patient ??= $this->createUser();
// اسلات یکتا به‌ازای هر نوبت: db_test بین اجراها پاک نمی‌شود.
$start = strtotime('+30 days') + random_int(0, 500_000) * 7;
$appointment = new Appointment($doctor, $patient, $start, $start + 900);
$appointment->setClinic($clinic);
$appointment->setVisitPriceRials($visitPriceRials);
$this->em->persist($appointment);
$this->em->flush();
return $appointment;
}
private function reload(Appointment $a): Appointment
{
$this->em->clear();
return $this->em->getRepository(Appointment::class)->find($a->getId());
}
// ── ساخت پنلی «ثبت‌شده» است، نه قطعی ─────────────────────────────────────
public function testPanelBookingIsCreatedPending(): void
{
$doctor = $this->makeDoctor();
$start = strtotime('+40 days') + random_int(0, 500_000) * 7;
$res = $this->authJson('POST', '/api/v1/my/appointment', $doctor->getUser(), [
'doctor_uuid' => $doctor->getUuid(),
'slot_start' => $start,
'slot_end' => $start + 1_800,
'patient_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT),
'patient_name' => 'بیمار تست',
'patient_national_code' => '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT),
]);
self::assertSame(201, $this->responseCode());
self::assertSame(Appointment::STATUS_PENDING, $res['data']['status']);
}
// ── قطعی‌کردن: بدون پرداخت / جزئی / کامل ─────────────────────────────────
public function testConfirmWithoutPaymentMovesToConfirmedAndOpensSession(): void
{
$doctor = $this->makeDoctor();
$appointment = $this->makeAppointment($doctor);
$res = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $doctor->getUser(), [
'version' => $appointment->getVersion(),
]);
self::assertSame(200, $this->responseCode());
self::assertSame(Appointment::STATUS_CONFIRMED, $res['data']['appointment']['status']);
self::assertSame(5_000_000, $res['data']['session']['final_price_rials']);
self::assertSame(0, $res['data']['session']['paid_total_rials']);
self::assertSame(5_000_000, $res['data']['session']['remaining_rials']);
self::assertFalse($res['data']['session']['is_paid']);
}
public function testConfirmWithPartialPaymentLeavesRemainder(): void
{
$doctor = $this->makeDoctor();
$appointment = $this->makeAppointment($doctor);
$res = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $doctor->getUser(), [
'version' => $appointment->getVersion(),
'payments' => [['method' => 'cash', 'amount_rials' => 2_000_000]],
]);
self::assertSame(200, $this->responseCode());
self::assertSame(2_000_000, $res['data']['session']['paid_total_rials']);
self::assertSame(3_000_000, $res['data']['session']['remaining_rials']);
self::assertFalse($res['data']['session']['is_paid']);
}
public function testConfirmWithFullPaymentSettlesSession(): void
{
$doctor = $this->makeDoctor();
$appointment = $this->makeAppointment($doctor);
$res = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $doctor->getUser(), [
'version' => $appointment->getVersion(),
'payments' => [['method' => 'pos', 'amount_rials' => 5_000_000]],
]);
self::assertSame(200, $this->responseCode());
self::assertSame(0, $res['data']['session']['remaining_rials']);
self::assertTrue($res['data']['session']['is_paid']);
}
public function testConfirmAcceptsSeveralPaymentRows(): void
{
$doctor = $this->makeDoctor();
$appointment = $this->makeAppointment($doctor);
$res = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $doctor->getUser(), [
'version' => $appointment->getVersion(),
'payments' => [
['method' => 'cash', 'amount_rials' => 1_000_000],
['method' => 'pos', 'amount_rials' => 4_000_000],
],
]);
self::assertSame(200, $this->responseCode());
self::assertSame(5_000_000, $res['data']['session']['paid_total_rials']);
self::assertTrue($res['data']['session']['is_paid']);
}
// ── پرونده: استفادهٔ مجدد یا ساخت ────────────────────────────────────────
public function testConfirmReusesExistingRecordOfSameDoctor(): void
{
$doctor = $this->makeDoctor();
$patient = $this->createUser();
$first = $this->makeAppointment($doctor, null, $patient);
$this->authJson('POST', "/api/v1/appointment/{$first->getUuid()}/confirm", $doctor->getUser(), [
'version' => $first->getVersion(),
]);
self::assertSame(200, $this->responseCode());
$second = $this->makeAppointment($doctor, null, $patient);
$this->authJson('POST', "/api/v1/appointment/{$second->getUuid()}/confirm", $doctor->getUser(), [
'version' => $second->getVersion(),
]);
self::assertSame(200, $this->responseCode());
$records = $this->em->getRepository(PatientRecord::class)->findBy([
'entityType' => 'doctor',
'entityId' => $doctor->getId(),
'user' => $patient,
]);
self::assertCount(1, $records, 'پروندهٔ همان پزشک دوباره ساخته نمی‌شود');
$sessions = $this->em->getRepository(PatientSession::class)->findBy(['record' => $records[0]]);
self::assertCount(2, $sessions, 'هر نوبت مراجعهٔ خودش را دارد');
}
public function testConfirmInClinicFilesUnderClinicRecord(): void
{
$doctor = $this->makeDoctor();
[$owner, $clinic] = $this->makeClinicWith($doctor);
$patient = $this->createUser();
$appointment = $this->makeAppointment($doctor, $clinic, $patient);
$this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $owner, [
'version' => $appointment->getVersion(),
]);
self::assertSame(200, $this->responseCode());
$clinicRecord = $this->em->getRepository(PatientRecord::class)->findOneBy([
'entityType' => 'clinic',
'entityId' => $clinic->getId(),
'user' => $patient,
]);
$doctorRecord = $this->em->getRepository(PatientRecord::class)->findOneBy([
'entityType' => 'doctor',
'entityId' => $doctor->getId(),
'user' => $patient,
]);
self::assertNotNull($clinicRecord, 'نوبت کلینیکی در پروندهٔ کلینیک می‌نشیند');
self::assertNull($doctorRecord, 'و در مطب شخصی پزشک پروندهٔ موازی نمی‌سازد');
}
// ── خطاها ────────────────────────────────────────────────────────────────
public function testPaymentAboveTotalIsRejectedAndNothingIsCommitted(): void
{
$doctor = $this->makeDoctor();
$appointment = $this->makeAppointment($doctor);
$this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $doctor->getUser(), [
'version' => $appointment->getVersion(),
'payments' => [['method' => 'cash', 'amount_rials' => 9_000_000]],
]);
self::assertSame(422, $this->responseCode());
self::assertSame(Appointment::STATUS_PENDING, $this->reload($appointment)->getStatus(), 'تراکنش برگشته');
}
public function testUnknownPaymentMethodIs422(): void
{
$doctor = $this->makeDoctor();
$appointment = $this->makeAppointment($doctor);
$this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $doctor->getUser(), [
'version' => $appointment->getVersion(),
'payments' => [['method' => 'bitcoin', 'amount_rials' => 1_000]],
]);
self::assertSame(422, $this->responseCode());
self::assertSame(Appointment::STATUS_PENDING, $this->reload($appointment)->getStatus());
}
public function testStaleVersionIs409(): void
{
$doctor = $this->makeDoctor();
$appointment = $this->makeAppointment($doctor);
$this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $doctor->getUser(), [
'version' => $appointment->getVersion() + 5,
]);
self::assertSame(409, $this->responseCode());
}
public function testConfirmingAnAlreadyConfirmedAppointmentIs422(): void
{
$doctor = $this->makeDoctor();
$appointment = $this->makeAppointment($doctor);
$this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $doctor->getUser(), [
'version' => $appointment->getVersion(),
]);
self::assertSame(200, $this->responseCode());
$fresh = $this->reload($appointment);
$this->authJson('POST', "/api/v1/appointment/{$fresh->getUuid()}/confirm", $doctor->getUser(), [
'version' => $fresh->getVersion(),
]);
self::assertSame(422, $this->responseCode(), 'confirmed → confirmed گذار مجاز نیست');
}
public function testStrangerCannotConfirm(): void
{
$doctor = $this->makeDoctor();
$appointment = $this->makeAppointment($doctor);
$this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $this->createUser(), [
'version' => $appointment->getVersion(),
]);
self::assertSame(403, $this->responseCode());
}
public function testDetailExposesServicePricesForTheModal(): void
{
$doctor = $this->makeDoctor();
$appointment = $this->makeAppointment($doctor);
$res = $this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}", $doctor->getUser());
self::assertSame(200, $this->responseCode());
self::assertSame(5_000_000, $res['data']['data']['visit_price_rials']);
self::assertArrayHasKey('service_items', $res['data']['data']);
}
}
@@ -28,6 +28,8 @@ class AppointmentExpiryServiceTest extends ApiTestCase
// distinct past slots — one live booking per (doctor, slot)
$slotStart = $past - $i * 1000;
$appt = new Appointment($doctor, $patient, $slotStart, $slotStart + 900);
// مثل مسیر واقعیِ رزرو آنلاین: نگه‌داشتِ موقت تا پرداخت درگاه.
$appt->markPendingWithTtl(-1);
$this->em->persist($appt);
$payment = new Payment($patient, 100_000, 'mellat', 'appointment');
@@ -53,4 +55,27 @@ class AppointmentExpiryServiceTest extends ApiTestCase
$this->assertSame(Payment::STATUS_CANCELED, $freshPay->getStatus());
}
}
/**
* نوبت «ثبت‌شده»ی پنل TTL ندارد؛ گذشتنِ ساعتِ نوبت نباید خودبه‌خود منقضی‌اش کند —
* قطعی/لغو کردنش تصمیم اپراتور است.
*/
public function testPanelRegisteredPendingSurvivesExpiry(): void
{
$owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر پنل');
$this->em->persist($doctor);
$slotStart = time() - 7200;
$appt = new Appointment($doctor, $this->createUser(['ROLE_USER']), $slotStart, $slotStart + 900);
$this->em->persist($appt);
$this->em->flush();
static::getContainer()->get(AppointmentExpiryService::class)->expireStale();
$this->em->clear();
$fresh = $this->em->getRepository(Appointment::class)->find($appt->getId());
$this->assertSame(Appointment::STATUS_PENDING, $fresh->getStatus());
}
}
@@ -0,0 +1,329 @@
<?php
namespace App\Tests\Appointment;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\Auth\Repository\UserActiveContextRepository;
use App\Clinic\Entity\Clinic;
use App\Clinic\Repository\ClinicDoctorPermissionRepository;
use App\Doctor\Entity\Doctor;
use App\Secretary\Entity\DoctorSecretary;
use App\Tests\ApiTestCase;
/**
* دسترسی به «یک نوبت مشخص» بر پایهٔ محیطِ خود نوبت (appointment.clinic) است، نه نقش
* کاربر. پیش از این مسیرهای تک‌نوبت فقط بیمار، پزشکِ مالک و ادمین را می‌شناختند و
* کاربر کلینیک روی نوبتی که خودش ساخته بود ۴۰۳ می‌گرفت.
*/
class ClinicAppointmentAccessTest extends ApiTestCase
{
private function makeDoctor(string $name = 'دکتر تست'): Doctor
{
$user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$doctor = new Doctor($user, $name);
$doctor->setMobileNumber($user->getMobileNumber());
$this->em->persist($doctor);
$this->em->flush();
return $doctor;
}
/** @return array{0: User, 1: Clinic} */
private function makeClinicWith(Doctor ...$doctors): array
{
$owner = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($owner);
$clinic->setName('کلینیک تست');
foreach ($doctors as $d) {
$clinic->getDoctors()->add($d);
}
$this->em->persist($clinic);
$this->em->flush();
return [$owner, $clinic];
}
private function makeAppointment(Doctor $doctor, ?Clinic $clinic, ?User $patient = null): Appointment
{
$patient ??= $this->createUser();
$start = strtotime('+3 days 10:00');
$appointment = new Appointment($doctor, $patient, $start, $start + 900);
$appointment->setClinic($clinic);
$this->em->persist($appointment);
$this->em->flush();
return $appointment;
}
private function makeClinicSecretary(Clinic $clinic, Doctor $doctor, array $permissionPatch = []): User
{
$user = $this->createUser(['ROLE_USER', 'ROLE_SECRETARY']);
$secretary = new DoctorSecretary($doctor, $user, DoctorSecretary::OWNER_CLINIC, $clinic);
if ($permissionPatch !== []) {
$secretary->mergePermissions(['resources' => ['appointments' => $permissionPatch]]);
}
$this->em->persist($secretary);
$this->em->flush();
static::getContainer()->get(UserActiveContextRepository::class)->upsert($user, $clinic->getUuid());
return $user;
}
public function testClinicOwnerCanViewClinicAppointment(): void
{
$doctor = $this->makeDoctor();
[$owner, $clinic] = $this->makeClinicWith($doctor);
$appointment = $this->makeAppointment($doctor, $clinic);
$this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}", $owner);
self::assertSame(200, $this->responseCode());
}
public function testClinicOwnerCanUpdateAndMoveClinicAppointment(): void
{
$doctor = $this->makeDoctor();
[$owner, $clinic] = $this->makeClinicWith($doctor);
$appointment = $this->makeAppointment($doctor, $clinic);
$newStart = strtotime('+4 days 11:00');
$this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}", $owner, [
'slot_start' => $newStart,
'slot_end' => $newStart + 900,
'note' => 'جابه‌جا شد',
'version' => $appointment->getVersion(),
]);
self::assertSame(200, $this->responseCode());
}
public function testClinicOwnerCanChangeStatusOfClinicAppointment(): void
{
$doctor = $this->makeDoctor();
[$owner, $clinic] = $this->makeClinicWith($doctor);
$appointment = $this->makeAppointment($doctor, $clinic);
$this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}/status", $owner, [
'status' => Appointment::STATUS_CONFIRMED,
'version' => $appointment->getVersion(),
]);
self::assertSame(200, $this->responseCode());
}
public function testClinicOwnerCanTransferAppointmentToReserveAndBack(): void
{
$doctor = $this->makeDoctor();
[$owner, $clinic] = $this->makeClinicWith($doctor);
$appointment = $this->makeAppointment($doctor, $clinic);
$midnight = strtotime('+3 days 00:00');
$this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}", $owner, [
'is_reserve' => true,
'slot_start' => $midnight,
'slot_end' => $midnight,
'version' => $appointment->getVersion(),
]);
self::assertSame(200, $this->responseCode(), 'انتقال به لیست رزرو');
$this->em->clear();
$reloaded = $this->em->getRepository(Appointment::class)->find($appointment->getId());
self::assertTrue($reloaded->isReserve());
$back = strtotime('+5 days 09:00');
$this->authJson('PATCH', "/api/v1/appointment/{$reloaded->getUuid()}", $owner, [
'is_reserve' => false,
'slot_start' => $back,
'slot_end' => $back + 900,
'version' => $reloaded->getVersion(),
]);
self::assertSame(200, $this->responseCode(), 'بازگشت از لیست رزرو');
}
public function testClinicOwnerCanReadAppointmentEvents(): void
{
$doctor = $this->makeDoctor();
[$owner, $clinic] = $this->makeClinicWith($doctor);
$appointment = $this->makeAppointment($doctor, $clinic);
$this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}/events", $owner);
self::assertSame(200, $this->responseCode());
}
public function testClinicOwnerCannotTouchDoctorPersonalAppointment(): void
{
$doctor = $this->makeDoctor();
[$owner] = $this->makeClinicWith($doctor);
$appointment = $this->makeAppointment($doctor, null);
$this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}", $owner);
self::assertSame(403, $this->responseCode(), 'مطب شخصی پزشک از دسترس کلینیک خارج است');
}
public function testForeignClinicOwnerIsDenied(): void
{
$doctor = $this->makeDoctor();
[, $clinic] = $this->makeClinicWith($doctor);
[$otherOwner] = $this->makeClinicWith($this->makeDoctor('دکتر دیگر'));
$appointment = $this->makeAppointment($doctor, $clinic);
$this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}", $otherOwner);
self::assertSame(403, $this->responseCode());
}
public function testMemberDoctorLosesAccessWhenDeactivated(): void
{
$member = $this->makeDoctor('دکتر عضو');
$colleague = $this->makeDoctor('همکار');
[, $clinic] = $this->makeClinicWith($member, $colleague);
$appointment = $this->makeAppointment($colleague, $clinic);
$permissions = static::getContainer()->get(ClinicDoctorPermissionRepository::class);
$permissions->getOrCreate($clinic, $member);
$this->em->flush();
$this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}", $member->getUser());
self::assertSame(200, $this->responseCode(), 'پزشک فعالِ کلینیک نوبت‌های همان کلینیک را می‌بیند');
$permissions->getOrCreate($clinic, $member)->setActive(false);
$this->em->flush();
$this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}", $member->getUser());
self::assertSame(403, $this->responseCode(), 'پس از پایان همکاری دسترسی قطع می‌شود');
}
public function testClinicSecretaryCanManageAssignedDoctorAppointment(): void
{
$doctor = $this->makeDoctor();
[, $clinic] = $this->makeClinicWith($doctor);
$secretary = $this->makeClinicSecretary($clinic, $doctor);
$appointment = $this->makeAppointment($doctor, $clinic);
$this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}", $secretary);
self::assertSame(200, $this->responseCode());
$this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}/status", $secretary, [
'status' => Appointment::STATUS_CONFIRMED,
'version' => $appointment->getVersion(),
]);
self::assertSame(200, $this->responseCode());
}
public function testClinicSecretaryCannotTouchUnassignedDoctorAppointment(): void
{
$mine = $this->makeDoctor('پزشک من');
$theirs = $this->makeDoctor('پزشک دیگر');
[, $clinic] = $this->makeClinicWith($mine, $theirs);
$secretary = $this->makeClinicSecretary($clinic, $mine);
$appointment = $this->makeAppointment($theirs, $clinic);
$this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}", $secretary);
self::assertSame(403, $this->responseCode(), 'منشی فقط پزشکان تخصیص‌یافتهٔ خودش را دارد');
}
public function testSecretaryCancelRequiresCancelPermission(): void
{
$doctor = $this->makeDoctor();
[, $clinic] = $this->makeClinicWith($doctor);
$secretary = $this->makeClinicSecretary($clinic, $doctor);
$appointment = $this->makeAppointment($doctor, $clinic);
$this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}/status", $secretary, [
'status' => Appointment::STATUS_CANCELLED_BY_DOCTOR,
'version' => $appointment->getVersion(),
]);
self::assertSame(403, $this->responseCode(), 'لغو به‌صورت پیش‌فرض برای منشی خاموش است');
}
public function testSecretaryWithCancelPermissionCanCancel(): void
{
$doctor = $this->makeDoctor();
[, $clinic] = $this->makeClinicWith($doctor);
$secretary = $this->makeClinicSecretary($clinic, $doctor, ['cancel' => true]);
$appointment = $this->makeAppointment($doctor, $clinic);
$this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}/status", $secretary, [
'status' => Appointment::STATUS_CANCELLED_BY_DOCTOR,
'version' => $appointment->getVersion(),
]);
self::assertSame(200, $this->responseCode());
}
public function testInlineStatusCannotBypassCancelGate(): void
{
$doctor = $this->makeDoctor();
[, $clinic] = $this->makeClinicWith($doctor);
$secretary = $this->makeClinicSecretary($clinic, $doctor);
$appointment = $this->makeAppointment($doctor, $clinic);
$this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}", $secretary, [
'status' => Appointment::STATUS_CANCELLED_BY_DOCTOR,
'version' => $appointment->getVersion(),
]);
self::assertSame(403, $this->responseCode(), 'status درون‌خطی همان گیت لغو را دارد');
}
public function testOwnerDoctorKeepsFullAccessToOwnClinicAppointment(): void
{
$doctor = $this->makeDoctor();
[, $clinic] = $this->makeClinicWith($doctor);
$appointment = $this->makeAppointment($doctor, $clinic);
$this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}", $doctor->getUser());
self::assertSame(200, $this->responseCode());
}
public function testPatientCanViewButNotRescheduleOwnAppointment(): void
{
$doctor = $this->makeDoctor();
$patient = $this->createUser();
$appointment = $this->makeAppointment($doctor, null, $patient);
$newStart = strtotime('+6 days 10:00');
$this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}", $patient);
self::assertSame(200, $this->responseCode());
$this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}", $patient, [
'slot_start' => $newStart,
'slot_end' => $newStart + 900,
'version' => $appointment->getVersion(),
]);
self::assertSame(403, $this->responseCode(), 'بیمار نوبت خودش را جابه‌جا نمی‌کند');
}
public function testPatientCanCancelOwnAppointment(): void
{
$doctor = $this->makeDoctor();
$patient = $this->createUser();
$appointment = $this->makeAppointment($doctor, null, $patient);
$this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}/status", $patient, [
'status' => Appointment::STATUS_CANCELLED_BY_USER,
'version' => $appointment->getVersion(),
]);
self::assertSame(200, $this->responseCode());
}
public function testStrangerIsDenied(): void
{
$doctor = $this->makeDoctor();
$appointment = $this->makeAppointment($doctor, null);
$stranger = $this->createUser();
$this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}", $stranger);
self::assertSame(403, $this->responseCode());
}
}
+251
View File
@@ -0,0 +1,251 @@
<?php
namespace App\Tests\Patient;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\Auth\Repository\UserActiveContextRepository;
use App\Clinic\Entity\Clinic;
use App\Clinic\Repository\ClinicDoctorPermissionRepository;
use App\Doctor\Entity\Doctor;
use App\Patient\Entity\PatientRecord;
use App\Secretary\Entity\DoctorSecretary;
use App\Tests\ApiTestCase;
/**
* پروندهٔ کلینیکی per-بیمار است (یکتایی clinic+user) و مدیر کلینیک همه را می‌بیند.
* پزشکِ عضو هم باید پرونده‌های بیمارانِ خودش در همان کلینیک را ببیند رابطه از
* نوبت‌های همان پزشک در همان کلینیک می‌آید، نه از ستونی روی پرونده.
*
* با غیرفعال شدن پزشک، دسترسی‌اش قطع می‌شود ولی پرونده‌ها دست‌نخورده می‌مانند.
*/
class ClinicRecordAccessTest extends ApiTestCase
{
private function makeDoctor(string $name = 'دکتر تست'): Doctor
{
$user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$doctor = new Doctor($user, $name);
$doctor->setMobileNumber($user->getMobileNumber());
$this->em->persist($doctor);
$this->em->flush();
return $doctor;
}
/** @return array{0: User, 1: Clinic} */
private function makeClinicWith(Doctor ...$doctors): array
{
$owner = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($owner);
$clinic->setName('کلینیک تست');
foreach ($doctors as $d) {
$clinic->getDoctors()->add($d);
}
$this->em->persist($clinic);
$this->em->flush();
return [$owner, $clinic];
}
private function activeContext(User $user, string $dbUuid): void
{
static::getContainer()->get(UserActiveContextRepository::class)->upsert($user, $dbUuid);
}
/** پروندهٔ کلینیکی بیمار + نوبتی که او را به این پزشک وصل می‌کند. */
private function makeClinicRecordFor(Clinic $clinic, Doctor $doctor, ?User $patient = null): PatientRecord
{
$patient ??= $this->createUser();
$record = new PatientRecord('clinic', $clinic->getId(), $patient, 'system', $clinic->getId());
$this->em->persist($record);
$start = strtotime('+60 days') + random_int(0, 500_000) * 7;
$appointment = new Appointment($doctor, $patient, $start, $start + 900);
$appointment->setClinic($clinic);
$this->em->persist($appointment);
$this->em->flush();
return $record;
}
private function uuidsFromList(array $response): array
{
return array_map(fn(array $row) => $row['uuid'], $response['data'] ?? []);
}
// ── پزشک عضو ─────────────────────────────────────────────────────────────
public function testMemberDoctorSeesOwnPatientsClinicRecord(): void
{
$doctor = $this->makeDoctor();
[, $clinic] = $this->makeClinicWith($doctor);
$record = $this->makeClinicRecordFor($clinic, $doctor);
$this->activeContext($doctor->getUser(), $clinic->getUuid());
$res = $this->authJson('GET', '/api/v1/patients?limit=50', $doctor->getUser());
self::assertSame(200, $this->responseCode());
self::assertContains($record->getUuid(), $this->uuidsFromList($res));
}
public function testMemberDoctorCannotSeeAnotherDoctorsClinicRecord(): void
{
$mine = $this->makeDoctor('پزشک من');
$theirs = $this->makeDoctor('پزشک دیگر');
[, $clinic] = $this->makeClinicWith($mine, $theirs);
$foreign = $this->makeClinicRecordFor($clinic, $theirs);
$this->activeContext($mine->getUser(), $clinic->getUuid());
$res = $this->authJson('GET', '/api/v1/patients?limit=50', $mine->getUser());
self::assertNotContains($foreign->getUuid(), $this->uuidsFromList($res));
$this->authJson('GET', "/api/v1/patient/{$foreign->getUuid()}", $mine->getUser());
self::assertSame(404, $this->responseCode(), 'پروندهٔ بیمارِ پزشک دیگر برای او وجود ندارد');
}
public function testMemberDoctorCanOpenAndManageOwnPatientRecord(): void
{
$doctor = $this->makeDoctor();
[, $clinic] = $this->makeClinicWith($doctor);
$record = $this->makeClinicRecordFor($clinic, $doctor);
$this->activeContext($doctor->getUser(), $clinic->getUuid());
$this->authJson('GET', "/api/v1/patient/{$record->getUuid()}", $doctor->getUser());
self::assertSame(200, $this->responseCode());
$this->authJson('POST', "/api/v1/patient/{$record->getUuid()}/note", $doctor->getUser(), [
'body' => 'یادداشت پزشک عضو',
]);
self::assertSame(201, $this->responseCode(), 'پزشک عضو فعال پرونده را مدیریت هم می‌کند');
}
public function testDoctorInPersonalContextSeesOnlyOwnOfficeRecords(): void
{
$doctor = $this->makeDoctor();
[, $clinic] = $this->makeClinicWith($doctor);
$clinicRecord = $this->makeClinicRecordFor($clinic, $doctor);
// بدون محیط فعالِ کلینیک ⇒ مطب شخصی.
$this->activeContext($doctor->getUser(), $doctor->getUuid());
$res = $this->authJson('GET', '/api/v1/patients?limit=50', $doctor->getUser());
self::assertSame(200, $this->responseCode());
self::assertNotContains($clinicRecord->getUuid(), $this->uuidsFromList($res));
}
// ── پایان همکاری ─────────────────────────────────────────────────────────
public function testDeactivatedDoctorLosesClinicRecordsButOwnerKeepsThem(): void
{
$doctor = $this->makeDoctor();
[$owner, $clinic] = $this->makeClinicWith($doctor);
$record = $this->makeClinicRecordFor($clinic, $doctor);
$this->activeContext($doctor->getUser(), $clinic->getUuid());
$this->authJson('GET', "/api/v1/patient/{$record->getUuid()}", $doctor->getUser());
self::assertSame(200, $this->responseCode(), 'پزشک فعال دسترسی دارد');
$permissions = static::getContainer()->get(ClinicDoctorPermissionRepository::class);
$permissions->getOrCreate($clinic, $doctor)->setActive(false);
$this->em->flush();
$this->authJson('GET', "/api/v1/patient/{$record->getUuid()}", $doctor->getUser());
self::assertSame(404, $this->responseCode(), 'بعد از پایان همکاری، دسترسی قطع می‌شود');
$this->authJson('GET', "/api/v1/patient/{$record->getUuid()}", $owner);
self::assertSame(200, $this->responseCode(), 'مدیر کلینیک دسترسی کامل دارد');
$this->em->clear();
self::assertNotNull(
$this->em->getRepository(PatientRecord::class)->find($record->getId()),
'پرونده حذف یا منتقل نمی‌شود',
);
}
// ── مدیر کلینیک ──────────────────────────────────────────────────────────
public function testClinicOwnerSeesEveryDoctorsRecords(): void
{
$first = $this->makeDoctor('پزشک اول');
$second = $this->makeDoctor('پزشک دوم');
[$owner, $clinic] = $this->makeClinicWith($first, $second);
$firstRecord = $this->makeClinicRecordFor($clinic, $first);
$secondRecord = $this->makeClinicRecordFor($clinic, $second);
$res = $this->authJson('GET', '/api/v1/patients?limit=50', $owner);
$uuids = $this->uuidsFromList($res);
self::assertSame(200, $this->responseCode());
self::assertContains($firstRecord->getUuid(), $uuids);
self::assertContains($secondRecord->getUuid(), $uuids);
}
// ── منشی ─────────────────────────────────────────────────────────────────
public function testClinicSecretaryIsLimitedToAssignedDoctors(): void
{
$mine = $this->makeDoctor('پزشک من');
$theirs = $this->makeDoctor('پزشک دیگر');
[, $clinic] = $this->makeClinicWith($mine, $theirs);
$ownRecord = $this->makeClinicRecordFor($clinic, $mine);
$foreign = $this->makeClinicRecordFor($clinic, $theirs);
$secretaryUser = $this->createUser(['ROLE_USER', 'ROLE_SECRETARY']);
$relation = new DoctorSecretary($mine, $secretaryUser, DoctorSecretary::OWNER_CLINIC, $clinic);
$this->em->persist($relation);
$this->em->flush();
$this->activeContext($secretaryUser, $clinic->getUuid());
$res = $this->authJson('GET', '/api/v1/patients?limit=50', $secretaryUser);
$uuids = $this->uuidsFromList($res);
self::assertSame(200, $this->responseCode());
self::assertContains($ownRecord->getUuid(), $uuids);
self::assertNotContains($foreign->getUuid(), $uuids);
}
// ── قطعی‌کردن نوبت کلینیکی، دیده‌شده توسط هر دو نقش ───────────────────────
public function testConfirmedClinicAppointmentRecordIsVisibleToBothRoles(): void
{
$doctor = $this->makeDoctor();
[$owner, $clinic] = $this->makeClinicWith($doctor);
$patient = $this->createUser();
$start = strtotime('+70 days') + random_int(0, 500_000) * 7;
$appointment = new Appointment($doctor, $patient, $start, $start + 900);
$appointment->setClinic($clinic);
$appointment->setVisitPriceRials(3_000_000);
$this->em->persist($appointment);
$this->em->flush();
// محیط فعال را قبل از confirm ست کن: آن درخواست EntityManager را پاک می‌کند.
$this->activeContext($doctor->getUser(), $clinic->getUuid());
$this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $owner, [
'version' => $appointment->getVersion(),
]);
self::assertSame(200, $this->responseCode());
$record = $this->em->getRepository(PatientRecord::class)->findOneBy([
'entityType' => 'clinic',
'entityId' => $clinic->getId(),
'user' => $patient,
]);
self::assertNotNull($record);
$this->authJson('GET', "/api/v1/patient/{$record->getUuid()}", $owner);
self::assertSame(200, $this->responseCode(), 'مدیر کلینیک');
$this->authJson('GET', "/api/v1/patient/{$record->getUuid()}", $doctor->getUser());
self::assertSame(200, $this->responseCode(), 'پزشکِ همان نوبت');
}
}