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

11 KiB

فرآیند ثبت و قطعی کردن نوبت (مودال پرداخت + پرونده)

پروژه

clinicpro (backend + پنل ادمین) — پیش‌نیاز: clinic-appointment-operations-fix.md اجرا شده باشد.

زمینه

وضعیت‌ها همین حالا وجود دارند: pending = «ثبت شده»، confirmed = «قطعی شده» (turnStatus.ts). زیرساخت پرونده هم هست: با confirm شدن نوبت، AppointmentConfirmationService::onConfirmedPatientService::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)

وضعیت فعلی

// MyAppointmentsController (~192): نوبت پنلی بلافاصله confirmed
$appointment->setStatus(Appointment::STATUS_CONFIRMED);

// PaymentManager (~313): نوبت سایت بعد از پرداخت درگاه confirmed می‌شود (درست است، حفظ شود)
// AppointmentRepository::expireLapsedPending: pending های کهنه را expire می‌کند (TTL رزرو آنلاین ۱۵ دقیقه)
// 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 پرامپت قبلی):

// 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_LABELSPriceInput تومان، امکان چند ردیف پرداخت یا یک ردیف با مبلغ دلخواه؛ دکمه میان‌بر «پرداخت کامل».
    • خلاصه شفاف: پرداخت‌شده / باقی‌مانده / وضعیت (تسویه کامل، پرداخت جزئی، بدون پرداخت).
    • تأیید → 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؛ طراحی مودال با تم/کلاس‌های موجود پنل، بدون طراحی جدید.