Files
clinicpro/.claude/prompt/appointment-confirm-flow.md
T
hamedandClaude Opus 4.8 7921407f33 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>
2026-07-18 21:04:50 +03:30

105 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# فرآیند ثبت و قطعی کردن نوبت (مودال پرداخت + پرونده)
## پروژه
`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`؛ طراحی مودال با تم/کلاس‌های موجود پنل، بدون طراحی جدید.