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
+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.