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:
@@ -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` تکیه دارد).
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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="توضیحات..."
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(), 'پزشکِ همان نوبت');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user